RESTful API
When this handbook says "the project's RESTful API contract," it means one very specific thing: every JSON endpoint — on every panel, in every domain — returns the same envelope. A status block telling you what happened, a data payload with the resource, and (only when the result is paginated) a top-level pagination block. That shape is what every mobile and frontend client has built against; breaking it silently breaks them.
The envelope is produced in exactly one place: the ResponseBuilder class (src/Domain/Core/Helpers/ResponseBuilder.php). Controllers never call response()->json(...) directly. They build a ResponseBuilder through the res() helper, hand it a Resource, and Laravel converts it to JSON through the Responsable interface. That single chokepoint is what makes the contract enforceable.
The envelope
Every response — success or error, GET or DELETE, list or single resource — comes out of ResponseBuilder::toResponse() and looks like this:
{
"status": {
"code": 200,
"success": true,
"message": "OK",
"error_key": "ok"
},
"data": {}
}
The top-level keys are exactly status, data, and — only when the data is a LengthAwarePaginator — pagination. Nothing else lives at the root.
Inside status you get:
code— mirrors the HTTP status (200, 201, 204, 400, 422, …).success—truewhencode < 400,falseotherwise. Computed, not passed in.message— the human string. If you call->message('voucher.approved'), it's looked up inlang/{locale}/api.php; otherwise it falls back to the HTTP status text ("OK", "Created", "Not Found").error_key— a stable machine-readable slug. It's whatever string you passed to->message(...), or the lowercased HTTP status text. Clients branch on this, not onmessage.errors_list— present only when you call->withErrors([...]). Omitted otherwise.
A complete success response from store:
{
"status": {
"code": 201,
"success": true,
"message": "Created",
"error_key": "created"
},
"data": {
"id": 42,
"name": "Acme Logistics",
"mobile": "501234567",
"created_at": "2026-05-25T10:14:00.000000Z"
}
}
A complete error response built inside a controller via res()->message(...)->status(400)->withErrors([...]):
{
"status": {
"code": 400,
"success": false,
"message": "Customer cannot be deleted while waybills exist",
"error_key": "customer.has_waybills",
"errors_list": ["1 active waybill blocks deletion"]
},
"data": {}
}
->data(...), the envelope's data field is an empty object {}, not null. That's deliberate — clients can always destructure it safely.The res() helper
res() is defined in app/helpers.php (around line 45) and is the only sanctioned way to construct a ResponseBuilder. It's a thin factory:
function res(
int $status = 200,
?string $message = null,
array $headers = [],
array $replaceAttributesMessage = []
): ResponseBuilder {
return new ResponseBuilder($status, $message, $headers, $replaceAttributesMessage);
}
Everything else is fluent chaining on the returned builder:
return res()->data($resource); // 200 OK
return res(201)->data($resource); // 201 Created
return res(204); // 204 No Content, no body
return res()->message('voucher.approved')->data($v); // localised message
return res()->message('customer.has_waybills')
->status(400)
->withErrors(['1 active waybill blocks deletion']);
The status code is set once — either via res(201) at the call site or via ->status(400) later. Don't mix the two on the same builder.
CRUD endpoints
The patterns below come straight from the canonical CRUD controller (src/Presentation/Shared/Controllers/CompanyCustomer/CompanyCustomerController.php). Every API controller in the codebase should look like this — Form Request validates, DTO normalises, Contract executes, Resource shapes, res() envelops.
index — list (200)
public function index(IndexCustomerRequest $request): ResponseBuilder
{
return res()->data(
CustomerResource::collection(
$this->customerContract->getCustomers(
data: FilterCustomerData::fromArray($request->validated()),
withCount: ['senderWaybills', 'receiverWaybills'],
)->pagination()
)
);
}
Type-hint a typed FormRequest even for index — list endpoints take filters, sort, and pages_count, all of which need validation. The Action returns a GetDataAbstract instance; calling ->pagination() on it yields a LengthAwarePaginator, which is what triggers the top-level pagination block in the envelope (see Pagination below).
show — single record (200)
public function show(string $id): ResponseBuilder
{
return res()->data(
CustomerResource::make(
$this->customerContract->getCustomer($id)
->loadCount(['senderWaybills', 'receiverWaybills'])
)
);
}
getCustomer($id) goes through the Contract and returns the Model (or throws 404 via findOrFail inside the Action). loadCount(...) lazy-loads the relationship counts the Resource will read with whenCounted(...).
store — create (201)
public function store(Request $request): ResponseBuilder
{
$data = $request->validate(StoreCustomerRequest::rules());
return res(201)->data(
CustomerResource::make(
$this->customerContract->create(
CreateCustomerData::fromArray($data)
)
)
);
}
Two things stand out compared to a textbook FormRequest:
StoreCustomerRequestis a plain class with a staticrules()method, not aFormRequestsubclass. The controller calls$request->validate($rules)itself. This pattern is used when rules need parameters at call time (panel-specific scoping, owner IDs, etc.).storereturns 201, not 200. Forgetting this is the single most common API anti-pattern in code review.
update — modify (200)
public function update(string $id, Request $request): ResponseBuilder
{
$data = $request->validate(UpdateCustomerRequest::rules($id));
$customer = $this->customerContract->getCustomer($id);
return res()->data(
CustomerResource::make(
$this->customerContract->update(
customer: $customer,
data: UpdateCustomerData::fromArray($data),
)
)
);
}
Fetch the existing record through the Contract first — that's both the 404 check and the input to the update Action. The Update{Entity}Data DTO is fully nullable so partial updates work; the Action strips nulls before calling ->update().
destroy — delete (204)
public function destroy(string $id): ResponseBuilder
{
$customer = $this->customerContract->getCustomer($id);
$this->customerContract->delete($customer);
return res(204);
}
A successful destroy returns 204 No Content with no body. Don't call ->data(...) after res(204); don't return the deleted record. Clients expect an empty 204.
{"data": null} from destroy. Use res(204) and let the envelope be empty.Validation errors
When a FormRequest (or $request->validate(...)) fails, Laravel's validator throws a ValidationException before the controller body runs. The exception is converted to a 422 response by Laravel itself — it never passes through ResponseBuilder.
That means the validation error shape is Laravel's default, not the project envelope:
{
"message": "The given data was invalid.",
"errors": {
"mobile": ["The mobile field is required."],
"email": ["The email must be a valid email address."]
}
}
This is the one place in the API where the response is not enveloped. Tests assert against this shape directly — see Pest assertions below.
ValidationException and re-wrapping it. Clients already know the 422 shape; changing it is a breaking change for them. The asymmetry is intentional.Manual error responses
For any non-422 error the controller wants to return inside the envelope — a business-rule failure, an upstream conflict, a soft-deny — build it with res() directly:
public function destroy(string $id): ResponseBuilder
{
$customer = $this->customerContract->getCustomer($id);
if ($customer->waybills_count > 0) {
return res()
->message('customer.has_waybills')
->status(400)
->withErrors(["{$customer->waybills_count} active waybill(s) block deletion"]);
}
$this->customerContract->delete($customer);
return res(204);
}
->withErrors([...]) accepts a plain list; it shows up in the envelope as status.errors_list. Use it for human-readable error detail that the client should surface; use status.error_key for the programmatic slug the client branches on.
Pagination
When ->data(...) receives a resource collection whose underlying resource is a LengthAwarePaginator, ResponseBuilder automatically attaches a top-level pagination block. You don't add it manually; you just make sure your Action returns a paginator (call ->pagination() on the Get{Entity}s Action — see Eloquent and queries).
{
"status": {
"code": 200,
"success": true,
"message": "OK",
"error_key": "ok"
},
"data": [
{ "id": 1, "name": "Acme Logistics" },
{ "id": 2, "name": "Beta Freight" }
],
"pagination": {
"total": 137,
"count": 20,
"per_page": 20,
"next_page_url": "https://api.example.com/v1/customers?page=2",
"prev_page_url": null,
"current_page": 1,
"last_page": 7,
"from": 1,
"to": 20
}
}
The exact keys, in order, are: total, count, per_page, next_page_url, prev_page_url, current_page, last_page, from, to. These come straight from LengthAwarePaginator's API in the pagination() method of ResponseBuilder — don't reshape them.
Status codes
| Code | Verb / Situation | When |
|---|---|---|
200 OK | GET, PUT, PATCH success | res()->data(...) |
201 Created | POST success | res(201)->data(...) — always on store |
204 No Content | DELETE success | res(204) — no body |
400 Bad Request | Business-rule failure | res()->status(400)->message('...')->withErrors([...]) |
401 Unauthorized | Missing or invalid auth token | Thrown by auth middleware |
403 Forbidden | Authenticated but lacks ability | Thrown by $this->authorize(...) or policy |
404 Not Found | Resource missing / out of scope | Thrown by findOrFail inside the Action |
422 Unprocessable Entity | Validation failure | Automatic from Form Request — not enveloped |
500 Internal Server Error | Uncaught exception | Logged; clients see a generic envelope |
Two repeat offenders: returning 200 on store (must be 201) and returning 200 with an empty body on destroy (must be 204).
What to read next
- Controllers — how the controller wires the Form Request → DTO → Contract → Resource flow.
- Contracts and Services — what the controller injects and calls.
- DTOs — the typed inputs that drive validation and the writes.
- Actions — what runs behind the Contract, including the
Get{Entity}sActions that return paginators. - Eloquent and queries —
->pagination()and thefilterBymacro. - DDD basics — the layered architecture this contract sits inside.
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.
Models
Everything Eloquent for a domain lives under `Models/`, split into Entities, Scopes, Abilities, and Features. What each sub-folder holds, the conventions for entities, and where a new model concern belongs.