Controllers
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
| Panel | Kind | Returns | Response shape |
|---|---|---|---|
Admin | SSR (server-rendered Blade) | View / RedirectResponse | view('cp::...', [...]), redirect()->with('success'|'error', ...) |
Business, Customer, Owner, Provider | API (JSON) | ResponseBuilder | res()->data(...) envelope from API responses |
Pdf | special (PDF stream) | Response from mPDF | binary download |
Common, Shared | shared helpers / abstract bases | varies | varies |
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.phpsrc/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— forindex,show,create,edit\Illuminate\Http\RedirectResponse— forstore,update,destroyafter 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'));
}
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
| Verb | Success | Status |
|---|---|---|
GET /resource (list) | 200 | res()->data(Resource::collection(...)) |
GET /resource/{id} | 200 | res()->data(Resource::make(...)) |
POST /resource (create) | 201 | res(status: 201)->data(Resource::make(...)) |
PUT /resource/{id} (replace) | 200 | res()->data(Resource::make(...)) |
PATCH /resource/{id} (partial) | 200 | res()->data(Resource::make(...)) |
DELETE /resource/{id} | 204 | res(status: 204) (no body) |
| Validation error | 422 | handled automatically by FormRequest |
| Authorization failure | 403 | thrown via $this->authorize(...) |
| Not found | 404 | thrown via findOrFail inside the Action |
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 controllers —
Order::with(...)->find($id). Use the Contract. If no Contract method covers it, add one. -
$request->all()straight intoModel::create()or a write DTO — bypasses validation. Use a typed FormRequest, then$request->validated(). - Type-hinting
Illuminate\Http\Requeston any endpoint that reads input — use a typed FormRequest, even forindex. - 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. Alwaysredirect()->with('success'|'error', ...)after a write. - Returning
Viewwithout a return type declaration — declare: Viewor: RedirectResponseso 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
ResponseBuilderreturn 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 inres()->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'sfailedValidationreturn the project envelope automatically. Don't hand-roll error JSON. - Skipping
IndexFooRequestforindexendpoints — list endpoints accept user input (filters, sort, pagination); validate them. - Not localising messages — use
res()->message('domain.key')instead of a hardcoded English string.
What to read next
- Services and contracts — what the controller injects and calls.
- DTOs — how the typed payloads are shaped.
- Actions — what runs behind the Contract.
- API responses — the envelope, status codes, and Pest assertions.
- Anti-patterns — items #5 and #17 cover the most common controller mistakes.
DTOs
How to define typed payloads (DTOs) that flow between requests, Contracts, Actions, and Eloquent without a mapping layer.
RESTful API
The project's API contract — URL pattern, ResponseBuilder envelope, CRUD verb conventions, pagination, validation errors, and how to assert against responses in Pest.