Skip to content

Customization

Presets & configurable JSON structure

Every top-level key is renameable:

'keys' => [
    'status' => 'success',
    'message' => 'msg',
    'data' => 'result',
],
{ "success": true, "msg": "...", "result": {} }

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' => [
    'enabled' => ['request_id', 'execution_time', 'api_version', 'locale'],
],
{ "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'],
],
{ "debug": { "execution_time_ms": 12.4, "memory_usage_mb": 8.1, "queries": [...] } }

API versioning

'version' => env('API_RESPONSE_VERSION', 'v1'),
return api()->version('v2')->success(); // overrides for this response

Exposed automatically via the api_version meta provider when enabled.

Localization

Translating messages

return api()->updated()->localize('bhry98-api-response::api.updated');

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():

  1. The Accept-Language request header (first tag, e.g. fr out of fr-CA;q=0.9,en;q=0.5), matched against locale.supported when that list is non-empty.
  2. The authenticated user's locale column - the column name is configurable, so this works whatever your users table calls it (locale, lang, preferred_language, ...).
  3. locale.fallback (defaults to config('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:

Route::post('/login', ...)->middleware('api.rate_limit:5,60'); // 5 attempts per 60 seconds

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',
    ],
],
php artisan vendor:publish --tag=bhry98-api-response-migrations
php artisan migrate
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('api', 'api.audit');
})
  • queue.connection/queue.queue map directly onto StoreAuditLogEntry::onConnection()/onQueue(). Leave them null to use your app's default queue connection (config('queue.default'), e.g. sync, database, redis, ...) - set queue.connection to '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 hidden is 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 when capture.headers is on (so Authorization is redacted by default).
  • Uploaded files are never stored. They show up in request_body as {"file": "avatar.png", "size": 1024, "mime": "image/png"}.
  • methods is case-insensitive (['post'] and ['POST'] behave the same).
  • user_id is 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:

'map' => [
    // ...the defaults above
    App\Exceptions\InsufficientBalance::class => 409,
],
{ "status": false, "message": "Your balance is too low.", "data": null }

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:

'override_default_response' => env('API_RESPONSE_OVERRIDE_DEFAULT', false),
// 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 ResponseFormatterContract for a fully custom envelope shape.
  • Presets - implement PresetContract and register via PresetManager::register().
  • Data resolvers - the default DataResolverContract binding (DataResolver) can be swapped in your own service provider if you need custom auto-detection.
  • Meta providers - implement MetaProviderContract, register under meta.providers, then list the key under meta.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.