Key Concepts

Controllers

How to write thin controllers that only validate a request, build a DTO, call a Contract, and shape the response — no business logic, no model access.

A controller is the HTTP adapter for a domain. It takes a request, validates it through a typed FormRequest, hydrates a DTO, hands that DTO to a {Domain}Contract, and shapes whatever comes back into a response. That's the whole job.

Once a controller starts doing more — looking up Models, computing totals, orchestrating writes across multiple Contracts — that logic becomes the one place that knows the full flow, and it can never be reused by a job, a console command, or another entry point. Keeping controllers thin is what lets the same domain logic serve the Admin panel, the Customer API, a queued job, and a CLI command without duplication.

The codebase has two kinds of controllers with different output contracts. Both follow the same universal rules; the SSR and API kinds differ in what they return.

The two kinds

PanelKindReturnsResponse shape
AdminSSR (server-rendered Blade)View / RedirectResponseview('cp::...', [...]), redirect()->with('success'|'error', ...)
Business, Customer, Owner, ProviderAPI (JSON)ResponseBuilderres()->data(...) envelope from API responses
Pdfspecial (PDF stream)Response from mPDFbinary download
Common, Sharedshared helpers / abstract basesvariesvaries

The flow is the same end-to-end: HTTP request → FormRequest validation → DTO::fromArray() → app(DomainContract::class)->method($dto) → Resource → response. For SSR controllers the final step renders a view or issues a redirect; for API controllers it builds the res()->data(...) envelope.

The shape

class OrderController extends Controller
{
    public function __construct(
        private readonly OrderContract $orderContract,
    ) {}

    public function store(StoreOrderRequest $request): ResponseBuilder
    {
        $order = $this->orderContract->createOrder(
            CreateOrderData::fromArray($request->validated()),
        );

        return res(status: 201)->data(OrderResource::make($order));
    }
}

The constructor injects the domain Contract. The method type-hints a typed FormRequest for validation, builds a DTO from $request->validated(), calls a single method on the Contract, and returns a response envelope. There is nothing else.

Universal rules — every controller

Keep it thin

A controller maps HTTP to a domain call and back. No business logic, no DB queries, no transactions, no Model::find() lookups, no Eloquent calls of any kind. Everything beyond request → DTO → Contract → response-shape belongs in a domain Service or Action.

// ❌ Bad — fat controller, regardless of panel
class OrderController extends Controller
{
    public function store(StoreOrderRequest $request): ResponseBuilder
    {
        $provider = Provider::findOrFail($request->provider_id);     // model query
        $customer = User::findOrFail($request->customer_id);         // model query
        $order = Order::create([                                     // model write
            'provider_id' => $provider->id,
            'amount' => $request->amount,
        ]);
        foreach ($request->lines as $line) {                         // business logic
            $order->lines()->create($line);
        }
        return res()->data(OrderResource::make($order));
    }
}
// ✅ Good — thin: validated request → DTO → Contract → resource
class OrderController extends Controller
{
    public function __construct(
        private readonly OrderContract $orderContract,
    ) {}

    public function store(StoreOrderRequest $request): ResponseBuilder
    {
        $order = $this->orderContract->createOrder(
            CreateOrderData::fromArray($request->validated()),
        );

        return res(status: 201)->data(OrderResource::make($order));
    }
}

Constructor-inject the {Domain}Contract — and only Contracts

Controllers are the one place where constructor injection is the rule. Inject the relevant {Domain}Contract(s) via promoted properties. Never inject a concrete Service, an Action, a Model, or a repository — only Contracts.

public function __construct(
    private readonly OrderContract $orderContract,
    private readonly ProviderContract $providerContract,
) {}

This contradicts the rule on the domain side (services and contracts, actions), where dependencies are resolved with app(SomeContract::class). The split is intentional: controllers are framework-managed and their constructor is the natural DI seam; domain classes stay testable in isolation by avoiding constructor wiring.

Validate via a typed FormRequest — always

Every method that reads request data — yes, including index — goes through a typed FormRequest. Never type-hint a plain Illuminate\Http\Request and call $request->all() for writes. That bypasses validation and the schema.

// ❌ Bad
public function store(Request $request) {
    $data = CreateOrderData::fromArray($request->all());
}

// ✅ Good
public function store(StoreOrderRequest $request) {
    $data = CreateOrderData::fromArray($request->validated());
}

FormRequests live alongside their controller's panel:

  • src/Presentation/Admin/Requests/{Feature}/StoreFooRequest.php
  • src/Presentation/Business/Requests/{Feature}/IndexFooRequest.php

API index endpoints validate filters too — name the request Index{Entity}Request and define rules() for every queryable field.

No direct model access — go through the Contract

A controller never touches an Eloquent model. Not for reads (Order::find(...)), not for writes (Order::create(...)), not for relationship loading. If the Contract doesn't expose what you need, add a method to the Contract — don't reach around it.

// ❌ Bad
$order = Order::with('lines')->findOrFail($id);

// ✅ Good
$order = $this->orderContract->getOrder($id);

The bad version couples your controller to the Order Model's schema. The Contract is the boundary; everything behind it is free to change.

Building DTOs from the request

Filter DTOs — fromArray($request->validated() + [...overrides])

For list/index endpoints, build the Filter DTO with Filter{Entity}Data::fromArray($request->validated() + [...overrides]). Use $request->validated() from a typed FormRequest — yes, even on indexes — so the queryable surface is explicit. Overrides go after to enforce server-side defaults (current branch, current user, immutable scope).

$filters = FilterEmployeeData::fromArray([
    ...$request->validated(),                                  // user-supplied filters
    'winch_branch_id' => $request->user()->winch_branch_id,    // server enforced
    'is_active' => true,                                       // server enforced
    'pages_count' => 20,
]);

Server-enforced keys go after the spread so they override any matching key from the user. See DTOs for FilterData structure and Eloquent and queries for the matching scopeFilterBy macro.

Create / Update DTOs — fromArray($request->validated())

Writes go through a typed FormRequest, then Create{Entity}Data::fromArray($request->validated()). The Update{Entity}Data DTO is fully nullable so partial updates work — the matching Update{Entity} Action strips nulls before calling $entity->update(...).

public function store(StoreOrderRequest $request): ResponseBuilder
{
    $order = $this->orderContract->createOrder(
        CreateOrderData::fromArray($request->validated()),
    );

    return res(status: 201)->data(OrderResource::make($order));
}

public function update(UpdateOrderRequest $request, int $id): ResponseBuilder
{
    $order = $this->orderContract->updateOrder(
        $this->orderContract->getOrder($id),
        UpdateOrderData::fromArray($request->validated()),
    );

    return res()->data(OrderResource::make($order));
}

See DTOs for the full DTO rules.

SSR controllers — the Admin panel

Return types

Use one of:

  • \Illuminate\Contracts\View\View — for index, show, create, edit
  • \Illuminate\Http\RedirectResponse — for store, update, destroy after a successful write

Type-hint the return so the intent is explicit:

public function index(Request $request): View
public function store(StoreFooRequest $request): RedirectResponse
public function destroy(int $id): RedirectResponse

Index / show / create / edit — return a view with named data

public function index(IndexUserContractTypeRequest $request): View
{
    return view('cp::common.user_contracts.user_contract_type.index', [
        'userContractTypes' => $this->userContractTypeContract
            ->getUserContractTypes(FilterUserContractTypeData::fromArray([
                'document_type' => UserContractTypeDocumentType::CONTRACT,
                'sort_by_direction' => 'desc',
                ...$request->validated(),
            ]))
            ->pagination(),
    ]);
}

View keys use camelCase to match Blade conventions. Always paginate index lists via ->pagination() from the Get{Entity}s Action.

Store / update / destroy — redirect with a flash message

After a successful write, always redirect to a follow-up route and flash a status message:

public function store(UserContractTypeRequest $request): RedirectResponse
{
    $userContractType = $this->userContractTypeContract->create(
        CreateUserContractTypeData::fromArray([
            'document_type' => UserContractTypeDocumentType::CONTRACT,
            ...$request->validated(),
        ]),
    );

    return redirect(route('cp.user_contract_types.index'))
        ->with('success', __('dashboard.action_completed'));
}
Never return a view(...) from a write endpoint (store, update, destroy). That breaks the POST/redirect/GET pattern — refreshing the result page re-submits the form.

Authorization

For panel-wide permissions, gate at the route level via middleware (->middleware('can:cp.foo.show')). For per-record gates, call $this->authorize('update', $entity) inside the method or @can(...) in the Blade view. See the admin resource recipe for the full admin recipe (controller + routes + views + permissions + translations + sidebar).

API controllers — Business, Customer, Owner, Provider

Return type MUST be ResponseBuilder

Every API controller method declares : ResponseBuilder as its return type. This is non-negotiable — it's the project's API contract.

public function index(IndexVoucherRequest $request): ResponseBuilder
public function show(string $id): ResponseBuilder
public function store(StoreVoucherRequest $request): ResponseBuilder
public function update(UpdateVoucherRequest $request, int $id): ResponseBuilder
public function destroy(int $id): ResponseBuilder

ResponseBuilder implements Responsable, so Laravel converts it to a JSON response with the project envelope automatically. Never return a raw JsonResponse, an array, a Resource directly, or response()->json(...) from an API controller — that bypasses the envelope.

Build responses with res()

The res() helper (defined in app/helpers.php) is the only sanctioned way to construct a ResponseBuilder. Chain it fluently:

return res()->data(VoucherResource::make($voucher));                  // 200
return res(status: 201)->data(VoucherResource::make($voucher));       // 201
return res(status: 204);                                              // 204 no data
return res(status: 422)->withErrors(['amount' => 'must be positive']);
return res()->message('voucher.approved')->data(...);                 // localised message

Status code semantics

VerbSuccessStatus
GET /resource (list)200res()->data(Resource::collection(...))
GET /resource/{id}200res()->data(Resource::make(...))
POST /resource (create)201res(status: 201)->data(Resource::make(...))
PUT /resource/{id} (replace)200res()->data(Resource::make(...))
PATCH /resource/{id} (partial)200res()->data(Resource::make(...))
DELETE /resource/{id}204res(status: 204) (no body)
Validation error422handled automatically by FormRequest
Authorization failure403thrown via $this->authorize(...)
Not found404thrown via findOrFail inside the Action
A 201 on create and a 204 on destroy are the two status codes most often forgotten. Get them right.

Response shaping with API Resources

Wrap the domain entity in an API Resource from src/Presentation/{Panel}/Resources/. Use Resource::make(...) for single items and Resource::collection(...) for lists.

public function index(IndexVoucherRequest $request): ResponseBuilder
{
    $vouchers = $this->voucherContract->getVouchers(
        data: FilterVoucherData::fromArray([
            ...$request->validated(),
            'user_id' => $this->getCurrentBranch(),
            'user_type' => 'branch',
        ]),
        withRelations: ['paymentMethod', 'voucherable'],
    );

    return res()->data(
        IndexVoucherResource::collection($vouchers->pagination())
            ->additional([
                'totals' => [
                    'remaining_totals' => $vouchers->builder()->sum('remaining_total'),
                    'totals' => $vouchers->builder()->sum('total'),
                ],
            ]),
    );
}

Pagination metadata is added automatically when data() receives a paginated Resource collection — no manual pagination() call required in the controller.

See API responses for the envelope schema (status, data, pagination, errors) and the matching Pest assertions.

Location

Controllers live under src/Presentation/{Panel}/Controllers/{Feature}/{Feature}Controller.php. Each panel — Admin, Business, Common, Customer, Owner, Pdf, Provider, Shared — has its own subtree. A controller belongs to exactly one panel. If two panels need the same behaviour, lift the logic into the domain Service and call it from both. Never cross-include a controller from another panel.

For the Admin dashboard recipe (controller + routes + views + permissions + translations + sidebar menu), follow the admin resource recipe.

Anti-patterns to avoid

Universal (any panel)

  • Business logic in controller methods — orchestrating creates, calling multiple models, computing totals. Move it into an Action and expose it through the Service. (anti-patterns #5)
  • Direct model queries in controllersOrder::with(...)->find($id). Use the Contract. If no Contract method covers it, add one.
  • $request->all() straight into Model::create() or a write DTO — bypasses validation. Use a typed FormRequest, then $request->validated().
  • Type-hinting Illuminate\Http\Request on any endpoint that reads input — use a typed FormRequest, even for index.
  • Constructor-injecting an Action, a concrete Service, or a Model — only Contracts. Actions are static; Services are bound; Models are domain internals.

SSR-only (Admin panel)

  • Returning a view(...) from a write endpoint (store/update/destroy) — breaks POST/redirect/GET. Always redirect()->with('success'|'error', ...) after a write.
  • Returning View without a return type declaration — declare : View or : RedirectResponse so intent is explicit.
  • Skipping the flash message on redirect() — admins expect feedback. Use ->with('success', __('dashboard.action_completed')) or 'error' for failures.

API-only (Business, Customer, Owner, Provider)

  • Missing ResponseBuilder return type — every API method must declare : ResponseBuilder. Untyped returns break the envelope contract.
  • Returning a raw Resource, array, or response()->json(...) — bypasses the envelope. Wrap in res()->data(...).
  • Returning 200 on store — use 201. Forgetting this is the single most common API anti-pattern in this codebase.
  • Returning 200 with an empty body on destroy — use 204. The body should be empty.
  • Inline response()->json([...], 422) for validation errors — let the FormRequest's failedValidation return the project envelope automatically. Don't hand-roll error JSON.
  • Skipping IndexFooRequest for index endpoints — list endpoints accept user input (filters, sort, pagination); validate them.
  • Not localising messages — use res()->message('domain.key') instead of a hardcoded English string.