mcpField
L
laravel-controllers

laravel-controllers

github.com/leeoveryDevelopment4341 installsUpdated 31w ago
Repository

About laravel-controllers

A Laravel development skill that defines thin-controller conventions, keeping HTTP handling separate from domain logic. It is for developers structuring web and API controllers with Actions, Form Requests, and Query Objects.

Key features

  1. 01Controllers handle HTTP concerns only
  2. 02Delegate domain logic to Actions
  3. 03Use plural resource controller names
  4. 04Separate web and versioned API controllers
  5. 05Use Query Objects for API queries

Use cases

  1. 01Reviewing Laravel controller structure
  2. 02Designing resource and nested-resource routes
  3. 03Keeping business rules out of HTTP controllers

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/leeovery/claude-laravel --skill laravel-controllers

After installing, type/laravel-controllersin Claude Code to confirm it loaded.

Bundled filesreferences/query-objects.md

Details

From the project README

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);

Response Types

JSON Resource
public function show(Order $order): OrderResource
{
    return OrderResource::make($order);
}
Collection Resource
public function index(OrderIndexQuery $query): AnonymousResourceCollection
{
    return OrderResource::collection($query->jsonPaginate());
}
201 Created
return OrderResource::make($order)->response()->setStatusCode(201);
204 No Content
return response()->noContent();
Redirect
return redirect()->route('orders.show', $order);

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());
}

Summary

Controllers are HTTP adapters:

  1. Receive HTTP request
  2. Validate via Form Request
  3. Call Action (with DTO if needed)
  4. Return HTTP response via Resource

Every line of domain logic belongs in an Action, not a Controller.

Comments

Markdown supported