A unified error contract keeps clients predictable and observability clean. Here’s how I shape API errors in Laravel.
Why a Unified Error Contract?
- One shape to parse, regardless of origin.
- Structured fields (
code,trace_id) improve logging/dashboards. - Safe evolution: change wording without breaking clients keyed on codes.
Recommended Error Envelope
{
"errors": [
{
"code": "validation_failed",
"field": "email",
"message": "The email must be a valid address."
}
],
"meta": {
"trace_id": "req-123",
"version": "v1"
}
}
Rules: errors is always an array; code is stable; include field when relevant; echo trace_id for correlation; meta.version for contract visibility.
HTTP Status Codes (be intentional)
- 400 Bad Request: malformed JSON/unsupported content-type.
- 401 Unauthorized: missing/invalid auth (not permission failure).
- 403 Forbidden: authenticated but not allowed.
- 404 Not Found: missing (or hidden) resource.
- 409 Conflict: business rule clash/state transition invalid.
- 422 Unprocessable Entity: validation errors.
- 429 Too Many Requests: include
Retry-After. - 500 Server Error: unexpected; keep message generic.
Validation Errors (422)
Convert Form Request errors to your envelope:
public function invalidJson($request, ValidationException $exception)
{
return response()->json([
'errors' => collect($exception->errors())->flatMap(function ($messages, $field) {
return collect($messages)->map(fn ($m) => [
'code' => 'validation_failed',
'field' => $field,
'message' => $m,
]);
})->values(),
'meta' => [
'trace_id' => $request->header('X-Request-Id'),
'version' => 'v1',
],
], 422);
}
Authorization & Policies (403)
{
"errors": [{ "code": "forbidden", "message": "You do not have permission to perform this action." }],
"meta": { "trace_id": "..." }
}
Avoid leaking resource existence.
Business Rule Conflicts (409)
{
"errors": [{ "code": "state_conflict", "message": "Leave request is not pending." }]
}
Rate Limits (429)
Include backoff hints and header:
{
"errors": [{ "code": "rate_limited", "message": "Too many attempts." }],
"meta": { "retry_after": 30, "trace_id": "..." }
}
Server Errors (500)
Generic outward message; log internally with trace_id:
{
"errors": [{ "code": "server_error", "message": "Something went wrong. Please try again." }],
"meta": { "trace_id": "..." }
}
Centralizing Error Responses
- Create a responder/helper to build envelopes.
- Map exceptions in the handler: Validation→422, Auth→401, Forbidden→403, NotFound→404, Conflict→409, RateLimit→429, fallback→500.
- Always return JSON.
Testing Checklist
- Assert status + envelope for validation, forbidden, not found, conflict, rate limit, server error.
errorsis an array;codepresent;trace_idechoed when provided.- Snapshot error payloads for versioned APIs.
Versioning & Deprecation
- Embed
meta.versionin every response. - For changes, add
meta.deprecated: truebefore removing fields/codes. - Keep
codestable; add new codes instead of mutating meaning.
Practical Tips
- Verb-noun, scoped codes:
validation_failed,forbidden,state_conflict,rate_limited. - Localize messages on server or let clients localize off
code. - Propagate
X-Request-Idand echo astrace_id. - Cap
per_page; never leak stack traces in prod.
Takeaway: A unified error envelope + disciplined status codes turns failures into predictable, debuggable signals while keeping your API contract stable.