vandet / laravel-api-response
Laravel package that enforces a consistent API response envelope — success, paginated, error, and bulk partial failure — across all services.
Requires
- php: ^8.2
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- symfony/http-kernel: ^7.0
Requires (Dev)
- illuminate/auth: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/pagination: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
This package is auto-updated.
Last update: 2026-07-31 01:56:43 UTC
README
A Laravel package that enforces a consistent API response envelope — success, paginated, error, and bulk partial failure — so all services speak the same shape without hand-rolling ResponseFactory in each one.
Requirements
- PHP 8.2+
- Laravel 12 or 13
Installation
Option 1 — Composer (recommended)
composer require vandet/laravel-api-response
Laravel auto-discovers the service provider — no manual registration needed.
Option 2 — Clone the repository
Use this when you want to contribute, customise the source, or install without Packagist.
1. Clone into your project
git clone https://github.com/vandet/laravel-api-response.git packages/laravel-api-response
2. Add the local path repository to your composer.json
"repositories": [ { "type": "path", "url": "./packages/laravel-api-response" } ]
3. Require the package
composer require vandet/laravel-api-response
Composer symlinks the cloned folder into vendor/ — any changes you make to the source are reflected immediately without re-running composer update.
Publish the config (optional)
php artisan vendor:publish --tag=api-response-config
This creates config/api-response.php where you can toggle exception handling per type.
Usage
ResponseFactory
Import once at the top of your controller:
use Vandet\ApiResponse\Http\ResponseFactory;
Success — single resource
return ResponseFactory::success($user, 'User retrieved successfully.');
{ "success": true, "message": "User retrieved successfully.", "data": { ... } }
Success — created (201)
return ResponseFactory::created($user, 'User created successfully.');
Success — accepted async job (202)
// No data (typical for fire-and-forget jobs) return ResponseFactory::accepted('Import queued successfully.'); // With tracking info return ResponseFactory::accepted('Import queued.', ['job_id' => 'abc-123']);
Success — paginated collection
Pass a Laravel LengthAwarePaginator directly. Call ->withQueryString() on the paginator to preserve filter/sort params in links.
$users = User::paginate(20)->withQueryString(); return ResponseFactory::paginated($users, 'Users retrieved successfully.');
{
"success": true,
"message": "Users retrieved successfully.",
"data": [...],
"pagination": { "current_page": 1, "last_page": 5, "per_page": 20, "total": 82, "from": 1, "to": 20 },
"links": { "first": "...", "last": "...", "next": "...", "prev": null }
}
Success — with reference data
return ResponseFactory::withIncluded($users, [ 'roles' => Role::all()->toArray(), 'statuses' => Status::all()->toArray(), ], 'Users retrieved successfully.');
Delete (204 — no body)
return ResponseFactory::deleted();
Validation error (422)
With FormRequest (recommended) — no manual call needed.
The exception handler catches the ValidationException that FormRequest throws automatically
and converts it to the standard envelope for you.
// FormRequest — just type-hint it, validation + response are automatic public function store(StoreUserRequest $request): JsonResponse { $dto = UserDTO::fromRequest($request); // ... }
{ "success": false, "message": "Validation failed.", "code": "VALIDATION_FAILED", "errors": { "email": ["Email is required."] } }
With a manual validator — call ResponseFactory::validationError() yourself:
$validator = Validator::make($request->all(), [ 'email' => ['required', 'email'], ]); if ($validator->fails()) { return ResponseFactory::validationError($validator->errors()->toArray()); }
Not found (404)
use Vandet\ApiResponse\Constants\ErrorCodes; return ResponseFactory::notFound(ErrorCodes::RESOURCE_NOT_FOUND, 'User not found.');
Unauthorized (401) / Forbidden (403) / Conflict (409)
return ResponseFactory::unauthorized(ErrorCodes::AUTH_TOKEN_EXPIRED, 'Token has expired.'); return ResponseFactory::forbidden(ErrorCodes::AUTH_USER_FORBIDDEN, 'You do not have permission.'); return ResponseFactory::conflict(ErrorCodes::RESOURCE_CONFLICT, 'Email already registered.');
Bulk partial failure (207)
return ResponseFactory::bulkPartialFailure([ 'created' => 1, 'failed' => 1, 'items' => [ ['index' => 0, 'success' => true, 'id' => '550e8400-...'], ['index' => 1, 'success' => false, 'code' => 'USER_EMAIL_DUPLICATE', 'message' => 'Email already registered.'], ], ]);
Rate limited (429) / Server error (500)
return ResponseFactory::rateLimited(); return ResponseFactory::serverError('Something went wrong.');
Error Codes
All standard error codes are available as constants:
use Vandet\ApiResponse\Constants\ErrorCodes; ErrorCodes::AUTH_TOKEN_EXPIRED ErrorCodes::RESOURCE_NOT_FOUND ErrorCodes::VALIDATION_FAILED ErrorCodes::RESOURCE_NOT_FOUND ErrorCodes::SERVER_UNEXPECTED_ERROR // ... and 35 more
See src/Constants/ErrorCodes.php for the full list, or refer to 04-error-code-standard.md.
Exception Handler
The package automatically intercepts Laravel exceptions on JSON requests and converts them to the standard envelope.
| Config key | Exception | HTTP | Code |
|---|---|---|---|
validation |
ValidationException |
422 | VALIDATION_FAILED |
authentication |
AuthenticationException |
401 | AUTH_TOKEN_MISSING |
authorization |
AuthorizationException |
403 | AUTH_USER_FORBIDDEN |
model_not_found |
ModelNotFoundException |
404 | RESOURCE_NOT_FOUND |
route_not_found |
NotFoundHttpException |
404 | RESOURCE_NOT_FOUND |
rate_limited |
TooManyRequestsHttpException |
429 | SERVER_RATE_LIMITED |
http_error |
HttpException (503, etc.) |
varies | SERVER_UNAVAILABLE / SERVER_UNEXPECTED_ERROR |
server_error |
Throwable (catch-all) |
500 | SERVER_UNEXPECTED_ERROR |
ApiException and its subclasses are handled via their own render() method and do not appear in this table — no config key needed.
Only requests with Accept: application/json are intercepted — web/HTML routes are unaffected.
Disabling the exception handler
To disable all automatic exception handling:
// config/api-response.php 'handle_exceptions' => false,
To disable specific exception types:
'exceptions' => [ 'validation' => true, 'authentication' => true, 'authorization' => false, // handle manually 'model_not_found' => true, 'route_not_found' => true, 'rate_limited' => true, 'http_error' => true, 'server_error' => true, ],
Customizing exception handlers
Each exception type can be replaced with your own class instead of being toggled on/off. The config value accepts three forms:
| Value | Behaviour |
|---|---|
true |
Use the package default (default) |
false |
Skip — Laravel handles it |
'ClassName' |
Use your custom class (resolved via container) |
Your custom class must implement Vandet\ApiResponse\Contracts\ExceptionHandlerContract:
use Illuminate\Http\JsonResponse; use Vandet\ApiResponse\Contracts\ExceptionHandlerContract; interface ExceptionHandlerContract { public function handle(\Throwable $e): JsonResponse; }
Register custom handlers in config/api-response.php:
'exceptions' => [ 'validation' => \App\Exceptions\Handlers\ValidationHandler::class, 'authentication' => \App\Exceptions\Handlers\AuthenticationHandler::class, 'authorization' => \App\Exceptions\Handlers\AuthorizationHandler::class, 'model_not_found' => \App\Exceptions\Handlers\ModelNotFoundHandler::class, 'route_not_found' => \App\Exceptions\Handlers\RouteNotFoundHandler::class, 'rate_limited' => \App\Exceptions\Handlers\RateLimitedHandler::class, 'http_error' => \App\Exceptions\Handlers\HttpErrorHandler::class, 'server_error' => \App\Exceptions\Handlers\ServerErrorHandler::class, ],
Publish ready-to-use starting points for all exception types directly into your project:
php artisan vendor:publish --tag=api-response-stubs
This creates app/Exceptions/Handlers/ with one file per exception type. Modify as needed.
Example — custom validation handler:
// app/Exceptions/Handlers/ValidationHandler.php namespace App\Exceptions\Handlers; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Vandet\ApiResponse\Contracts\ExceptionHandlerContract; use Vandet\ApiResponse\Http\ResponseFactory; class ValidationHandler implements ExceptionHandlerContract { public function handle(\Throwable $e): JsonResponse { /** @var ValidationException $e */ return ResponseFactory::validationError( $e->errors(), 'Please fix the highlighted fields.' ); } }
model_not_found and route_not_found are separate keys — they can be customized or disabled independently:
'exceptions' => [ 'model_not_found' => \App\Exceptions\Handlers\ModelNotFoundHandler::class, 'route_not_found' => false, // let Laravel handle missing routes ],
Conflict with an existing exception handler
If your service already has custom exception handling, the package renderables take priority for matched types. To opt out of specific types (see above) and handle them yourself, use $exceptions['type'] => false in the config.
Using ResponseFactory in a Custom Exception Handler
You can use ResponseFactory directly inside your own exception handler alongside or instead of the package's built-in renderables.
Laravel 11 — bootstrap/app.php
use Illuminate\Foundation\Configuration\Exceptions; use Vandet\ApiResponse\Http\ResponseFactory; use Vandet\ApiResponse\Constants\ErrorCodes; use App\Exceptions\PaymentFailedException; use App\Exceptions\TenantSuspendedException; ->withExceptions(function (Exceptions $exceptions) { // Custom domain exception $exceptions->renderable(function (PaymentFailedException $e, $request) { if ($request->expectsJson()) { return ResponseFactory::conflict( ErrorCodes::PAYMENT_FAILED, $e->getMessage() ); } }); // Another domain exception $exceptions->renderable(function (TenantSuspendedException $e, $request) { if ($request->expectsJson()) { return ResponseFactory::forbidden( ErrorCodes::TENANT_SUSPENDED, 'This account has been suspended.' ); } }); })
Laravel 10 — app/Exceptions/Handler.php
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Http\Request; use Vandet\ApiResponse\Http\ResponseFactory; use Vandet\ApiResponse\Constants\ErrorCodes; use App\Exceptions\PaymentFailedException; use App\Exceptions\TenantSuspendedException; class Handler extends ExceptionHandler { public function register(): void { $this->renderable(function (PaymentFailedException $e, Request $request) { if ($request->expectsJson()) { return ResponseFactory::conflict( ErrorCodes::PAYMENT_FAILED, $e->getMessage() ); } }); $this->renderable(function (TenantSuspendedException $e, Request $request) { if ($request->expectsJson()) { return ResponseFactory::forbidden( ErrorCodes::TENANT_SUSPENDED, 'This account has been suspended.' ); } }); } }
Custom domain exception pattern
Define your exception with a built-in error code so the handler stays clean:
class PaymentFailedException extends \RuntimeException { public function __construct(string $message = 'Payment gateway rejected the transaction.') { parent::__construct($message); } }
Then throw it anywhere in your application:
throw new PaymentFailedException('Card declined.');
The handler catches it and returns:
{ "success": false, "message": "Card declined.", "code": "PAYMENT_FAILED", "errors": {} }
ApiException — built-in base class
The package ships with ApiException, a base class your domain exceptions can extend.
It stores the error code and HTTP status directly on the exception — no renderable registration needed.
use Vandet\ApiResponse\Exceptions\ApiException; use Vandet\ApiResponse\Constants\ErrorCodes; class PaymentFailedException extends ApiException { public function __construct(string $message = 'Payment gateway rejected the transaction.') { parent::__construct(ErrorCodes::PAYMENT_FAILED, $message, 422); } } class TenantSuspendedException extends ApiException { public function __construct() { parent::__construct(ErrorCodes::TENANT_SUSPENDED, 'This account has been suspended.', 403); } }
Throw from anywhere — controller, action, service — and the package handler responds automatically:
// In an Action or Service if ($tenant->isSuspended()) { throw new TenantSuspendedException(); } // In a controller throw new PaymentFailedException('Card declined.');
{ "success": false, "message": "Card declined.", "code": "PAYMENT_FAILED", "errors": {} }
No need to register a renderable() for each exception type. Laravel calls render() on the exception directly — the package handles it automatically.
Generic one-off errors without a custom class
Use ResponseFactory::error() when you need a specific code and status without creating a dedicated exception class:
use Vandet\ApiResponse\Http\ResponseFactory; use Vandet\ApiResponse\Constants\ErrorCodes; return ResponseFactory::error(ErrorCodes::ORDER_CANCELLED, 'Order has been cancelled.', 409);
Tip — disable the built-in handler for types you own
If your service handles its own ModelNotFoundException with a domain-specific message,
disable the package's version in config/api-response.php to avoid conflicts:
'exceptions' => [ 'model_not_found' => false, // I handle this myself 'route_not_found' => false, ],
Response Envelope Reference
// Success
{ "success": true, "message": "...", "data": {} }
{ "success": true, "message": "...", "data": [], "pagination": {}, "links": {} }
{ "success": true, "message": "...", "data": [], "included": {} }
// Error
{ "success": false, "message": "...", "code": "DOMAIN_ENTITY_REASON", "errors": {} }
// Delete
HTTP 204 No Content
// Bulk partial (only error response that includes data)
{ "success": false, "message": "...", "code": "BULK_PARTIAL_FAILURE", "data": { "items": [] }, "errors": {} }
Optional fields (pagination, links, included, meta) are omitted entirely when absent — never null.
Running Tests
composer install ./vendor/bin/phpunit
Testing against a specific Laravel version
To test against a specific Laravel version locally, pin the relevant packages before installing:
# Laravel 12 composer require --dev "orchestra/testbench:^10" "illuminate/http:^12" "illuminate/support:^12" "illuminate/auth:^12" "illuminate/database:^12" "illuminate/pagination:^12" "illuminate/validation:^12" --no-update composer update --prefer-dist --no-audit # Laravel 13 composer require --dev "orchestra/testbench:^11" "illuminate/http:^13" "illuminate/support:^13" "illuminate/auth:^13" "illuminate/database:^13" "illuminate/pagination:^13" "illuminate/validation:^13" --no-update composer update --prefer-dist --no-audit
CI matrix
| PHP | Laravel 12 | Laravel 13 |
|---|---|---|
| 8.2 | ✓ | — |
| 8.3 | ✓ | ✓ |
| 8.4 | ✓ | ✓ |
Laravel 10 and 11 reached end-of-life and are no longer tested. Laravel 13 requires PHP 8.3+.
Changelog
| Version | Date | Change |
|---|---|---|
| 1.1.0 | 2026-07-01 | Split not_found into model_not_found / route_not_found; ApiException self-renders via render(); accepted() data now optional; removed domain-specific error codes; publishable stubs; symfony/http-kernel declared as explicit dependency; integration test suite; dropped Laravel 10 and 11 from CI (both EOL) |
| 1.0.0 | 2026-06-26 | Initial release |