Laravel Core

Controller Standards

Responsibilities

Controllers should be thin and focused:

  • Handle HTTP requests and return HTTP responses
  • Validate input via Form Requests
  • Authorize actions via policies or gates
  • Delegate business logic to Actions, Batches, or Pipelines
  • Transform output via Resources (for APIs)
// Good - Thin controller
public function __invoke(StoreTicketRequest $request, CreateTicket $createTicket): RedirectResponse
{
    $ticket = $createTicket->execute(TicketData::from($request));

    return redirect()->route('tickets.show', $ticket);
}

// Bad - Fat controller with business logic
public function __invoke(Request $request): RedirectResponse
{
    $validated = $request->validate([...]);

    $ticket = new Ticket($validated);
    $ticket->user_id = auth()->id();
    $ticket->save();

    Mail::to($ticket->user)->send(new TicketCreated($ticket));
    Log::info('Ticket created', ['id' => $ticket->id]);

    return redirect()->route('tickets.show', $ticket);
}

File Organization

app/Http/Controllers/
├── Controller.php
├── DashboardController.php
├── Tickets/
│   ├── IndexController.php
│   ├── ShowController.php
│   ├── StoreController.php
│   ├── UpdateController.php
│   └── DestroyController.php
├── Api/
│   └── Tickets/
│       ├── IndexController.php
│       └── ShowController.php
└── Auth/
    ├── LoginController.php
    └── RegisterController.php

Naming Conventions

  • Use singular nouns for the resource
  • Use the Controller suffix
  • For single action controllers, name by action: StoreController, DestroyController
  • Group related controllers in subdirectories
// Good
DashboardController
Tickets/StoreController
Tickets/UpdateController
Auth/LoginController

// Bad
TicketsController          // Plural
CreateTicketController     // Redundant when in Tickets/ directory
TicketCreator             // Missing Controller suffix

Single Action Controllers

Prefer single action controllers using __invoke:

<?php

declare(strict_types=1);

namespace App\Http\Controllers\Tickets;

use App\Actions\Tickets\CreateTicket;
use App\Data\TicketData;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tickets\StoreTicketRequest;
use Illuminate\Http\RedirectResponse;

final class StoreController extends Controller
{
    public function __invoke(
        StoreTicketRequest $request,
        CreateTicket $createTicket,
    ): RedirectResponse {
        $ticket = $createTicket->execute(TicketData::from($request));

        return redirect()
            ->route('tickets.show', $ticket)
            ->with('success', __('tickets.created'));
    }
}

When to Use Single Action Controllers

  • Each controller handles one action
  • Makes controllers easier to find and navigate
  • Follows single responsibility principle
  • Simplifies testing
// Route definitions for single action controllers
Route::get('/tickets', Tickets\IndexController::class)->name('tickets.index');
Route::get('/tickets/create', Tickets\CreateController::class)->name('tickets.create');
Route::post('/tickets', Tickets\StoreController::class)->name('tickets.store');
Route::get('/tickets/{ticket}', Tickets\ShowController::class)->name('tickets.show');
Route::get('/tickets/{ticket}/edit', Tickets\EditController::class)->name('tickets.edit');
Route::put('/tickets/{ticket}', Tickets\UpdateController::class)->name('tickets.update');
Route::delete('/tickets/{ticket}', Tickets\DestroyController::class)->name('tickets.destroy');

Resource Controllers

For simple CRUD without complex logic, resource controllers are acceptable:

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Category;
use App\Http\Requests\Categories\StoreCategoryRequest;
use App\Http\Requests\Categories\UpdateCategoryRequest;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;

final class CategoryController extends Controller
{
    public function index(): View
    {
        return view('categories.index', [
            'categories' => Category::ordered()->get(),
        ]);
    }

    public function store(StoreCategoryRequest $request): RedirectResponse
    {
        Category::create($request->validated());

        return redirect()
            ->route('categories.index')
            ->with('success', __('categories.created'));
    }

    public function update(UpdateCategoryRequest $request, Category $category): RedirectResponse
    {
        $category->update($request->validated());

        return redirect()
            ->route('categories.index')
            ->with('success', __('categories.updated'));
    }

    public function destroy(Category $category): RedirectResponse
    {
        $category->delete();

        return redirect()
            ->route('categories.index')
            ->with('success', __('categories.deleted'));
    }
}

Parameter Order

Order method parameters consistently:

  1. Route model bindings
  2. Form Request
  3. Injected dependencies
// Good
public function __invoke(
    Ticket $ticket,                    // 1. Route model binding
    UpdateTicketRequest $request,      // 2. Form Request
    UpdateTicket $updateTicket,        // 3. Dependencies
): RedirectResponse {
    // ...
}

// Bad - Inconsistent order
public function __invoke(
    UpdateTicket $updateTicket,
    UpdateTicketRequest $request,
    Ticket $ticket,
): RedirectResponse {
    // ...
}

Form Requests

Always use Form Requests for validation:

<?php

declare(strict_types=1);

namespace App\Http\Requests\Tickets;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class StoreTicketRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // Or use policies
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string', 'min:10'],
            'priority' => ['required', Rule::in(['low', 'medium', 'high'])],
            'category_id' => ['nullable', 'exists:categories,id'],
            'tags' => ['nullable', 'array'],
            'tags.*' => ['exists:tags,id'],
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => __('validation.tickets.title_required'),
            'body.min' => __('validation.tickets.body_min'),
        ];
    }
}

Validation Rules Format

Use array syntax, not pipe syntax:

// Good - Array syntax
'email' => ['required', 'string', 'email', 'max:255'],
'status' => ['required', Rule::in(Status::cases())],

// Bad - Pipe syntax
'email' => 'required|string|email|max:255',

Authorization

In Form Request

public function authorize(): bool
{
    return $this->user()->can('update', $this->route('ticket'));
}

In Controller

public function __invoke(Ticket $ticket): View
{
    $this->authorize('view', $ticket);

    return view('tickets.show', compact('ticket'));
}

Using Middleware

// In routes
Route::get('/admin', AdminController::class)->can('access-admin');

// Or in controller constructor
public function __construct()
{
    $this->middleware('can:manage-tickets');
}

Responses

Web Responses

// Return a view
return view('tickets.show', [
    'ticket' => $ticket,
    'comments' => $ticket->comments()->latest()->get(),
]);

// Redirect to named route
return redirect()->route('tickets.index');

// Redirect with flash message
return redirect()
    ->route('tickets.show', $ticket)
    ->with('success', __('tickets.created'));

// Redirect back with errors
return back()->withErrors([
    'email' => __('auth.failed'),
])->onlyInput('email');

// Redirect back with input
return back()->withInput();

API Responses

// Return resource
return new TicketResource($ticket);

// Return collection
return TicketResource::collection($tickets);

// Return with status code
return response()->json(
    new TicketResource($ticket),
    Response::HTTP_CREATED
);

// No content
return response()->noContent();

// Error response
return response()->json([
    'message' => 'Ticket not found.',
], Response::HTTP_NOT_FOUND);

API Controllers

<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\Tickets;

use App\Actions\Tickets\CreateTicket;
use App\Data\TicketData;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\StoreTicketRequest;
use App\Http\Resources\TicketResource;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

final class StoreController extends Controller
{
    public function __invoke(
        StoreTicketRequest $request,
        CreateTicket $createTicket,
    ): JsonResponse {
        $ticket = $createTicket->execute(TicketData::from($request));

        return response()->json(
            new TicketResource($ticket),
            Response::HTTP_CREATED
        );
    }
}

Route Model Binding

Implicit Binding

// Route: /tickets/{ticket}
public function __invoke(Ticket $ticket): View
{
    return view('tickets.show', compact('ticket'));
}

Custom Key

// Route: /tickets/{ticket:hash_id}
public function __invoke(Ticket $ticket): View
{
    return view('tickets.show', compact('ticket'));
}

// Or in model
public function getRouteKeyName(): string
{
    return 'hash_id';
}

Scoped Binding

// Route: /users/{user}/tickets/{ticket}
// Ensures ticket belongs to user
Route::get('/users/{user}/tickets/{ticket}', ShowController::class)
    ->scopeBindings();

Testing Controllers

<?php

declare(strict_types=1);

namespace Tests\Feature\Controllers\Tickets;

use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

final class StoreControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_authenticated_user_can_create_ticket(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)
            ->post(route('tickets.store'), [
                'title' => 'Test Ticket',
                'body' => 'This is the ticket body content.',
                'priority' => 'medium',
            ]);

        $response->assertRedirect(route('tickets.show', Ticket::first()));
        $response->assertSessionHas('success');

        $this->assertDatabaseHas('tickets', [
            'title' => 'Test Ticket',
            'user_id' => $user->id,
        ]);
    }

    public function test_validation_errors_are_returned(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)
            ->post(route('tickets.store'), [
                'title' => '',
                'body' => 'short',
            ]);

        $response->assertSessionHasErrors(['title', 'body', 'priority']);
    }

    public function test_guest_cannot_create_ticket(): void
    {
        $response = $this->post(route('tickets.store'), [
            'title' => 'Test Ticket',
            'body' => 'This is the ticket body content.',
            'priority' => 'medium',
        ]);

        $response->assertRedirect(route('login'));
    }
}

Best Practices

Keep Controllers Thin

// Good - Controller only handles HTTP concerns
public function __invoke(
    StoreOrderRequest $request,
    ProcessOrder $processOrder,
): RedirectResponse {
    $order = $processOrder->execute(OrderData::from($request));

    return redirect()->route('orders.show', $order);
}

Use Dependency Injection

// Good - Inject via method
public function __invoke(
    Ticket $ticket,
    CloseTicket $closeTicket,
): RedirectResponse {
    $closeTicket->execute($ticket);

    return redirect()->route('tickets.show', $ticket);
}

// Avoid - Using app() helper
public function __invoke(Ticket $ticket): RedirectResponse
{
    app(CloseTicket::class)->execute($ticket);

    return redirect()->route('tickets.show', $ticket);
}

Type All Returns

// Good
public function __invoke(Ticket $ticket): View
public function __invoke(StoreRequest $request): RedirectResponse
public function __invoke(Ticket $ticket): JsonResponse

// Bad - Missing return type
public function __invoke(Ticket $ticket)