bootstrap/app.php and App\Exceptions\ApiExceptionHandler. Every failure — whether validation, auth, RBAC, routing, or database constraint — is normalized into a single envelope so frontend, mobile, and Postman consumers never branch on Accept headers or HTML error pages.bootstrap/app.phpshouldRenderJsonWhen => true — Laravel will never render an HTML error page, even for curl without Accept: application/json. Every exception is forced to JSON.renderable — All throwables flow through one class: ApiExceptionHandler::render(). No scattered reportable() closures, no per-exception render() overrides. One router, one shape.ApiExceptionHandler::$handlers is an ordered map. render() iterates top-to-bottom with instanceof checks; first match wins. Specific subclasses must be listed before their parents (ValidationException before HttpException).| Exception | Status | Type field | User Message |
|---|---|---|---|
AuthenticationException | 401 | AuthenticationException | Authentication required. Please provide valid credentials. |
AuthorizationException, AccessDeniedHttpException, UnauthorizedException (Spatie) | 403 | AuthorizationException / UnauthorizedException | You do not have permission to perform this action. |
ValidationException | 422 | ValidationException | The provided data is invalid. + validation_errors bag |
ModelNotFoundException, NotFoundHttpException | 404 | ModelNotFoundException / NotFoundHttpException | The requested resource was not found. / The requested endpoint '{uri}' was not found. |
MethodNotAllowedHttpException | 405 | MethodNotAllowedHttpException | The {METHOD} method is not allowed... + allowed_methods |
HttpException (generic) | $e->getStatusCode() | HttpException | $e->getMessage() ?: 'An HTTP error occurred.' |
QueryException | 409 / 500 | QueryException | FK 1451 → Cannot delete... referenced ; 1062 → A record... already exists. ; else A database error occurred. |
InvalidStateTransitionException | 409 | invalid_state_transition | custom message (see §4) |
| unhandled | 500 or $e->getStatusCode() | basename(get_class($e)) | An unexpected error occurred. |
App\Support\ApiResponse{
"success": true,
"message": "Success",
"data": { ... }
}ApiResponse::success($data, $message, $status, $extra).{
"error": {
"type": "ValidationException",
"status": 422,
"message": "The provided data is invalid.",
"timestamp": "2026-08-26T12:00:00.000000Z",
"validation_errors": [
{ "field": "email", "message": "The email field is required." }
]
}
}ApiResponse::fail($message, $type, $status, $extras) where type = basename(get_class($e)).Note: This is the ground truth. The error envelope is not {success:false, message, data}— it is{error:{type,status,message,timestamp,...}}. The success envelope is{success:true, message, data}. Consumers must branch onsuccessvserrorat the top level.
handleValidationExceptionFormRequest classes (Phase 7). When rules() fail, Laravel throws ValidationException before the controller executes. The handler flattens it:validation_errors is a flat array of {field, message} — not the Laravel default {field:[messages]}. Easier for SPA form libraries to map.Log::warning('Validation failed', ['errors'=> $errors]).POST /api/checkout with bad payload:HTTP/1.1 422 Unprocessable Entity
{
"error": {
"type": "ValidationException",
"status": 422,
"message": "The provided data is invalid.",
"timestamp": "2026-08-26T10:00:00Z",
"validation_errors": [
{ "field": "type", "message": "The selected type is invalid." },
{ "field": "idempotency_key", "message": "The idempotency key must not be greater than 64 characters." }
]
}
}409 Conflict:QueryException with MySQL errorInfo[1] === 1062 (duplicate) or 1451 (FK restrict) → mapped to 409 with domain messages.InvalidStateTransitionException extends AuthorizationException — thrown by domain services when an entity cannot transition (e.g., Order already FULFILLED). Its own render() emits {error:{type:"invalid_state_transition", status:409}} and is caught before the generic 403 handler because the handler map tests instanceof AuthorizationException — note: InvalidStateTransitionException is an AuthorizationException, so handler order matters; its dedicated render() is invoked via model binding, not the central map, giving 409 instead of 403.logExceptionLog::warning with uniform context: exception, message, file, line, url, method, ip, plus branch extras (errors, sql, allowed_methods). Unhandled throwables also log as Unhandled exception before returning 500.bootstrap/app.php installs a single renderable; ApiExceptionHandler owns the status→type→message table. No controller try/catch duplicates it.shouldRenderJsonWhen => true eliminates content-negotiation branches — QA and route:list consumers see JSON even for 404/405.getExceptionType() uses basename(get_class($e)) (or explicit invalid_state_transition). Frontend can switch on error.type without parsing messages.{field, message}[] avoids field->[messages] nesting; frontend can groupBy(field) if needed.1062 and FK 1451 are user errors, not server errors — mapped to 409 Conflict with actionable messages.