· 9 min read

Localizing a Laravel API: Accept-Language, Fallbacks and Translated Errors

Localizing a web app is mostly a solved shape: middleware reads the session, sets the locale, views render. An API has no session, no cookie you can rely on, and often no user at all on public endpoints. The locale has to come out of the request itself, and every consumer expresses it slightly differently.

Here's how to do it without the two mistakes that show up most: trusting a fallback that doesn't behave the way it reads, and returning localized responses that get cached and served to the wrong client.

Accept-Language, and one surprise

The standard mechanism is the Accept-Language header, carrying weighted BCP 47 tags:

Accept-Language: es-MX,es;q=0.9,en;q=0.8

Laravel inherits Symfony's negotiation, so you don't parse this yourself:

$request->getPreferredLanguage(['en', 'es', 'de']);  // "es"

Two behaviours are worth knowing precisely, because both are easy to get wrong from reading the method name.

It normalizes to underscores. Whatever you pass in, and whatever the client sent, you get Laravel-style locales back:

$request->headers->set('Accept-Language', 'pt-BR,pt;q=0.9');

$request->getPreferredLanguage(['en', 'pt-BR']);  // "pt_BR", not "pt-BR"

That's genuinely helpful — it means this path won't hand you a hyphenated locale that breaks trans_choice(), as covered in the pluralization deep dive.

When nothing matches, it returns the first item of your array — not null.

$request->headers->set('Accept-Language', 'de');

$request->getPreferredLanguage(['en', 'es']);  // "en"

So this is dead code, and it reads like a safety net:

// The ?? never fires. getPreferredLanguage doesn't return null here.
$locale = $request->getPreferredLanguage($supported) ?? config('app.locale');

Put your default first in the array and drop the fallback:

// 'en' first, so an unmatched header resolves to English.
$locale = $request->getPreferredLanguage(['en', 'es', 'de', 'pt_BR']);

The middleware

final class SetApiLocale
{
    public function handle(Request $request, Closure $next): Response
    {
        $supported = config('app.available_locales');   // ['en', 'es', 'de', 'pt_BR']

        $locale = $this->explicitLocale($request, $supported)
            ?? $request->getPreferredLanguage($supported);

        App::setLocale($locale);
        Carbon::setLocale($locale);

        return $next($request)->header('Content-Language', $locale);
    }

    /**
     * An explicit ?lang= or X-Locale wins over header negotiation, so a
     * consumer can override what the browser sends.
     */
    private function explicitLocale(Request $request, array $supported): ?string
    {
        $explicit = $request->query('lang') ?? $request->header('X-Locale');

        if (! $explicit) {
            return null;
        }

        $normalized = str_replace('-', '_', $explicit);

        return in_array($normalized, $supported, strict: true) ? $normalized : null;
    }
}

Three deliberate decisions in there.

An explicit parameter beats the header. Server-to-server consumers often can't control Accept-Language but can add a query parameter. Mobile apps frequently want to follow an in-app language setting rather than the device locale.

Explicit input is allow-listed, not trusted. ?lang= reaches setLocale(), so it gets checked against known locales. Returning null on an unknown value means you fall through to negotiation rather than 400-ing — friendlier for a parameter the client may have guessed at.

Content-Language goes on the response. It tells the client what it actually got, which matters when negotiation didn't give them their first choice. It's also the header a cache needs to reason about.

Caching: the mistake that hurts

Here's the failure that turns a localization detail into an outage-shaped problem. Any cache between you and your clients — a CDN, a reverse proxy, Laravel's own response cache — keys on the URL. Two requests to /api/products with different Accept-Language headers look identical to it. The first response cached wins, and every client afterwards gets that language.

Advertise the header your response depends on:

return $next($request)
    ->header('Content-Language', $locale)
    ->header('Vary', 'Accept-Language');

Vary: Accept-Language tells caches to keep a separate entry per header value. If you also honour ?lang=, that's already part of the URL, so it varies naturally.

Be aware of the trade: Accept-Language values are near-unique in the wild — browsers send long weighted lists — so varying on the raw header fragments your cache badly. If hit rate matters, normalize before caching. Put the locale in the path instead:

GET /api/v1/es/products
GET /api/v1/products?lang=es

An explicit locale in the URL is cache-friendly, debuggable, and unambiguous in a bug report. For a public API consumed by many clients, it's usually the better design, with header negotiation as a convenience that redirects or defaults.

Translated validation errors

Laravel's validation errors resolve through the translator, so a 422 is localized once the locale is set:

{
    "message": "El campo correo electrónico es obligatorio.",
    "errors": {
        "email": ["El campo correo electrónico es obligatorio."]
    }
}

That requires lang/es/validation.php to exist and its attributes array to be filled in, or you get English field names inside Spanish sentences. The validation messages post covers that file in full.

One API-specific decision: don't make clients parse your prose. A message is for humans; code should branch on a stable identifier. If consumers need to react programmatically, give them something that doesn't change with the locale:

return response()->json([
    'message' => __('errors.insufficient_quota'),   // localized, for display
    'code' => 'INSUFFICIENT_QUOTA',                 // stable, for logic
], 402);

Without the code, someone will match on the string, and your next copy edit becomes a breaking change for them.

Exception messages

Laravel's HTTP exception messages are English by default. Wrap them in your renderer:

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (ModelNotFoundException $e, Request $request) {
        if (! $request->expectsJson()) {
            return null;
        }

        return response()->json([
            'message' => __('errors.not_found'),
            'code' => 'NOT_FOUND',
        ], 404);
    });
})

Two notes. Returning null lets Laravel handle non-JSON requests normally, so you're not breaking your web routes. And keep exception messages — the ones in your own throw statements — in English: they go to logs read by your team, not to users. Translate at the boundary where the response is built, not where the error is raised.

Queued jobs lose the locale

This is the API bug that surfaces days later. A request localized to Spanish dispatches a job; the job runs on a worker with no request context and English as the app default. The confirmation email arrives in the wrong language.

Pass the locale explicitly and restore it in the job:

final class SendOrderConfirmation implements ShouldQueue
{
    use Queueable, Localizable;

    public function __construct(
        private readonly Order $order,
        private readonly string $locale,
    ) {}

    public function handle(): void
    {
        $this->withLocale($this->locale, function () {
            Mail::to($this->order->email)->send(new OrderConfirmation($this->order));
        });
    }
}
SendOrderConfirmation::dispatch($order, app()->getLocale());

Illuminate\Support\Traits\Localizable gives you withLocale(), which sets the locale, runs the callback, and restores the previous value in a finally block. That last part matters on a long-running worker: without restoration, one job's locale leaks into the next job on the same process. Don't just call App::setLocale() in handle().

Notifications have this built in — implement HasLocalePreference on your user model and Laravel uses it automatically:

class User extends Authenticatable implements HasLocalePreference
{
    public function preferredLocale(): ?string
    {
        return $this->locale;
    }
}

What to localize, and what not to

Not everything in a JSON response should follow the request locale. A useful split:

Localize: validation messages, error messages meant for display, enum labels, anything the client will render as-is.

Don't localize: identifiers, enum values, timestamps, machine-readable codes. Return created_at as ISO 8601 in UTC and let the client format it — you don't know their timezone, and a formatted date is much harder to work with than an instant.

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'status' => $this->status->value,            // "shipped" — stable
        'status_label' => __("orders.status.{$this->status->value}"),
        'created_at' => $this->created_at->toIso8601String(),   // UTC, unformatted
        'total' => $this->total_cents,               // integer minor units
        'currency' => $this->currency,
    ];
}

Sending both status and status_label looks redundant and isn't: one is for logic, one is for humans. Same reasoning as the error code. And money as integer minor units plus a currency code lets the client format it correctly for its own locale — a pre-formatted "1.234,50 €" is lossy, since the consumer has to parse a localized number back out before it can do arithmetic.

Testing it

These are cheap tests that catch real regressions:

it('negotiates the locale from Accept-Language', function () {
    $this->withHeader('Accept-Language', 'es-MX,es;q=0.9')
        ->getJson('/api/v1/products')
        ->assertOk()
        ->assertHeader('Content-Language', 'es');
});

it('lets an explicit lang parameter win over the header', function () {
    $this->withHeader('Accept-Language', 'es')
        ->getJson('/api/v1/products?lang=de')
        ->assertHeader('Content-Language', 'de');
});

it('falls back to english for an unsupported language', function () {
    $this->withHeader('Accept-Language', 'sw')
        ->getJson('/api/v1/products')
        ->assertHeader('Content-Language', 'en');
});

it('returns validation errors in the requested locale', function () {
    $this->withHeader('Accept-Language', 'es')
        ->postJson('/api/v1/orders', [])
        ->assertStatus(422)
        ->assertJsonPath('errors.email.0', 'El campo correo electrónico es obligatorio.');
});

it('advertises that responses vary by language', function () {
    $this->getJson('/api/v1/products')->assertHeader('Vary', 'Accept-Language');
});

The last one looks trivial and protects you from a whole class of cache bug that is genuinely unpleasant to debug in production.

Checklist

  • getPreferredLanguage() normalizes to underscores and returns the first array element when nothing matches. Put your default first; drop the ??.
  • Let an explicit ?lang= or X-Locale override the header, allow-listed against known locales.
  • Send Content-Language, and Vary: Accept-Language if you negotiate — or put the locale in the path for cache-friendliness.
  • Pair every localized message with a stable machine-readable code.
  • Keep thrown exception messages in English; translate at the response boundary.
  • Pass the locale into queued jobs and use the Localizable trait, so it's restored afterwards.
  • Return raw timestamps and integer money; let clients format.

The locale plumbing is a one-off. Keeping the strings behind it complete across every language your API serves is the ongoing part — and a missing key in an API response is worse than on a web page, because a consumer may be parsing it. LangSyncer shows coverage per language, fills gaps with AI, and publishes fixes without a deploy.

We use cookies to improve your experience and analyze site traffic. Cookie Policy

Cookie Preferences

Essential

Required for the site to work

Analytics

Help us improve the site

Marketing

Personalized ads and content