Skip to main content
Laravel, shipping fast.
Chapter 4 ยท The Request Pipeline

Global API Middlewares (affect every API route)

Julian Beaujardin

In Laravel 13 there is no Kernel to edit. Middleware is appended in bootstrap/app.php, and it runs on every request in the order you add it, before the controller is reached.

We register two global API middlewares so every endpoint gets consistent localization and compression:

->withMiddleware(function (Middleware $middleware) {
    $middleware->append([
        SetRequestLocaleMiddleware::class,
        GzipResponseMiddleware::class,
    ]);
})

Both classes come from the shared package. Neither is defined in this service, and that is deliberate: every service in the fleet should localise and compress a response identically, and the only way to guarantee that is to give them no opportunity to differ.

This also affects the /health route from Chapter 1. It stays public (no auth or rate limiting), but it now inherits global API behavior: the locale is set for translated labels and validation messages, and the response can be gzipped when the client accepts gzip and the payload is large enough.

SetRequestLocaleMiddleware: Language Detection

Let your API consumers choose their language without special client libraries or extra wiring. This middleware keeps localization explicit and predictable.

Clients can specify their preferred language in two ways, with this priority:

  1. Query parameter (?locale=es): Explicit, highest priority. Useful for testing or when a user overrides their preference.
  2. Accept-Language header: Standard HTTP header, second priority. Must be one of the supported locale codes (en, es).
  3. Default locale: Falls back to application default if neither is provided.

This cascading approach gives clients flexibility while keeping defaults sensible.

final readonly class SetRequestLocaleMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $locale = $request->query('locale');

        if (! $locale) {
            $locale = $this->parseAcceptLanguage($request->header('Accept-Language', ''));
        }

        /** @var array<string> $supportedLocales */
        $supportedLocales = config('webplo.supported_locales', ['en', 'es']);

        if (is_string($locale) && in_array($locale, $supportedLocales, true)) {
            App::setLocale($locale);
        }

        return $next($request);
    }
}

Accept-Language is not a locale. It is a ranked list of them โ€” fr;q=0.9,en-US;q=0.8 means French if you have it, American English otherwise โ€” so comparing the raw header against a list of supported codes fails for every real browser that sends one. Parsing it means honouring the quality values in order, and falling back from a variant to its base language:

private function parseAcceptLanguage(string $acceptLanguage): ?string
{
    // ... parse each entry into ['locale' => ..., 'quality' => ...],
    // discarding any q outside the 0.0-1.0 range RFC 7231 allows.

    usort($parsed, fn (array $a, array $b) => $b['quality'] <=> $a['quality']);

    foreach ($parsed as $item) {
        if (in_array($item['locale'], $supportedLocales, true)) {
            return $item['locale'];
        }

        // 'en-US' is a dialect of a language we do speak.
        $baseLocale = explode('-', $item['locale'])[0];

        if (in_array($baseLocale, $supportedLocales, true)) {
            return $baseLocale;
        }
    }

    return null;
}

Keep the supported set in config rather than an enum. The list of languages an API answers in is a deployment fact, not a code fact โ€” a new market should be a config change, not a release.

The validation against that set is crucial: it prevents injection attacks. A malicious client can't request locale=../../etc/passwd or locale='; DROP TABLE users;--. Invalid locales are silently ignored, falling back to the default.

GET /api/licenses
Accept-Language: en

Response: English validation errors, English error messages, English documentation.

GET /api/licenses
Accept-Language: es

Response: Spanish validation errors, Spanish error messages.

GET /api/licenses?locale=es
Accept-Language: en-US

Response: Spanish (query param overrides header).

GET /api/licenses?locale=fr

Response: Default locale (French isn't supported, so application default applies).

GET /api/licenses
Accept-Language: en-US

Response: English. en-US is not in the supported set, but its base language is, so the fallback resolves it to en.

Once the middleware sets the locale with App::setLocale(), everything in Laravel automatically uses that locale:

// app/Http/Requests/CreateLicenseRequest.php
public function messages(): array
{
    return [
        'name.required' => Lang::get('validation.name.required'),
        'name.string' => Lang::get('validation.name.string'),
        'name.max' => Lang::get('validation.name.max'),
        'domain.required' => Lang::get('validation.domain.required'),
        'domain.string' => Lang::get('validation.domain.string'),
        'domain.max' => Lang::get('validation.domain.max'),
    ];
}
{
    "errors": [
        {
            "name": ["The license name is required."],
            "domain": ["The domain is required."]
        }
    ]
}

Internationalization isn't just about translation. It's about respect. When a user gets an error message in their language, they trust your API more. They don't feel like outsiders. When Spanish speakers get Spanish errors, English speakers get English errors, and Mandarin speakers get Mandarin errors, you're not just being inclusive, you're being professional. Translation files are cached after the first load, and setting the locale happens once per request, so the overhead is negligible.

Key patterns:

  1. Create translation files for each language
  2. Use an enum for supported locales
  3. Middleware sets locale from request (param or header)
  4. Validation messages are auto-translated

The outcome is simple: error messages, enums, and health labels follow the user's language without changing controller logic.