ivansostarko / laravel-api-errors
Centralized, enum-based API error codes for Laravel — frontend-safe, RFC 7807 compatible, Swagger-ready, with TypeScript export, Sentry integration, and request tracing.
Package info
github.com/ivansostarko/laravel-api-errors
pkg:composer/ivansostarko/laravel-api-errors
Requires
- php: ^8.2
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
README
Centralized, enum-based API error codes for Laravel — frontend-safe, RFC 7807 compatible, Swagger-ready, TypeScript-exportable, with Sentry integration, request tracing, and translation support.
Stop scattering magic strings and ad-hoc error responses across your codebase. Define every error code once as a PHP enum, and let the package handle JSON responses, logging, tracing, and cross-team contracts automatically.
Table of Contents
- Features
- Requirements
- Installation
- Quick Start
- Error Code Enums
- Throwing & Responding
- Response Formats
- Laravel Exception Handler Integration
- Request ID Tracing
- Translations
- Logging Integration
- Sentry Integration
- TypeScript Export
- Swagger / OpenAPI Export
- Artisan Commands
- Microservice Architecture
- Monolith Architecture
- Configuration Reference
- Testing
- License
Features
| Feature | Description |
|---|---|
| Centralized error codes | Every code lives in a single enum — no duplicates, no magic strings. |
| Enum-based registry | PHP 8.1+ backed enums implement a shared contract. |
| ApiException | Throw a rich exception that auto-renders to JSON. |
| Helper functions | api_error(), api_abort(), api_error_code() for expressive code. |
| Auto JSON responses | Laravel's exception handler renders consistent JSON automatically. |
| Frontend-safe | Stable string codes that frontend teams can rely on as a contract. |
| RFC 7807 support | Toggle between flat JSON and application/problem+json. |
| TypeScript export | Generate a .ts file with all codes, types, and a type guard. |
| Swagger / OpenAPI | Export an OpenAPI 3.1 JSON schema of all error codes. |
| Translation ready | Every code resolves through Laravel's translator. |
| Validation mapping | Flattens Laravel validation errors into [{field, message}]. |
| Domain grouping | Group codes by logical domain (AUTH, BILLING, etc.). |
| Logging integration | Auto-logs errors with code, domain, request ID, and severity. |
| Sentry integration | Sets tags (error_code, domain, request_id) on Sentry events. |
| Request ID tracing | Middleware generates/propagates X-Request-Id across services. |
| Publishable config | Full control via config/api-errors.php. |
| Monolith friendly | Register multiple domain enums in one app. |
| Microservice friendly | Share the contract package; each service registers its own codes. |
| Backward compatible | Default format is a simple, flat JSON object. |
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13
Installation
composer require ivansostarko/laravel-api-errors
Publish the config file:
php artisan vendor:publish --tag=api-errors-config
Optionally publish a starter enum stub into your app:
php artisan vendor:publish --tag=api-errors-stubs
Quick Start
1. Define an error code enum
// app/Enums/AppErrorCode.php namespace App\Enums; use LaravelApiErrors\Contracts\ApiErrorCode; use LaravelApiErrors\Enums\InteractsWithApiError; enum AppErrorCode: string implements ApiErrorCode { use InteractsWithApiError; case ORDER_ALREADY_SHIPPED = 'ORDER_ALREADY_SHIPPED'; public function code(): string { return $this->value; } public function httpStatus(): int { return 409; } public function domain(): string { return 'ORDER'; } public function severity(): string { return 'warning'; } public function message(): string { return match ($this) { self::ORDER_ALREADY_SHIPPED => 'This order has already been shipped.', }; } }
2. Register it
// config/api-errors.php 'extra_enums' => [ \App\Enums\AppErrorCode::class, ],
3. Use it
// In a controller use App\Enums\AppErrorCode; public function cancel(Order $order) { if ($order->shipped) { AppErrorCode::ORDER_ALREADY_SHIPPED->throw(['order_id' => $order->id]); } // ... }
The response is automatically rendered as:
{
"success": false,
"error_code": "ORDER_ALREADY_SHIPPED",
"message": "This order has already been shipped.",
"domain": "ORDER",
"status": 409,
"request_id": "a1b2c3d4-...",
"context": {
"order_id": 42
}
}
Error Code Enums
The Contract
Every error code enum must implement LaravelApiErrors\Contracts\ApiErrorCode:
interface ApiErrorCode { public function code(): string; // Unique stable string, e.g. "AUTH_TOKEN_EXPIRED" public function message(): string; // Default human-readable message public function httpStatus(): int; // HTTP status code public function domain(): string; // Logical group, e.g. "AUTH" public function severity(): string; // Log level: debug, info, warning, error, fatal }
Creating Your Own Enum
Use the InteractsWithApiError trait to get helper methods (throw(), respond(), exception(), translatedMessage()):
enum BillingErrorCode: string implements ApiErrorCode { use InteractsWithApiError; case CARD_DECLINED = 'BILLING_CARD_DECLINED'; case INSUFFICIENT_FUNDS = 'BILLING_INSUFFICIENT_FUNDS'; case SUBSCRIPTION_EXPIRED = 'BILLING_SUBSCRIPTION_EXPIRED'; public function code(): string { return $this->value; } public function domain(): string { return 'BILLING'; } public function severity(): string { return 'warning'; } public function message(): string { return match ($this) { self::CARD_DECLINED => 'The credit card was declined.', self::INSUFFICIENT_FUNDS => 'Insufficient funds.', self::SUBSCRIPTION_EXPIRED => 'Your subscription has expired.', }; } public function httpStatus(): int { return match ($this) { self::CARD_DECLINED => 402, self::INSUFFICIENT_FUNDS => 402, self::SUBSCRIPTION_EXPIRED => 403, }; } }
Registering Enums
Add enum classes to the extra_enums array in config/api-errors.php. The registry validates that no two enums register the same code string — if they do, it throws a LogicException at boot time.
'extra_enums' => [ \App\Enums\BillingErrorCode::class, \App\Enums\InventoryErrorCode::class, \Modules\Shipping\Enums\ShippingErrorCode::class, ],
Domain Grouping
Every code declares a domain(). You can list codes by domain:
php artisan api-errors:list --domain=BILLING
Or programmatically:
$registry = app(\LaravelApiErrors\Support\ErrorCodeRegistry::class); $billingCodes = $registry->domain('BILLING'); $grouped = $registry->groupedByDomain();
Throwing & Responding
Using the Enum Directly
use App\Enums\AppErrorCode; // Throw — renders automatically via Laravel's exception handler AppErrorCode::ORDER_ALREADY_SHIPPED->throw(['order_id' => $id]); // Return a response without throwing return AppErrorCode::ORDER_ALREADY_SHIPPED->respond(['order_id' => $id]); // Create the exception object for deferred throwing $e = AppErrorCode::ORDER_ALREADY_SHIPPED->exception(['order_id' => $id]);
Using Helper Functions
// Return a JSON response return api_error('ORDER_ALREADY_SHIPPED', ['order_id' => $id]); // Throw immediately api_abort('ORDER_ALREADY_SHIPPED', ['order_id' => $id]); // Resolve code string → enum case $code = api_error_code('ORDER_ALREADY_SHIPPED');
Helpers accept either an ApiErrorCode enum instance or a code string.
Validation Error Mapping
When Laravel throws a ValidationException on an API route, the package automatically catches it and returns:
{
"success": false,
"error_code": "VALIDATION_ERROR",
"message": "The given data was invalid.",
"domain": "GENERAL",
"status": 422,
"context": {
"errors": [
{ "field": "email", "message": "The email field is required." },
{ "field": "email", "message": "The email must be a valid email address." },
{ "field": "name", "message": "The name field is required." }
]
}
}
The nested validation errors are flattened into a consistent [{field, message}] array that frontends can iterate over directly.
Response Formats
Default JSON
Set API_ERRORS_FORMAT=default (this is the default):
{
"success": false,
"error_code": "AUTH_TOKEN_EXPIRED",
"message": "Your authentication token has expired.",
"domain": "AUTH",
"status": 401,
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"context": {}
}
RFC 7807 Problem Details
Set API_ERRORS_FORMAT=rfc7807:
{
"type": "https://api-errors.dev/codes/auth_token_expired",
"title": "AUTH_TOKEN_EXPIRED",
"status": 401,
"detail": "Your authentication token has expired.",
"instance": "/api/v1/profile",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
The Content-Type header is set to application/problem+json automatically.
Laravel Exception Handler Integration
Register the package's renderable callbacks in your bootstrap/app.php:
use LaravelApiErrors\Support\ExceptionRenderer; return Application::configure(basePath: dirname(__DIR__)) ->withExceptions(function (Exceptions $exceptions) { ExceptionRenderer::register($exceptions); }) ->create();
This automatically converts the following exceptions into consistent API error responses (only for requests that expect JSON or hit api/* routes):
| Laravel Exception | Error Code |
|---|---|
ApiException |
Whatever code you set |
ValidationException |
VALIDATION_ERROR |
AuthenticationException |
AUTH_UNAUTHENTICATED |
ModelNotFoundException |
RESOURCE_NOT_FOUND |
NotFoundHttpException |
RESOURCE_NOT_FOUND |
MethodNotAllowedHttpException |
METHOD_NOT_ALLOWED |
TooManyRequestsHttpException |
TOO_MANY_REQUESTS |
Any HttpExceptionInterface |
INTERNAL_SERVER_ERROR |
Request ID Tracing
Add the middleware to your API stack:
// bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->api(prepend: [ \LaravelApiErrors\Http\Middleware\AttachRequestId::class, ]); })
Behavior:
- If the incoming request has an
X-Request-Idheader (e.g. from a gateway or upstream service), it is preserved. - If not, a UUID v4 is generated automatically.
- The same ID is attached to the response header and included in every error JSON body.
- The ID is also sent to Sentry and the logger for end-to-end tracing.
Configure the header name in config/api-errors.php:
'request_id_header' => 'X-Request-Id',
Translations
Every error code's message is resolved through Laravel's translator. Generate a translation file:
php artisan api-errors:sync-translations --locale=en php artisan api-errors:sync-translations --locale=es
This creates lang/en/api-errors.php with all registered codes. Edit the file to customize messages per locale:
// lang/es/api-errors.php return [ 'AUTH_UNAUTHENTICATED' => 'Se requiere autenticación.', 'VALIDATION_ERROR' => 'Los datos proporcionados no son válidos.', ];
Translations are resolved automatically — no extra code needed.
Logging Integration
Every ApiException is logged automatically with structured context:
[2026-04-08 12:00:00] local.ERROR: [API Error] AUTH_TOKEN_EXPIRED: Your authentication token has expired. {
"error_code": "AUTH_TOKEN_EXPIRED",
"domain": "AUTH",
"http_status": 401,
"request_id": "550e8400-...",
"url": "https://example.com/api/v1/profile",
"method": "GET"
}
Configure in config/api-errors.php:
'logging' => [ 'enabled' => true, 'channel' => 'stack', // null = default channel 'exclude_status' => [404, 422], // Don't log these ],
Sentry Integration
Enable in .env:
API_ERRORS_SENTRY=true
When an ApiException is captured, the package:
- Sets Sentry tags:
error_code,error_domain,http_status,request_id - Sets Sentry context with the full error payload
- Maps
severity()to Sentry severity levels
This means you can filter Sentry issues by error code or domain directly in the Sentry dashboard.
TypeScript Export
Generate a TypeScript file that your frontend can import:
php artisan api-errors:ts
Output (resources/js/api-errors.ts):
export const API_ERROR_CODES = { UNKNOWN_ERROR: { code: 'UNKNOWN_ERROR', status: 500, domain: 'GENERAL', message: 'An unexpected error occurred.' }, VALIDATION_ERROR: { code: 'VALIDATION_ERROR', status: 422, domain: 'GENERAL', message: 'The given data was invalid.' }, AUTH_UNAUTHENTICATED: { code: 'AUTH_UNAUTHENTICATED', status: 401, domain: 'AUTH', message: 'Authentication is required.' }, // ... all registered codes } as const; export type ApiErrorCode = keyof typeof API_ERROR_CODES; export type ApiErrorResponse = { success: false; error_code: ApiErrorCode; message: string; domain: string; status: number; request_id?: string; context?: Record<string, unknown>; }; export function isApiError(data: unknown): data is ApiErrorResponse { return typeof data === 'object' && data !== null && 'error_code' in data && 'success' in data; }
Usage in frontend:
import { isApiError, API_ERROR_CODES } from './api-errors'; const res = await fetch('/api/orders/42', { method: 'DELETE' }); const data = await res.json(); if (isApiError(data)) { switch (data.error_code) { case 'ORDER_ALREADY_SHIPPED': toast.warn('Cannot cancel a shipped order.'); break; case 'AUTH_TOKEN_EXPIRED': router.push('/login'); break; default: toast.error(data.message); } }
Swagger / OpenAPI Export
php artisan api-errors:swagger
Generates an OpenAPI 3.1 JSON file at storage/api-docs/error-codes.json containing:
- An
ApiErrorCodestring enum schema with all registered codes ApiErrorResponseandRFC7807ProblemDetailobject schemasx-error-code-detailswith HTTP status, domain, message, and severity for each code
Reference these schemas in your main OpenAPI spec:
responses: '409': description: Conflict content: application/json: schema: $ref: './error-codes.json#/components/schemas/ApiErrorResponse'
Artisan Commands
| Command | Description |
|---|---|
php artisan api-errors:list |
List all registered error codes in a table. |
php artisan api-errors:list --domain=AUTH |
Filter by domain. |
php artisan api-errors:ts |
Export TypeScript file. |
php artisan api-errors:swagger |
Export OpenAPI schema. |
php artisan api-errors:sync-translations |
Generate translation file for a locale. |
Microservice Architecture
In a microservice setup, extract the contract into a shared Composer package:
shared-api-contracts/
├── src/
│ └── Contracts/
│ └── ApiErrorCode.php
│ └── Enums/
│ └── InteractsWithApiError.php
└── composer.json
Each microservice then:
- Requires the shared contracts package.
- Requires
laravel-api-errors/laravel-api-errors. - Defines its own domain-specific enums implementing
ApiErrorCode. - Registers them in
config/api-errors.php.
The X-Request-Id header flows through service-to-service calls automatically.
Monolith Architecture
In a monolith with multiple modules/domains, create one enum per domain and register them all:
app/
├── Enums/
│ ├── AuthErrorCode.php
│ ├── BillingErrorCode.php
│ ├── InventoryErrorCode.php
│ └── ShippingErrorCode.php
// config/api-errors.php 'extra_enums' => [ \App\Enums\AuthErrorCode::class, \App\Enums\BillingErrorCode::class, \App\Enums\InventoryErrorCode::class, \App\Enums\ShippingErrorCode::class, ],
The registry ensures no two domains accidentally use the same code string.
Configuration Reference
Publish with php artisan vendor:publish --tag=api-errors-config.
| Key | Default | Description |
|---|---|---|
format |
default |
default or rfc7807 |
debug |
false |
Include stack traces (only when APP_DEBUG=true) |
request_id_header |
X-Request-Id |
Header name for request tracing |
auto_request_id |
true |
Generate UUID if header is missing |
request_id_in_response |
true |
Include request ID in JSON body |
use_translations |
true |
Resolve messages through Laravel translator |
translation_namespace |
api-errors |
Translation namespace |
logging.enabled |
true |
Enable automatic logging |
logging.channel |
null |
Log channel (null = default) |
logging.exclude_status |
[404, 422] |
HTTP statuses to skip logging |
sentry.enabled |
false |
Enable Sentry integration |
sentry.set_tags |
true |
Set error_code/domain as Sentry tags |
validation_error_code |
VALIDATION_ERROR |
Code used for validation exceptions |
extra_enums |
[] |
Additional enum classes to register |
typescript_path |
resources/js/api-errors.ts |
TypeScript export output path |
swagger_path |
storage/api-docs/error-codes.json |
Swagger export output path |
Testing
composer test
Or with PHPUnit directly:
./vendor/bin/phpunit
License
The MIT License (MIT). Please see LICENSE for more information.