About laravel-controllers
A Laravel controller design guide for developers, keeping controllers focused on HTTP concerns while delegating domain logic to Actions. It also covers REST conventions, web and public API organization, query objects, and route model binding.
Key features
- 01Keep controllers thin
- 02Use standard REST methods
- 03Move non-REST actions to invokable controllers
- 04Separate web and versioned API controllers
- 05Use query objects for API queries
- 06Apply authorization and model binding patterns
Use cases
- 01Choose resource controller names and methods
- 02Move business rules out of controllers
- 03Organize web and public API controllers
- 04Design API filtering, sorting, and includes
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ leeovery/ agentic-skills --skill laravel-controllersAfter installing, type/laravel-controllersin Claude Code to confirm it loaded.
Bundled filesreferences/query-objects.md
Details
From the project READMELaravel Controllers
Controllers are extremely thin. They handle HTTP concerns only and contain zero domain logic.
Related guides:
- query-objects.md - Query objects for API filtering/sorting
- Actions - Actions contain the domain logic
- form-requests.md - Validation layer
- DTOs - DTOs for data transfer
- structure.md - Web vs API organization
Philosophy
Controllers should ONLY:
- Type-hint dependencies
- Validate (via Form Requests)
- Call actions
- Return responses (resources, redirects, views)
Controllers should NEVER:
- Contain domain logic
- Make database queries directly
- Perform calculations
- Handle complex business rules
Controller Naming Conventions
Controllers should be named using the PLURAL form of the main resource:
Standard Resource Controllers
// ✅ CORRECT - Plural resource names
CalendarsController // manages calendar resources
EventsController // manages event resources
OrdersController // manages order resources
UsersController // manages user resources
// ❌ INCORRECT - Singular form
CalendarController
EventController
Nested Resource Controllers
For nested resources, combine both resource names (parent + child):
// Route: /calendars/{calendar}/events
CalendarEventsController // manages events within a calendar
// Route: /orders/{order}/items
OrderItemsController // manages items within an order
Pattern: {ParentSingular}{ChildPlural}Controller
Routes:
// Standard resource routes
Route::resource('calendars', CalendarsController::class);
// Nested resource routes
Route::resource('calendars.events', CalendarEventsController::class);
RESTful Methods Only
Controllers must only use Laravel's standard RESTful method names.
Standard RESTful Methods
For web applications (with forms):
index- Display a listing of the resourcecreate- Show the form for creating a new resourcestore- Store a newly created resourceshow- Display the specified resourceedit- Show the form for editing the resourceupdate- Update the specified resourcedestroy- Remove the specified resource
For APIs (no form views):
index,show,store,update,destroy- APIs must NOT include
createoreditmethods (those are for HTML forms)
Forbidden Method Names
Never use custom method names in resource controllers:
// ❌ INCORRECT
class OrdersController extends Controller
{
public function all() { } // Use index
public function get() { } // Use show
public function add() { } // Use store
public function remove() { } // Use destroy
public function cancel() { } // Extract to CancelOrderController
}
Non-RESTful Actions: Extract to Invokable Controllers
If you need an endpoint that doesn't fit standard RESTful methods, extract it to its own invokable controller:
// app/Http/Api/V1/Controllers/CancelOrderController.php
class CancelOrderController extends Controller
{
public function __invoke(
Order $order,
CancelOrderAction $action
): OrderResource {
$order = $action($order);
return OrderResource::make($order);
}
}
Routes:
Route::apiResource('orders', OrdersController::class);
Route::post('/orders/{order:uuid}/cancel', CancelOrderController::class);
Why invokable controllers for non-RESTful actions?
- Single Responsibility Principle
- Clear intent from controller name
- Independently testable
- Prevents bloated resource controllers
Web Layer vs Public API
Web Layer Controllers
Purpose: Serve your application's web layer (API for separate frontend, Blade views, or Inertia)
Location: app/Http/Web/Controllers/
Routes: routes/web.php
Characteristics:
- Not versioned
- Can change freely
- Private (only your app consumes)
Public API Controllers
Purpose: For external/third-party consumption
Location: app/Http/Api/V1/Controllers/
Routes: routes/api/v1.php
Characteristics:
- Versioned (
/api/v1,/api/v2) - Stable contract
- Breaking changes require new version
Key difference: Namespace (Http\Web vs Http\Api\V1). Controller structure is identical.
Full Controller Example
<?php
declare(strict_types=1);
namespace App\Http\Web\Controllers;
use App\Actions\Order\CreateOrderAction;
use App\Actions\Order\DeleteOrderAction;
use App\Actions\Order\UpdateOrderAction;
use App\Data\Transformers\Web\OrderDataTransformer;
use App\Http\Controllers\Controller;
use App\Http\Web\Queries\OrderIndexQuery;
use App\Http\Web\Requests\CreateOrderRequest;
use App\Http\Web\Requests\UpdateOrderRequest;
use App\Http\Web\Resources\OrderResource;
use App\Models\Order;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class OrdersController extends Controller
{
public function index(OrderIndexQuery $query): AnonymousResourceCollection
{
return OrderResource::collection($query->jsonPaginate());
}
public function show(Order $order): OrderResource
{
return OrderResource::make($order->load('items', 'customer'));
}
public function store(
CreateOrderRequest $request,
CreateOrderAction $action
): OrderResource {
$order = $action(
user(),
OrderDataTransformer::fromRequest($request)
);
return OrderResource::make($order);
}
public function update(
UpdateOrderRequest $request,
Order $order,
UpdateOrderAction $action
): OrderResource {
$order = $action(
$order,
OrderDataTransformer::fromRequest($request)
);
return OrderResource::make($order);
}
public function destroy(
Order $order,
DeleteOrderAction $action
): Response {
$action($order);
return response()->noContent();
}
}
For API controllers: Same structure, different namespace (App\Http\Api\V1\Controllers).
Query Objects
For API filtering, sorting, and includes, use Query Objects with Spatie Query Builder:
public function index(OrderIndexQuery $query): AnonymousResourceCollection
{
return OrderResource::collection($query->jsonPaginate());
}
→ Complete query objects guide: query-objects.md
Authorization
public function store(
CreateOrderRequest $request,
CreateOrderAction $action
): OrderResource {
$this->authorize('create', Order::class);
$order = $action(user(), OrderDataTransformer::fromRequest($request));
return OrderResource::make($order);
}
Or use route middleware:
Route::post('/orders', [OrdersController::class, 'store'])
->can('create', Order::class);
Route Model Binding
Use route model binding for cleaner controllers:
Route::get('/orders/{order}', [OrdersController::class, 'show']);
Route::get('/orders/{order:uuid}', [OrdersController::class, 'show']); // Custom key
Controller automatically receives model:
public function show(Order $order): OrderResource
{
return OrderResource::make($order->load('items', 'customer'));
}
Controller Testing
Feature tests for controllers:
use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson;
it('creates an order', function () {
$user = User::factory()->create();
$data = CreateOrderData::testFactory()->make();
actingAs($user)
->postJson('/orders', $data->toArray())
->assertCreated()
->assertJsonStructure(['data' => ['id', 'status']]);
});
it('requires authentication', function () {
postJson('/orders', [])->assertUnauthorized();
});
it('validates required fields', function () {
actingAs(User::factory()->create())
->postJson('/orders', [])
->assertUnprocessable()
->assertJsonValidationErrors(['customer_email', 'items']);
});
Common Mistakes
❌ Domain Logic in Controller
// BAD
public function store(Request $request)
{
$order = Order::create($request->validated());
$order->items()->createMany($request->items);
$total = $order->items->sum('total');
$order->update(['total' => $total]);
}
✅ Delegate to Action
// GOOD
public function store(
CreateOrderRequest $request,
CreateOrderAction $action
): OrderResource {
$order = $action(
user(),
OrderDataTransformer::fromRequest($request)
);
return OrderResource::make($order);
}
❌ Database Queries in Controller
// BAD
public function index()
{
$orders = Order::with('items')
->where('status', 'pending')
->latest()
->paginate();
}
✅ Use Query Object
// GOOD
public function index(OrderIndexQuery $query): AnonymousResourceCollection
{
return OrderResource::collection($query->jsonPaginate());
}