implementing-controllers

Use when creating HTTP controllers, defining routes, implementing API endpoints, setting up Inertia page rendering, or organizing controller concerns in a DDD bounded context.

Overview

Controllers follow the ADR (Action-Domain-Responder) pattern — one action per invokable controller. They live in the Infrastructure layer, receive validated input from Form Requests, delegate to use cases, and return responses. Controllers never contain business logic.


Invokable Controller (Standard Pattern)

Web Controller (Inertia Rendering)

declare(strict_types=1);

namespace App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers;

use App\CustomerRelationshipManagement\Contacts\Application\FindContact;
use App\CustomerRelationshipManagement\Contacts\Domain\Contracts\ContactRepository;
use App\CustomerRelationshipManagement\Contacts\Domain\Exceptions\ContactNotFound;
use Inertia\Inertia;
use Inertia\Response;

class ShowContactController
{
    private FindContact $finder;

    public function __construct(ContactRepository $repository)
    {
        $this->finder = new FindContact($repository);
    }

    /**
     * @throws ContactNotFound
     */
    public function __invoke(string $id): Response
    {
        return Inertia::render('Contacts/Show', [
            'contact' => $this->finder->byId($id),
        ]);
    }
}

Web Controller (Create/Update Action)

declare(strict_types=1);

namespace App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers;

use App\CustomerRelationshipManagement\Contacts\Application\CreateContact;
use App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Requests\ContactRequest;
use Illuminate\Http\RedirectResponse;

class CreateContactController
{
    public function __construct(private CreateContact $creator) {}

    public function __invoke(ContactRequest $request): RedirectResponse
    {
        $contact = $this->creator->create($request->validated());

        return redirect()
            ->route('contacts.show', $contact)
            ->banner('Contact created successfully.');
    }
}

Web Controller (Delete Action)

declare(strict_types=1);

namespace App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers;

use App\CustomerRelationshipManagement\Contacts\Application\DeleteContact;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class DeleteContactController
{
    public function __construct(private DeleteContact $deleter) {}

    public function __invoke(Request $request, string $id): RedirectResponse
    {
        $this->deleter->delete($id);

        return redirect()
            ->route('contacts.index')
            ->banner('Contact deleted.');
    }
}

API Controller (JSON Response)

declare(strict_types=1);

namespace App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers\API;

use App\CustomerRelationshipManagement\Contacts\Application\FindContact;
use App\CustomerRelationshipManagement\Contacts\Domain\Contracts\ContactRepository;
use App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Requests\SearchContactRequest;
use Illuminate\Http\JsonResponse;

class SearchContactController
{
    private FindContact $finder;

    public function __construct(ContactRepository $repository)
    {
        $this->finder = new FindContact($repository);
    }

    public function __invoke(SearchContactRequest $request): JsonResponse
    {
        return response()->json(
            $this->finder->byCriteria($request->criteria())
        );
    }
}

Naming Convention

Controllers follow {Verb}{Entity}Controller with one action per class:

ActionController NameHTTP Method
List / IndexListContactsControllerGET
ShowShowContactControllerGET
Create (form)CreateContactControllerGET
StoreStoreContactControllerPOST
Edit (form)EditContactControllerGET
UpdateUpdateContactControllerPUT/PATCH
DeleteDeleteContactControllerDELETE
Search (API)SearchContactControllerPOST

Exception: Simple CRUD tokens or similar trivial resources may use a traditional resource controller with index, store, update, destroy methods. This should be rare — the ADR pattern is the default.


Directory Structure

{Module}/Infrastructure/Http/
├── Controllers/
│   ├── ListContactsController.php
│   ├── ShowContactController.php
│   ├── CreateContactController.php
│   ├── UpdateContactController.php
│   ├── DeleteContactController.php
│   └── API/
│       ├── SearchContactController.php
│       └── ListContactsController.php
├── Requests/
│   ├── ContactRequest.php
│   └── SearchContactRequest.php
└── (routes loaded from context-level Shared)

Web controllers in Controllers/, API controllers in Controllers/API/. Both delegate to the same use cases.


Route Organization

Routes are organized at the bounded context level, not per module:

{BoundedContext}/Shared/Infrastructure/Http/Routes/
├── web.php
└── api.php

Web Routes

use App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers\ListContactsController;
use App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers\ShowContactController;
use App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers\CreateContactController;
use App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers\DeleteContactController;

Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('/contacts', ListContactsController::class)->name('contacts.index');
    Route::get('/contacts/{id}', ShowContactController::class)->name('contacts.show');
    Route::post('/contacts', CreateContactController::class)->name('contacts.store');
    Route::delete('/contacts/{id}', DeleteContactController::class)->name('contacts.destroy');
});

API Routes

use App\CustomerRelationshipManagement\Contacts\Infrastructure\Http\Controllers\API\SearchContactController;

Route::middleware(['auth:sanctum'])->group(function () {
    Route::post('/contacts/search', SearchContactController::class);
});

Loading Context Routes

The bounded context ServiceProvider loads routes during boot. See configuring-bounded-contexts for the full ServiceProvider setup.


Base Controllers

The Shared Kernel provides minimal base controllers:

Abstract Base Controller

declare(strict_types=1);

namespace App\Shared\Infrastructure\Http\Controllers;

use Illuminate\Routing\Controller as BaseController;

abstract class Controller extends BaseController
{
    protected function localizedMarkdownPath(string $path): string
    {
        // Resolves localized markdown file paths for static pages
    }
}

Inertia Base Controller

declare(strict_types=1);

namespace App\Shared\Infrastructure\Http\Controllers;

use Inertia\Inertia;
use Inertia\Response;

abstract class InertiaController extends Controller
{
    protected function render(string $component, array $props = []): Response
    {
        return Inertia::render($component, $props);
    }
}

Base controllers provide utilities, not behavior. Most controllers don't need to extend them — only use when the helper methods are needed (e.g., static page controllers, Inertia-heavy controllers).


Controller Concerns

Shared traits for sensitive operations:

declare(strict_types=1);

namespace App\Shared\Infrastructure\Http\Controllers\Concerns;

trait ConfirmsPasswords
{
    // Password confirmation flow for sensitive actions (delete account, change password)
}

trait ConfirmsTwoFactorAuthentication
{
    // Validates 2FA state before allowing access
}

Usage:

class DeleteAccountController
{
    use ConfirmsPasswords;

    public function __invoke(DeleteAccountRequest $request): RedirectResponse
    {
        $this->ensurePasswordConfirmed();

        $this->deleter->delete(id: $request->user()->id, owner: $request->user()->id);

        return redirect()->route('dashboard');
    }
}

Placement: app/Shared/Infrastructure/Http/Controllers/Concerns/


Constructor Injection

Controllers inject dependencies via constructor — primarily use cases, occasionally repositories:

DependencyWhen
Use caseAlways — controllers delegate to use cases
RepositoryWhen the controller needs to compose a finder use case
EventBusRare — only if the controller publishes events directly (prefer use cases)

Standard pattern — inject use case directly:

public function __construct(private CreateContact $creator) {}

Finder pattern — compose from repository:

public function __construct(ContactRepository $repository)
{
    $this->finder = new FindContact($repository);
}

This mirrors the use case composition pattern (see implementing-use-cases).


Response Patterns

Inertia Render (Page Display)

return Inertia::render('Contacts/Show', [
    'contact' => $contact,
    'sessions' => $user->sessions(),
]);

Redirect with Flash (After Mutation)

return redirect()
    ->route('contacts.index')
    ->banner('Contact created successfully.');

Flash macros (banner(), error()) are registered in the Shared ServiceProvider:

RedirectResponse::macro('banner', function (string $message) {
    return $this->with('flash.banner', $message);
});

JSON Response (API)

return response()->json($this->finder->byCriteria($request->criteria()));

Common Mistakes

MistakeFix
Business logic in controllersDelegate to use cases; controllers orchestrate only
Validation in controllersUse Form Request classes (see implementing-form-requests)
Multiple actions in one controllerOne controller = one __invoke() method (ADR pattern)
Injecting repositories instead of use casesInject use cases; repositories are internal to use cases
Putting API controllers in the same directory as webSeparate into Controllers/API/ subdirectory
Extending base controller unnecessarilyOnly extend when you need the utility methods
Route definitions inside module directoriesRoutes live at bounded context level in Shared/Infrastructure/Http/Routes/

Pre-flight Checklist

  • Controller class in {Module}/Infrastructure/Http/Controllers/?
  • Named as {Verb}{Entity}Controller?
  • Single __invoke() method (invokable pattern)?
  • Constructor injects use cases (not repositories directly)?
  • Form Request handles validation (not the controller)?
  • Web controller returns Inertia render or redirect?
  • API controller in Controllers/API/ subdirectory?
  • API controller returns JSON response?
  • Routes defined in bounded context's Shared/Infrastructure/Http/Routes/?
  • Controller concerns from Shared used for sensitive operations?

Laravel Reference

This skill uses Laravel-specific features. Consult the official Laravel documentation (via Context7 or web search) for details on:

  • Illuminate\Routing\Controller — base controller class
  • Invokable controllers — single-action controllers with __invoke()
  • Inertia\Inertia::render() — server-side rendering for Inertia.js
  • Illuminate\Http\RedirectResponse — redirect responses with flash data
  • Illuminate\Http\JsonResponse — JSON API responses
  • Route registration — Route::get(), Route::post(), Route::delete(), middleware groups
  • Illuminate\Http\Request — HTTP request handling
  • auth:sanctum middleware — API authentication via Laravel Sanctum