skills/leeovery/agentic-skills/laravel-controllers

laravel-controllers

Installation
SKILL.md

Laravel Controllers

Controllers are extremely thin. They handle HTTP concerns only and contain zero domain logic.

Related guides:

Philosophy

Controllers should ONLY:

  1. Type-hint dependencies
  2. Validate (via Form Requests)
  3. Call actions
  4. 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 resource
  • create - Show the form for creating a new resource
  • store - Store a newly created resource
  • show - Display the specified resource
  • edit - Show the form for editing the resource
  • update - Update the specified resource
  • destroy - Remove the specified resource

For APIs (no form views):

  • index, show, store, update, destroy
  • APIs must NOT include create or edit methods (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());
}
Weekly Installs
14
GitHub Stars
5
First Seen
1 day ago