Production-ready Laravel API toolkit: consistent response envelopes, structured error formats, OpenAPI 3 generation, request correlation, API versioning, exception rendering, paginator adapters, ETag helpers, security headers, and a health check endpoint — all in one package.
- Unified
ApiResponderwith every standard HTTP status shortcut (200–503) - Two error formats:
envelope(ok / type / message / data / errors) andstructured(error.code / message / target / details / innererror) - Paginated collection responses with OData-lite shape (
value / @count / @nextLink / @prevLink) and RFC 5988Linkheader LengthAwarePaginatorandCursorPaginatoradapters (fromPaginator()/fromCursorPaginator())Locationheader oncreated()andaccepted()(RFC 7231)Retry-Afterheader on 429 / 503X-RateLimit-Limit / Remaining / Reseton 429- ETag generation, conditional-request checking, and
304 Not Modified - Configurable envelope keys, default messages, and pagination keys
laravel-api.force-json— forcesAccept: application/jsonso exceptions render as JSON, not HTMLlaravel-api.request-id— attachesX-Request-IDandX-Correlation-IDto every request and responselaravel-api.security-headers—X-Content-Type-Options,X-Frame-Options,Cache-Control,Referrer-Policylaravel-api.versioning— reads and validates?api-version=orApi-Version:headerlaravel-api.deprecation— setsDeprecation:andSunset:headers on retiring routes
ApiExceptionRendererconvertsAuthenticationException,AuthorizationException,ValidationException,ModelNotFoundException,HttpException, and genericThrowableinto consistent API responses- Opt-in auto-registration via
LARAVEL_API_EXCEPTIONS_AUTO_RENDER=true
- Route-driven OpenAPI 3.0.3 spec from Laravel routes
- Tags auto-inferred from controller class name (
OrderController→orders) - Auth middleware (
auth:sanctum,auth:api, etc.) →security: [{ bearerAuth: [] }] - Standard reusable response definitions auto-added to
components.responses - Standard error schemas (
ErrorEnvelope,StructuredError) incomponents.schemas FormRequestrules →requestBodyschema inferenceJsonResource/ResourceCollection→ response schema inference- JSON and YAML export, runtime endpoints, optional Swagger UI / Redoc
- Spec caching with configurable store and TTL
GET /_healthendpoint (configurable route, middleware, and on/off toggle)
ApiResponseAssertionstrait for fluent test assertions (assertApiSuccess,assertApiPaginated,assertStructuredError, etc.)
Add the path repository in root composer.json:
{
"type": "path",
"url": "packages/yoosuf/laravel-api",
"options": { "symlink": true }
}Require and publish:
composer require yoosuf/laravel-api:*
php artisan vendor:publish --tag=laravel-api-configcomposer require yoosuf/laravel-api
php artisan vendor:publish --tag=laravel-api-configIn app/Http/Kernel.php, add to the api middleware group:
'api' => [
\Yoosuf\LaravelApi\Http\Middleware\ForceJsonMiddleware::class,
\Yoosuf\LaravelApi\Http\Middleware\RequestIdMiddleware::class,
\Yoosuf\LaravelApi\Http\Middleware\SecurityHeadersMiddleware::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],Or use the named aliases registered by the package:
'api' => [
'laravel-api.force-json',
'laravel-api.request-id',
'laravel-api.security-headers',
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],In .env:
LARAVEL_API_EXCEPTIONS_AUTO_RENDER=true
Or register manually in AppServiceProvider::boot():
app(\Yoosuf\LaravelApi\Exceptions\ApiExceptionRenderer::class)->register();This converts AuthenticationException, ValidationException, ModelNotFoundException, and HttpException into consistent JSON API responses automatically.
use Yoosuf\LaravelApi\Concerns\HasApiResponses;
class OrderController extends Controller
{
use HasApiResponses;
public function index(): JsonResponse
{
$orders = Order::paginate(20);
return $this->fromPaginator($orders);
}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = Order::create($request->validated());
return $this->created($order, 'Order created', null, route('orders.show', $order));
}
public function show(Order $order): JsonResponse
{
$response = $this->success($order);
return $this->withEtag($response);
}
public function destroy(Order $order): JsonResponse
{
$order->delete();
return $this->noContent();
}
}use Yoosuf\LaravelApi\Facades\ApiResponse;
// Facade
return ApiResponse::success($data, 'Fetched');
return ApiResponse::fromPaginator($paginator);
return ApiResponse::validation('Invalid input', $errors);
// Global helpers
return response_success($data, 'Fetched');
return response_failed('Bad input', 422, $errors);
return api_paginated($items, $total, $nextUrl);Success:
{
"ok": true,
"type": "success",
"message": "Fetched",
"data": { "id": 1, "name": "Order #1" },
"meta": {}
}Error:
{
"ok": false,
"type": "failed",
"message": "Validation failed",
"data": null,
"meta": {},
"errors": {
"email": ["The email field is required."]
}
}Set LARAVEL_API_ERROR_FORMAT=structured to use the structured error format for all error responses:
{
"error": {
"code": "UnprocessableEntity",
"message": "Validation failed",
"target": null,
"details": [
{ "code": "ValidationError", "message": "The email field is required.", "target": "email" }
]
}
}Use structuredError() explicitly regardless of format setting:
return $this->structuredError('ResourceNotFound', 'Order not found', 404, 'orderId');{
"value": [ { "id": 1 }, { "id": 2 } ],
"@count": 100,
"@nextLink": "https://api.example.com/orders?page=3",
"@prevLink": "https://api.example.com/orders?page=1"
}Response also includes Link: <url>; rel="next", <url>; rel="prev" header (RFC 5988).
| Method | Status | Notes |
|---|---|---|
success($data, $message, $status, $meta) |
200 | Base success |
created($data, $message, $meta, $location) |
201 | Sets Location header |
accepted($data, $message, $meta, $location) |
202 | Sets Location header |
noContent() |
204 | Empty body |
paginated($items, $total, $nextLink, $prevLink) |
200 | OData-lite + Link header |
fromPaginator($paginator) |
200 | From LengthAwarePaginator |
fromCursorPaginator($paginator) |
200 | From CursorPaginator (no @count) |
| Method | Status |
|---|---|
badRequest($message, $errors, $meta) |
400 |
unauthorized($message, $errors, $meta) |
401 |
forbidden($message, $errors, $meta) |
403 |
notFound($message, $errors, $meta) |
404 |
conflict($message, $errors, $meta) |
409 |
gone($message, $errors, $meta) |
410 |
validation($messageOrException, $errors, $meta) |
422 |
unprocessable($message, $errors, $meta) |
422 |
locked($message, $errors, $meta) |
423 |
tooManyRequests($message, $errors, $meta, $retryAfter, $limit, $remaining, $reset) |
429 |
| Method | Status |
|---|---|
error($message, $status, $errors, $meta) |
5xx |
notImplemented($message, $errors, $meta) |
501 |
serviceUnavailable($message, $errors, $meta, $retryAfter) |
503 |
$response = $this->success($data);
$response = $this->withEtag($response); // adds ETag header
return $this->checkEtag($request, $response); // returns 304 if If-None-Match matchesForces Accept: application/json so every request — including framework-level errors — returns JSON.
// Kernel.php api group
'laravel-api.force-json',Attaches X-Request-ID and X-Correlation-ID to every request and response.
LARAVEL_API_REQUEST_ID_ENABLED=true
LARAVEL_API_REQUEST_ID_HEADER=X-Request-ID
LARAVEL_API_CORRELATION_HEADER=X-Correlation-ID
Adds X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Cache-Control: no-store, Referrer-Policy: strict-origin.
Reads ?api-version= or Api-Version: header. Returns a structured 400 for unsupported versions.
LARAVEL_API_VERSIONING_ENABLED=true
LARAVEL_API_VERSION_CURRENT=1.0
Config: laravel-api.versioning.supported = ['1.0', '2.0']
Signals deprecation and removal date on a route group.
Route::middleware(['laravel-api.deprecation:2025-01-01,2026-01-01'])->group(function () {
// deprecated routes
});Sets Deprecation: and Sunset: response headers automatically.
// AppServiceProvider::boot()
app(\Yoosuf\LaravelApi\Exceptions\ApiExceptionRenderer::class)->register();| Exception | Status |
|---|---|
AuthenticationException |
401 |
AuthorizationException |
403 |
ValidationException |
422 with full error bag |
ModelNotFoundException |
404 with model name |
HttpException |
matching status |
Throwable |
500 (message hidden in production, shown with APP_DEBUG=true) |
Only renders for requests that send Accept: application/json or match api/*.
Enabled by default at GET /_health:
{ "status": "ok", "timestamp": "2026-07-18T08:00:00Z" }LARAVEL_API_HEALTH_ENABLED=true
LARAVEL_API_HEALTH_ROUTE=_health
php artisan api:openapi
php artisan api:openapi --format=json
php artisan api:openapi --format=yaml --output=docs/openapi.v2.yaml
php artisan api:openapi --prefix=/api/v1
php artisan api:openapi --include-route=orders.index --middleware=apiEvery generated spec includes:
- Tags inferred from controller name (
OrderController→orders) bearerAuthsecurity scheme when any route uses auth middlewaresecurity: [{ bearerAuth: [] }]on auth-gated operations- Standard response refs:
401,403,404(path-param routes),422(POST/PUT/PATCH),429,500on every operation ErrorEnvelopeandStructuredErrorschemas incomponents.schemasrequestBodyinferred fromFormRequestrules- Response schemas inferred from
JsonResource/ResourceCollectionreturn types
GET /openapi.json
GET /openapi.yaml
GET /api-docs # optional Swagger UI or Redoc
GET /_health
// config/laravel-api.php
'providers' => [
App\OpenApi\Providers\OrderSchemaProvider::class,
],class OrderSchemaProvider implements SchemaProvider
{
public function schemas(): array
{
return [
'Order' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'status' => ['type' => 'string', 'enum' => ['draft', 'placed', 'fulfilled']],
],
],
];
}
}'overrides' => [
'routes' => [
'orders.store' => ['post' => ['summary' => 'Place an order', 'tags' => ['orders']]],
],
'actions' => [
'App\Http\Controllers\OrderController@store' => [
'post' => ['x-internal' => true],
],
],
],Add the trait to your test case for fluent API response assertions:
use Yoosuf\LaravelApi\Testing\ApiResponseAssertions;
class OrderTest extends TestCase
{
use ApiResponseAssertions;
public function test_index(): void
{
$response = $this->getJson('/api/orders');
$this->assertApiPaginated($response);
}
public function test_store_validation(): void
{
$response = $this->postJson('/api/orders', []);
$this->assertApiValidationError($response, 'amount');
}
public function test_store_success(): void
{
$response = $this->postJson('/api/orders', ['amount' => 100]);
$this->assertApiCreated($response);
$this->assertApiDataKey($response, 'id', 1);
}
public function test_rate_limiting(): void
{
$response = $this->getJson('/api/orders');
$this->assertApiTooManyRequests($response, 60); // asserts Retry-After: 60
}
}Available assertions:
| Method | Description |
|---|---|
assertApiSuccess($response, $status) |
ok=true, envelope structure |
assertApiCreated($response) |
201 + envelope |
assertApiAccepted($response) |
202 + envelope |
assertApiNoContent($response) |
204 |
assertApiPaginated($response) |
value + @count |
assertApiError($response, $status) |
ok=false |
assertApiValidationError($response, $field) |
422 + optional field check |
assertApiUnauthorized($response) |
401 |
assertApiForbidden($response) |
403 |
assertApiNotFound($response) |
404 |
assertApiTooManyRequests($response, $retryAfter) |
429 + Retry-After |
assertStructuredError($response, $code, $status) |
structured format |
assertApiDataKey($response, $key, $value) |
data.key value |
assertApiMeta($response, $key, $value) |
meta.key value |
assertApiHasRequestId($response) |
X-Request-ID header |
Full config is in config/laravel-api.php. Key sections:
| Key | Default | Description |
|---|---|---|
openapi.enabled |
true |
Enable OpenAPI generation |
openapi.title |
app name | Spec title |
openapi.version |
1.0.0 |
Spec version |
openapi.default_path_prefix |
/api |
Route prefix filter |
openapi.cache.enabled |
false |
Cache generated spec |
openapi.docs_route.enabled |
true |
Serve runtime JSON/YAML |
openapi.docs_ui.enabled |
false |
Enable Swagger/Redoc UI |
response.error_format |
envelope |
envelope or structured |
response.envelope.* |
— | Configurable response keys |
response.pagination.* |
— | OData-lite key names |
request_id.enabled |
true |
Attach correlation headers |
request_id.header |
X-Request-ID |
Request ID header name |
versioning.enabled |
false |
Validate api-version |
versioning.supported |
[] |
Whitelist (empty = accept all) |
exceptions.auto_render |
false |
Auto-register exception renderer |
health.enabled |
true |
Enable health endpoint |
health.route |
_health |
Health check URL path |
Run from the package directory:
composer test # all tests
composer test:unit
composer test:integration
composer analyse # PHPStan level 5
composer lint # Pint style check
composer release:check # test + analyse + lint- Changelog:
CHANGELOG.md - Upgrade notes:
UPGRADE.md - Versioning policy:
docs/SEMVER_POLICY.md
LICENSE— MITCONTRIBUTING.mdCODE_OF_CONDUCT.mdSECURITY.mdSUPPORT.md
- Generate OpenAPI 3.0.3 specs from Laravel routes
- Export JSON and YAML spec files
- Serve live docs endpoints for JSON and YAML
- Configurable metadata, server URL, and API path prefix
- Action-based request and response mapping
- Route/action operation overrides (summary, tags, security, and more)
- Reusable
components.schemasregistry with provider support - Auto-inference from
JsonResource/ResourceCollectionreturn types - FormRequest validation rule inference for request schemas
- Route filtering by name and middleware from CLI options
- Optional static docs UI (Swagger UI or Redoc)
- Unified API response helpers (success, failed, error, validation, and aliases)
- Add path repository in root
composer.json:
{
"type": "path",
"url": "packages/yoosuf/laravel-api",
"options": { "symlink": true }
}- Require package:
composer require yoosuf/laravel-api:*- Publish config:
php artisan vendor:publish --tag=laravel-api-config- (Optional) Publish docs UI assets:
php artisan vendor:publish --tag=laravel-api-assetsGenerate both outputs to configured paths:
php artisan api:openapiGenerate only JSON:
php artisan api:openapi --format=jsonGenerate YAML to custom file:
php artisan api:openapi --format=yaml --output=docs/openapi.v1.yamlLimit generation to a prefix:
php artisan api:openapi --prefix=/api/v1Generate with route filters:
php artisan api:openapi --include-route=documents.store --exclude-route=documents.destroy --middleware=apiGET /openapi.jsonGET /openapi.yaml
Optional docs UI route:
GET /api-docs
Routes are configurable in config/laravel-api.php.
- Install the package and publish the config:
composer require yoosuf/laravel-api:*
php artisan vendor:publish --tag=laravel-api-config- Enable the human-readable docs UI in
config/laravel-api.php:
'docs_ui' => [
'enabled' => true,
'driver' => 'swagger',
'route' => 'api-docs',
'title' => 'Laradoc API Reference',
'spec_url' => '/openapi.json',
'middleware' => [],
],- Publish the UI assets:
php artisan vendor:publish --tag=laravel-api-assets --force- Generate the OpenAPI artifacts:
php artisan api:openapi --format=all --prefix=/api/v1- Open the machine-readable and human-readable endpoints:
http://127.0.0.1:8000/openapi.jsonhttp://127.0.0.1:8000/openapi.yamlhttp://127.0.0.1:8000/api-docs
The integration guide in docs/PROJECT_INTEGRATION_GUIDE.md demonstrates that full flow with real endpoints and payloads.
Use the responder to avoid repeating JSON response structures in controllers.
Options:
- Facade:
ApiResponse::responseSuccess(...) - Helper:
response_success(...) - Service:
app('laravel-api.response')->success(...) - Trait:
Yoosuf\\LaravelApi\\Concerns\\HasApiResponses
Examples:
return response_success(['user' => $user], 'Fetched');
return response_failed('Validation failed', 422, ['email' => ['required']]);
return response_error('Unexpected failure', 500);Alias support (including user-requested typo compatibility):
responseSuccessresponseFailedresponseErrorresponseErrror
Run package quality checks from the package directory:
composer test
composer test:unit
composer test:integration
composer analyse
composer lintGitHub Actions workflow is provided at .github/workflows/laravel-api-quality.yml.
This package follows Semantic Versioning.
- MAJOR: backward-incompatible API changes.
- MINOR: backward-compatible feature additions.
- PATCH: backward-compatible bug fixes.
Public API surface is defined in docs/SEMVER_POLICY.md.
- Changelog:
CHANGELOG.md - Upgrade notes:
UPGRADE.md
- License:
LICENSE - Contributing:
CONTRIBUTING.md - Code of Conduct:
CODE_OF_CONDUCT.md - Security Policy:
SECURITY.md - Support Guide:
SUPPORT.md - Detailed contributor workflow:
docs/CONTRIBUTING_GUIDELINES.md
- Architecture:
docs/ARCHITECTURE.md - Configuration reference:
docs/CONFIG_REFERENCE.md - Operations runbook:
docs/OPERATIONS_RUNBOOK.md - End-to-end use cases:
docs/END_TO_END_USE_CASES.md - SemVer and public API policy:
docs/SEMVER_POLICY.md - Release process:
docs/RELEASE.md - Future engineering pipeline:
docs/FUTURE_PIPELINE.md - Contribution process:
docs/CONTRIBUTING_GUIDELINES.md - Project integration guide:
docs/PROJECT_INTEGRATION_GUIDE.md
Before tagging a release, run:
composer release:checkFor monorepo-only usage, keep requiring via path repository.
For distribution, publish to Packagist using the process in docs/RELEASE.md.
openapi.providers: class list for custom providers implementing:Yoosuf\\LaravelApi\\OpenApi\\Contracts\\SchemaProviderYoosuf\\LaravelApi\\OpenApi\\Contracts\\OperationOverrideProvider
openapi.action_map: map byController@methodto merge request/response schema fragments.openapi.overrides: operation overrides grouped byroutesandactions.openapi.components.schemas: reusable component schemas.