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.
ApiResponder with every standard HTTP status shortcut (200–503)envelope (ok / type / message / data / errors) and structured (error.code / message / target / details / innererror)value / @count / @nextLink / @prevLink) and RFC 5988 Link headerLengthAwarePaginator and CursorPaginator adapters (fromPaginator() / fromCursorPaginator())Location header on created() and accepted() (RFC 7231)Retry-After header on 429 / 503X-RateLimit-Limit / Remaining / Reset on 429304 Not Modifiedlaravel-api.force-json — forces Accept: application/json so exceptions render as JSON, not HTMLlaravel-api.request-id — attaches X-Request-ID and X-Correlation-ID to 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= or Api-Version: headerlaravel-api.deprecation — sets Deprecation: and Sunset: headers on retiring routesApiExceptionRenderer converts AuthenticationException, AuthorizationException, ValidationException, ModelNotFoundException, HttpException, and generic Throwable into consistent API responsesLARAVEL_API_EXCEPTIONS_AUTO_RENDER=trueOrderController → orders)auth:sanctum, auth:api, etc.) → security: [{ bearerAuth: [] }]components.responsesErrorEnvelope, StructuredError) in components.schemasFormRequest rules → requestBody schema inferenceJsonResource / ResourceCollection → response schema inferenceGET /_health endpoint (configurable route, middleware, and on/off toggle)ApiResponseAssertions trait 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-config
composer require yoosuf/laravel-api
php artisan vendor:publish --tag=laravel-api-config
In 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.
HasApiResponses in controllersuse 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 matches
Forces 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=api
Every generated spec includes:
OrderController → orders)bearerAuth security scheme when any route uses auth middlewaresecurity: [{ bearerAuth: [] }] on auth-gated operations401, 403, 404 (path-param routes), 422 (POST/PUT/PATCH), 429, 500 on every operationErrorEnvelope and StructuredError schemas in components.schemasrequestBody inferred from FormRequest rulesJsonResource / ResourceCollection return typesGET /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.mdUPGRADE.mddocs/SEMVER_POLICY.mdLICENSE — MITCONTRIBUTING.mdCODE_OF_CONDUCT.mdSECURITY.mdSUPPORT.mdcomponents.schemas registry with provider supportJsonResource/ResourceCollection return typescomposer.json:{
"type": "path",
"url": "packages/yoosuf/laravel-api",
"options": { "symlink": true }
}
composer require yoosuf/laravel-api:*
php artisan vendor:publish --tag=laravel-api-config
php artisan vendor:publish --tag=laravel-api-assets
Generate both outputs to configured paths:
php artisan api:openapi
Generate only JSON:
php artisan api:openapi --format=json
Generate YAML to custom file:
php artisan api:openapi --format=yaml --output=docs/openapi.v1.yaml
Limit generation to a prefix:
php artisan api:openapi --prefix=/api/v1
Generate with route filters:
php artisan api:openapi --include-route=documents.store --exclude-route=documents.destroy --middleware=api
GET /openapi.jsonGET /openapi.yamlOptional docs UI route:
GET /api-docsRoutes are configurable in config/laravel-api.php.
composer require yoosuf/laravel-api:*
php artisan vendor:publish --tag=laravel-api-config
config/laravel-api.php:'docs_ui' => [
'enabled' => true,
'driver' => 'swagger',
'route' => 'api-docs',
'title' => 'Laradoc API Reference',
'spec_url' => '/openapi.json',
'middleware' => [],
],
php artisan vendor:publish --tag=laravel-api-assets --force
php artisan api:openapi --format=all --prefix=/api/v1
http://127.0.0.1:8000/openapi.jsonhttp://127.0.0.1:8000/openapi.yamlhttp://127.0.0.1:8000/api-docsThe 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:
ApiResponse::responseSuccess(...)response_success(...)app('laravel-api.response')->success(...)Yoosuf\\LaravelApi\\Concerns\\HasApiResponsesExamples:
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):
responseSuccessresponseFailedresponseErrorresponseErrrorRun package quality checks from the package directory:
composer test
composer test:unit
composer test:integration
composer analyse
composer lint
GitHub Actions workflow is provided at .github/workflows/laravel-api-quality.yml.
This package follows Semantic Versioning.
Public API surface is defined in docs/SEMVER_POLICY.md.
CHANGELOG.mdUPGRADE.mdLICENSECONTRIBUTING.mdCODE_OF_CONDUCT.mdSECURITY.mdSUPPORT.mddocs/CONTRIBUTING_GUIDELINES.mddocs/ARCHITECTURE.mddocs/CONFIG_REFERENCE.mddocs/OPERATIONS_RUNBOOK.mddocs/END_TO_END_USE_CASES.mddocs/SEMVER_POLICY.mddocs/RELEASE.mddocs/FUTURE_PIPELINE.mddocs/CONTRIBUTING_GUIDELINES.mddocs/PROJECT_INTEGRATION_GUIDE.mdBefore tagging a release, run:
composer release:check
For 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\\OperationOverrideProvideropenapi.action_map: map by Controller@method to merge request/response schema fragments.openapi.overrides: operation overrides grouped by routes and actions.openapi.components.schemas: reusable component schemas.How can I help you explore Laravel packages today?