Key Concepts

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.

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 LengthAwarePaginatorpagination. Nothing else lives at the root.

Inside status you get:

  • code — mirrors the HTTP status (200, 201, 204, 400, 422, …).
  • successtrue when code < 400, false otherwise. Computed, not passed in.
  • message — the human string. If you call ->message('voucher.approved'), it's looked up in lang/{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 on message.
  • 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": {}
}
When you don't pass anything to ->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:

  1. StoreCustomerRequest is a plain class with a static rules() method, not a FormRequest subclass. The controller calls $request->validate($rules) itself. This pattern is used when rules need parameters at call time (panel-specific scoping, owner IDs, etc.).
  2. store returns 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.

The other API anti-pattern that recurs: returning 200 with {"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.

Don't try to "fix" this by catching 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

CodeVerb / SituationWhen
200 OKGET, PUT, PATCH successres()->data(...)
201 CreatedPOST successres(201)->data(...) — always on store
204 No ContentDELETE successres(204) — no body
400 Bad RequestBusiness-rule failureres()->status(400)->message('...')->withErrors([...])
401 UnauthorizedMissing or invalid auth tokenThrown by auth middleware
403 ForbiddenAuthenticated but lacks abilityThrown by $this->authorize(...) or policy
404 Not FoundResource missing / out of scopeThrown by findOrFail inside the Action
422 Unprocessable EntityValidation failureAutomatic from Form Request — not enveloped
500 Internal Server ErrorUncaught exceptionLogged; 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).

  • 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}s Actions that return paginators.
  • Eloquent and queries->pagination() and the filterBy macro.
  • DDD basics — the layered architecture this contract sits inside.