Customization¶
Presets & configurable JSON structure¶
Every top-level key is renameable:
Set 'wrap' => true to nest the whole envelope under a top-level data key.
Four built-in presets control the overall response shape (not just key names):
| Preset | Behaviour |
|---|---|
default |
The standard {status, message, data, errors?, meta?, links?} envelope. |
minimal |
Only {status, message, data, errors?} - no meta/links/extras/debug. |
mobile |
Same as default but recursively strips null values to save bandwidth. |
jsonapi |
A JSON:API-inspired {data, meta, links, errors} shape (not a strict spec implementation). |
config(['bhry98-api-response.preset' => 'minimal']); // global default
return api()->preset('jsonapi')->success()->data($user); // per response
Register your own preset:
use Bhry98\LaravelApiResponse\Contracts\{PresetContract, ResponseFormatterContract};
use Bhry98\LaravelApiResponse\Presets\PresetManager;
class ShoutyFormatter implements ResponseFormatterContract
{
public function format(\Bhry98\LaravelApiResponse\Data\ResponsePayload $payload): array
{
return ['STATUS' => $payload->success, 'MESSAGE' => strtoupper($payload->message)];
}
}
class ShoutyPreset implements PresetContract
{
public function __construct(private ShoutyFormatter $formatter) {}
public function formatter(): ResponseFormatterContract { return $this->formatter; }
}
// in a service provider
app(PresetManager::class)->register('shouty', ShoutyPreset::class);
return api()->preset('shouty')->success()->message('done');
Global metadata¶
Opt in per key:
{ "meta": { "request_id": "...", "execution_time": "12.4ms", "api_version": "v1", "locale": "en" } }
Explicit ->meta([...]) values always win over the automatic ones on key collisions. Add your own provider by implementing MetaProviderContract and registering it under meta.providers.
There is also an optional AttachRequestId middleware that reuses an incoming X-Request-Id header (or generates one) and echoes it back as a response header, so the request_id meta stays consistent with your logs:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\Bhry98\LaravelApiResponse\Middleware\AttachRequestId::class);
})
Debug mode¶
Development only - requires both flags to be true, so it can never leak into production by accident:
'debug' => [
'enabled' => env('API_RESPONSE_DEBUG', false),
'include' => ['queries', 'time', 'memory', 'exception', 'trace'],
],
API versioning¶
Exposed automatically via the api_version meta provider when enabled.
Localization¶
Translating messages¶
The package ships lang/en/api.php (publishable to lang/vendor/bhry98-api-response) with an entry per ready-made shortcut. config('bhry98-api-response.messages') remains the primary/default source so a config override is never silently shadowed by a translation - use localize() explicitly when you want locale-driven messages instead.
Resolving the request's locale (Accept-Language + user column)¶
Opt-in per-request locale resolution via the api.locale middleware alias. It resolves a locale in this priority order and calls App::setLocale():
- The
Accept-Languagerequest header (first tag, e.g.frout offr-CA;q=0.9,en;q=0.5), matched againstlocale.supportedwhen that list is non-empty. - The authenticated user's locale column - the column name is configurable, so this works whatever your
userstable calls it (locale,lang,preferred_language, ...). locale.fallback(defaults toconfig('app.locale')).
'locale' => [
'enabled' => env('API_RESPONSE_LOCALE_ENABLED', false),
'supported' => ['en', 'ar', 'fr'], // empty array = accept any tag from the header
'user_column' => env('API_RESPONSE_LOCALE_USER_COLUMN', 'locale'),
'fallback' => env('API_RESPONSE_LOCALE_FALLBACK', config('app.locale', 'en')),
],
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('api', 'api.locale');
})
Combine with the locale global metadata provider (above) to echo the resolved locale back on every response.
Rate limiting¶
Opt-in throttling for any route/group via the api.rate_limit middleware alias. It wraps Laravel's own RateLimiter, but returns a standard api()->tooManyRequests() envelope - with Retry-After/X-RateLimit-* headers - instead of the framework's default ThrottleRequestsException response.
'rate_limit' => [
'enabled' => env('API_RESPONSE_RATE_LIMIT_ENABLED', false),
'max_attempts' => env('API_RESPONSE_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('API_RESPONSE_RATE_LIMIT_DECAY_SECONDS', 60),
'by' => env('API_RESPONSE_RATE_LIMIT_BY', 'ip'), // "ip", "user" or "route"
'key_prefix' => env('API_RESPONSE_RATE_LIMIT_PREFIX', 'api-response-rate-limit'),
'message' => null, // null falls back to messages.too_many_requests
],
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('api', 'api.rate_limit');
})
Override the configured max_attempts/decay_seconds for a specific route via middleware parameters. The second parameter is in seconds, not minutes:
by: 'user' buckets authenticated requests by the user's auth identifier and falls back to IP for guests; by: 'route' buckets by route name (or URI path when unnamed).
Audit logging¶
Opt-in request/response auditing via the api.audit middleware alias. Every matching request is written to the api_response_audit_logs table (name configurable) through the queued StoreAuditLogEntry job by default, so logging never adds latency to the actual response - set queue.enabled to false to write synchronously instead (handy for local dev/testing).
'audit_log' => [
'enabled' => env('API_RESPONSE_AUDIT_LOG_ENABLED', false),
'connection' => env('API_RESPONSE_AUDIT_LOG_DB_CONNECTION'), // null = default DB connection
'table' => env('API_RESPONSE_AUDIT_LOG_TABLE', 'api_response_audit_logs'),
'queue' => [
'enabled' => env('API_RESPONSE_AUDIT_LOG_QUEUE_ENABLED', true),
'connection' => env('API_RESPONSE_AUDIT_LOG_QUEUE_CONNECTION'), // null = config('queue.default')
'queue' => env('API_RESPONSE_AUDIT_LOG_QUEUE_NAME'), // null = the connection's default queue
],
'capture' => [
'request_body' => true,
'response_body' => true,
'headers' => false,
],
'methods' => [], // e.g. ['POST', 'PUT', 'PATCH', 'DELETE'] - empty means every method
'exclude_routes' => [], // route names or URI patterns (fnmatch), e.g. 'telescope*'
'hidden' => [
'password', 'password_confirmation', 'token', 'secret',
'authorization', 'api_key', 'access_token', 'refresh_token', 'card_number', 'cvv',
],
],
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('api', 'api.audit');
})
queue.connection/queue.queuemap directly ontoStoreAuditLogEntry::onConnection()/onQueue(). Leave themnullto use your app's default queue connection (config('queue.default'), e.g.sync,database,redis, ...) - setqueue.connectionto'database'(or'redis', etc.) explicitly if you want audit logs on a different connection than the rest of your app's jobs.- Anything listed under
hiddenis redacted (replaced with"***") before the entry is saved. Matching is case-insensitive and applies to keys at any depth in the request/response body, and to request headers whencapture.headersis on (soAuthorizationis redacted by default). - Uploaded files are never stored. They show up in
request_bodyas{"file": "avatar.png", "size": 1024, "mime": "image/png"}. methodsis case-insensitive (['post']and['POST']behave the same).user_idis a string column, so integer, UUID and ULID user keys all work.- Query the log like any other Eloquent model:
use Bhry98\LaravelApiResponse\Models\AuditLog;
AuditLog::where('user_id', $user->id)->latest('created_at')->get();
Hooks¶
Run arbitrary logic against every response before it is formatted:
use Bhry98\LaravelApiResponse\Facades\ApiResponse;
use Bhry98\LaravelApiResponse\Data\ResponsePayload;
ApiResponse::before(function (ResponsePayload $payload) {
$payload->extra['server'] = gethostname();
});
ApiResponse::after(function (ResponsePayload $payload) {
Log::info('api.response', ['status' => $payload->success]);
});
Events¶
Bhry98\LaravelApiResponse\Events\ResponseCreated::class; // fired for every response
Bhry98\LaravelApiResponse\Events\ResponseFailed::class; // fired when success === false
Bhry98\LaravelApiResponse\Events\ResponseSent::class; // fired once the JsonResponse is built
Exception handling¶
This is fully opt-in and off by default:
'exceptions' => [
'override_exception_handler' => env('API_RESPONSE_OVERRIDE_EXCEPTIONS', false),
'map' => [
ValidationException::class => 422,
AuthenticationException::class => 401,
AuthorizationException::class => 403,
ModelNotFoundException::class => 404,
HttpExceptionInterface::class => null, // uses the exception's own status code
],
],
When it's enabled, exceptions thrown during JSON requests (or any api/* route) are rendered as api() responses:
| Exception | Response |
|---|---|
ValidationException |
api()->unprocessable($e->errors()), 422 |
AuthenticationException |
api()->unauthorized(), 401 |
AuthorizationException |
api()->forbidden($e->getMessage()), 403 (or the status from Gate::denyWithStatus() / denyAsNotFound()) |
ModelNotFoundException |
api()->notFound(), 404, with the generic "Resource not found." message, so model class names and IDs never reach the client |
any HttpExceptionInterface |
its own status code, message and headers (e.g. Retry-After, Allow) |
a class you added to map |
a failed response with the mapped status code and the exception's message |
| anything else | api()->internal(), 500, with the real message only when app.debug is true |
Use map to change the status code of a built-in type or to register your own domain exceptions:
An explicit status on the exception itself (ValidationException::withMessages(...)->status(400), Gate::denyWithStatus(404)) always beats the map.
Overriding the default response()->json()¶
Also fully opt-in:
// with the flag enabled
return response()->json(['id' => 1]);
// => {"status": true, "data": {"id": 1}}
It's never forced: leave it false (the default) and response()->json() behaves exactly like stock Laravel. Responses built with api() (and the legacy BResponse* classes) are already full envelopes, so they are never wrapped a second time, whether or not this flag is on.
Testing helpers¶
These assertions are registered automatically on Illuminate\Testing\TestResponse, so you don't need a trait or import. They respect your renamed keys and the wrap option:
$response->assertApiSuccess('User created.');
$response->assertApiError('Something went wrong.');
$response->assertApiValidation(['email']);
$response->assertApiMessage('User created.');
$response->assertApiStatus(true);
$response->assertApiData(['id' => 1]);
Extending the package¶
- Formatters - implement
ResponseFormatterContractfor a fully custom envelope shape. - Presets - implement
PresetContractand register viaPresetManager::register(). - Data resolvers - the default
DataResolverContractbinding (DataResolver) can be swapped in your own service provider if you need custom auto-detection. - Meta providers - implement
MetaProviderContract, register undermeta.providers, then list the key undermeta.enabled. - Macros -
ResponseBuilder::macro()/ApiResponse::macro()for one-off custom shortcuts. - Hooks -
ApiResponse::before()/ApiResponse::after()for cross-cutting concerns (logging, request ids, ...). - Events - listen to
ResponseCreated/ResponseFailed/ResponseSent.
Next¶
See the Upgrade Guide for backward compatibility with the pre-2.0 API and a look at the package's internal architecture.