The JSON API
Read when: designing or reviewing a JSON endpoint, an API Resource, or what an API failure returns.
Owns: endpoint naming, the status/data envelope and its builder, status codes, API Resources, the
pagination shape, the validation error shape, the API CRUD recipe, error handling for JSON requests,
domain exceptions, JSON endpoints that feed the app's own pages.
Controllers, Form Requests, route binding and authorization are shared with page panels and live in http.md.
TL;DR
/api/{version}/{resources}: plural lowercase nouns, nested one level at most, version in the path.- Every JSON response, success or failure, is
{ status: { code, success, message, error_key }, data }. It replaces Laravel's Resource wrapping and its validation payload. - API controllers declare
: ResponseBuilderand build it withres(). Neverresponse()->json(), an array, or a bare Resource. 201on create,204on delete,422on validation,404(not500) for a missing id.- Validation errors sit in
status.errors_listas[{ field, message }]. - A paginated list puts the rows in
dataand the paginator in a siblingpaginationkey. - Clients branch on
error_key, never onmessage, which is translated. - Resources whitelist what leaves the server; they never query.
Endpoints
✅ GET /api/v1/employees GET /api/v1/employees/42 POST /api/v1/employees
PUT|PATCH /api/v1/employees/42 DELETE /api/v1/employees/42 GET /api/v1/employees?user_id=7
❌ GET /api/getEmployees POST /api/employee/create POST /api/employees/42/update GET /api/v1/Employee
GET /api/v1/users/7/employees/42/contacts/3
- Plural, lowercase nouns. Nest one level at most; past that, filter by query string.
- Version in the path (
v1,v2). Never change a shipped version's shape; add the next version. GEThas no side effects.PUTreplaces,PATCHchanges part,DELETEremoves.
The envelope
{ "status": { "code": 201, "success": true, "message": "Created", "error_key": "created" },
"data": { "id": "42", "employee_number": "EMP-0042", "name": "Jane Doe" } }
{ "status": { "code": 422, "success": false, "message": "The given data was invalid.", "error_key": "validation_error",
"errors_list": [ { "field": "user_id", "message": "The user id field is required." } ] },
"data": {} }
{ "status": { "code": 200, "success": true, "message": "OK", "error_key": "ok" },
"data": [ { "id": "1", "employee_number": "EMP-0001" } ],
"pagination": { "total": 137, "count": 20, "per_page": 20, "current_page": 1, "last_page": 7,
"from": 1, "to": 20, "next_page_url": "…?page=2", "prev_page_url": null } }
status.codemirrors the HTTP status;successiscode < 400.error_keyis a stable slug: the message key when one was given (validation_error,not_found,employee_already_archived), else the lower-cased status text (ok,created). Clients branch on it.messageis human and translated. Never branch on it, and never assert it in a test without pinning the locale.errors_listappears only when there are field errors: one entry per field, its first message. Never send an empty list.datais{}when there is nothing to send, so a client can always read it as an object or list.- A paginated list adds
pagination; extra list-level values (counts) sit beside it, never inside each row. - A 500 says something generic. Log the exception; never put a trace or SQL in
message.
The builder
res() builds a ResponseBuilder, which implements Responsable, so Laravel turns it into JSON.
final class ResponseBuilder implements Responsable
{
private mixed $data = null;
private ?array $pagination = null;
private ?array $errors = null;
private ?string $errorKey = null;
public function __construct(private int $status = 200, private ?string $message = null, private array $replace = []) {}
public function data(mixed $data): self
{
$this->data = $data;
if ($data instanceof ResourceCollection && $data->resource instanceof LengthAwarePaginator) {
$this->pagination = [...$data->additional, 'pagination' => [
'total' => $data->total(), 'count' => $data->count(), 'per_page' => $data->perPage(),
'current_page' => $data->currentPage(), 'last_page' => $data->lastPage(),
'from' => $data->firstItem(), 'to' => $data->lastItem(),
'next_page_url' => $data->nextPageUrl(), 'prev_page_url' => $data->previousPageUrl(),
]];
}
return $this;
}
public function message(string $key, array $replace = []): self
{
[$this->message, $this->replace] = [$key, $replace];
return $this;
}
public function withErrors(array $errors): self
{
$this->errors = $errors;
return $this;
}
public function errorKey(string $key): self
{
$this->errorKey = $key;
return $this;
}
public function toResponse($request): JsonResponse
{
$statusText = Response::$statusTexts[$this->status] ?? '';
return new JsonResponse([
'status' => [
'code' => $this->status,
'success' => $this->status < 400,
'message' => $this->message === null ? $statusText : (Lang::has("api.{$this->message}") ? __("api.{$this->message}", $this->replace) : $this->message),
'error_key' => $this->errorKey ?? $this->message ?? Str::snake(strtolower($statusText)),
...($this->errors ? ['errors_list' => $this->errors] : []),
],
'data' => $this->data ?? (object) [],
...($this->pagination ?? []),
], $this->status);
}
}
function res(int $status = 200, ?string $message = null, array $replace = []): ResponseBuilder
{
return new ResponseBuilder($status, $message, $replace);
}
- Lives in
src/Domain/Core/Helpers/;res()sits in a helpers file autoloaded throughcomposer.jsonfiles. - A message is a key in
lang/{locale}/api.php; a string that is not a key passes through as it is. res(204)sends no body: Symfony drops the content of a 204.
return res()->data(EmployeeResource::make($employee)); // 200
return res(201)->data(EmployeeResource::make($employee)); // 201
return res(204); // 204
return res()->message('employee_activated')->data(EmployeeResource::make($employee));
Status codes
| Case | Code | Built by |
|---|---|---|
| Read, replace, partial update | 200 | res()->data(…) |
| Create (return the new resource) | 201 | res(201)->data(…) |
| Delete | 204 | res(204) |
| A refused domain operation | 400 or a specific 4xx | the domain exception |
| Not signed in, or a bad token | 401 | the handler |
| Signed in, not allowed | 403 | the handler |
| Missing, or outside the caller's scope | 404 | abort_if(…, 404) → the handler |
| Validation failed | 422 | the Form Request → the handler |
| Too many requests | 429 | the handler |
| Uncaught exception | 500 | the handler |
201 on create and 204 on delete are the two most often forgotten. 400 is not a validation failure
(422), and 404 is not "not signed in" (401). In a multi-tenant app a stranger gets 404, never 403,
which would confirm the row exists.
API Resources
final class EmployeeResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => (string) $this->id,
'employee_number' => $this->employee_number,
'status' => ['value' => $this->status->value, 'label' => $this->status->label()],
'name' => $this->name,
'user' => UserResource::make($this->whenLoaded('user')),
'activated_at' => $this->activated_at?->toIso8601String(),
];
}
}
- File:
src/Presentation/{Panel}/Resources/{Feature}/{Entity}Resource.php.::make()for one,::collection()for a list. - A Resource shapes; it never queries and never decides what to load. Guard relations with
whenLoaded(), so a missing eager load shows as absent, not as one query per row. - It is the whitelist: a field not listed never leaves the server, so password hashes, tokens and internal flags stay out by default.
- A list's own Resource (
IndexEmployeeResource) may be slimmer than the detail one. - Page panels use their own typed resources instead; see http.md.
Pagination
- Lists paginate through the query's
->pagination()(queries.md); the controller passes the paginated collection todata()and the builder addspagination. - Default page size 20. The index request caps the
pages_countinput at 100 (the response reports it asper_page), so a client cannot ask for the table. - Counts for the whole filtered set go through
->additional(['counts' => […]])on the collection, computed by the Contract, not in the Resource.
Validation errors
A Form Request failure reaches the handler as a ValidationException and leaves as 422,
error_key: validation_error, with status.errors_list. Never build a 422 by hand in a controller.
A failed throttle on a login form (a ValidationException with status 429) keeps its 429 and
error_key: throttling.
Error handling for JSON requests
Map framework exceptions to the envelope once, in bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (Throwable $e, Request $request) {
if (! $request->wantsJson() || $request->isAttemptingPrecognition()) {
return null;
}
$response = match (true) {
$e instanceof ValidationException && $e->status === 429 => res(429)->message($e->validator->errors()->first())->errorKey('throttling'),
$e instanceof ValidationException => res($e->status, 'validation_error')->withErrors(
collect($e->errors())->map(fn (array $messages, string $field) => ['field' => $field, 'message' => $messages[0]])->values()->all(),
),
$e instanceof ModelNotFoundException, $e instanceof NotFoundHttpException => res(404, 'not_found'),
$e instanceof AuthenticationException => res(401, 'unauthenticated'),
$e instanceof AuthorizationException => res(403, 'forbidden'),
$e instanceof ThrottleRequestsException => res(429, 'throttling'),
$e instanceof HttpExceptionInterface => res($e->getStatusCode(), $e->getMessage() ?: null),
default => config('app.debug') ? null : res(500, 'server_error'),
};
return $response?->toResponse($request);
});
})
wantsJson(), notexpectsJson(). Why: only a client that asked for JSON gets the envelope; a page form post or an Inertia visit gets the redirect and error page from http.md.- Precognition requests keep Laravel's own shape, because the front-end helper reads it.
ModelNotFoundExceptionin production says only "not found"; never echo the model class.- Report each exception once. Domain exceptions are expected outcomes: list them in
dontReport.
Domain exceptions
A domain refuses an operation by throwing an exception that carries a message key and a status, and renders itself for both kinds of request:
class BaseException extends Exception
{
public function __construct(protected string $messageKey, protected int $status = 400, protected array $replace = [])
{
parent::__construct(__("api.{$messageKey}", $replace));
}
public function render(Request $request): Responsable|RedirectResponse
{
return $request->expectsJson()
? res($this->status, $this->messageKey, $this->replace)
: back()->withErrors($this->getMessage())->withInput();
}
}
final class EmployeeAlreadyArchived extends BaseException
{
public function __construct()
{
parent::__construct('employee_already_archived', 422);
}
}
- The base lives in
app/Exceptions/(it knows HTTP); each domain's subclasses live insrc/Domain/{Domain}/Exceptions/. - The message key becomes
error_key, so it is stable and clients can branch on it. - Never
try/catchone in a controller to rebuild the response; let it render.
API CRUD recipe
final class EmployeeController extends Controller
{
use HasCurrentTenant;
public function __construct(private readonly EmployeeContract $employees) {}
public function index(IndexEmployeeRequest $request): ResponseBuilder
{
return res()->data(EmployeeResource::collection(
$this->employees->getEmployees(FilterEmployeeData::fromArray([...$request->validated(), ...$this->scope()]), withRelations: ['user'])->pagination(),
));
}
public function show(string $id): ResponseBuilder
{
return res()->data(EmployeeResource::make($this->findOrFail($id)));
}
public function store(StoreEmployeeRequest $request): ResponseBuilder
{
$employee = $this->employees->create(CreateEmployeeData::fromArray([...$request->validated(), ...$this->scope()]));
return res(201)->data(EmployeeResource::make($employee));
}
public function update(string $id, UpdateEmployeeRequest $request): ResponseBuilder
{
$employee = $this->employees->update($this->findOrFail($id), UpdateEmployeeData::fromArray($request->validated()));
return res()->data(EmployeeResource::make($employee));
}
public function destroy(string $id): ResponseBuilder
{
$this->employees->delete($this->findOrFail($id));
return res(204);
}
private function scope(): array
{
return ['tenant_id' => $this->currentTenant()->id];
}
private function findOrFail(string $id): Employee
{
$employee = $this->employees->getEmployee($id, FilterEmployeeData::fromArray($this->scope()));
abort_if($employee === null, 404);
return $employee;
}
}
Route::prefix('api/v1')->name('api.')->middleware('api')->group(function () {
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('employees', EmployeeController::class);
});
});
Checklist:
- [ ]
Index,StoreandUpdaterequests inRequests/{Feature}/; the index request validates every filter even though it is aGET(http.md). - [ ] Every method returns
ResponseBuilder;201on store,204on destroy. - [ ]
Route::apiResource()inside the authenticated group;->only([...])or->except([...])for a subset, and drop the matching methods, requests and Contract methods. - [ ] The scope key comes after the spread, and every method that takes an id (
export,archiveincluded) resolves it through the same scoped lookup. - [ ] Foreign keys to tenant-owned tables use an ownership-scoped
Rule::exists. - [ ] Contract methods exist for each call (contracts-and-services.md); no reach-around to a Model.
- [ ] A feature test per endpoint asserts through the envelope (testing.md).
JSON endpoints that feed the app's own pages
A page panel's screens sometimes read JSON after load (a comments panel, a search picker, a table that refreshes). These are not the public API:
- Route them in their own group (
/data/{module}/{action}, nameddata.{module}.{action}) with the panel's login. An endpoint that serves one page is a method on that page's controller; one that several pages share gets its own controller. - The group has no page-permission middleware, so each endpoint checks it itself: it answers 403 unless
the user may open the page it serves (
abort_unless($user->hasPermission('admin.employees.show'), 403)). - Input through a Form Request, like any action.
- A success is plain JSON with no envelope: records through the page resources, under a named key
(
['activities' => ActivityResource::collection($activities)]); a paginated list uses the same{ rows, meta }shape as the page props. - A failure still comes back in the envelope from the handler (422
validation_error, 403, 404), so the page readsstatus.error_key, never the message text.
Verify
# JSON built outside the builder
grep -rn "response()->json(" src/Presentation/
# API controllers that never mention the builder
grep -rLn "ResponseBuilder" src/Presentation/Api/Controllers/
# store returning 200 (review each hit)
grep -rnA6 "function store(" src/Presentation/Api/Controllers/ | grep "return res()"
# Resources that query
grep -rnE "::query\(|::where\(|->get\(\)|->count\(\)" src/Presentation/*/Resources/