· 7 min read

Localized Dates in Laravel with Carbon: isoFormat, translatedFormat and What Breaks

You translated every string in the app, then someone sends a screenshot of the Spanish invoice page reading "August 11, 2026". The date wasn't in a lang file, so it was never part of the translation pass — and date('F j, Y') doesn't care what locale you set.

Carbon gives you three ways to format a date and they localize to three different degrees. Picking the wrong one is the most common reason a "fully localized" Laravel app still shows English dates.

The three methods, side by side

Take 2026-08-11 14:30:00 and ask for a long date in Spanish:

$date = Carbon::parse('2026-08-11 14:30:00')->locale('es');

$date->format('F j, Y');            // "August 11, 2026"  ← not localized at all
$date->translatedFormat('j F Y');   // "11 agosto 2026"   ← words translated
$date->isoFormat('LL');             // "11 de agosto de 2026"  ← correct Spanish

Three different results from the same date and locale. The distinction:

  • format() is PHP's native formatter. It has no concept of locale and never will. F is always "August".
  • translatedFormat() takes the same PHP format tokens but translates month and day names. It respects your token ordering — which is the problem, because ordering is part of what differs between languages.
  • isoFormat() uses locale-aware patterns derived from CLDR. You ask for "a long date" and each locale decides what that means, including connectors like Spanish's de.

That de is the tell. No amount of rearranging PHP tokens produces "11 de agosto de 2026", because the connector isn't a token — it's part of the Spanish long-date pattern. translatedFormat() cannot get there.

isoFormat tokens worth knowing

isoFormat() uses Moment.js-style tokens. The localized presets are the useful part:

| Token | en | es | de | ja | |---|---|---|---|---| | L | 08/11/2026 | 11/08/2026 | 11.08.2026 | 2026/08/11 | | LL | August 11, 2026 | 11 de agosto de 2026 | 11. August 2026 | 2026年8月11日 | | LT | 2:30 PM | 14:30 | 14:30 | 14:30 | | LLL | August 11, 2026 2:30 PM | 11 de agosto de 2026 14:30 | 11. August 2026 14:30 | 2026年8月11日 14:30 | | LLLL | Tuesday, August 11, 2026 2:30 PM | martes, 11 de agosto de 2026 14:30 | Dienstag, 11. August 2026 14:30 | 2026年8月11日 火曜日 14:30 |

That table is the whole argument. L alone covers three incompatible orderings — month-first, day-first, year-first — and the 12-versus-24-hour clock in LT is handled without you writing a conditional. Hardcode 'm/d/Y' and you've silently told every European user the eleventh of August is the eighth of November.

Use the presets by default:

<time datetime="{{ $invoice->issued_at->toIso8601String() }}">
    {{ $invoice->issued_at->isoFormat('LL') }}
</time>

The datetime attribute stays machine-readable and locale-independent; the visible text is localized. Worth doing for anything a crawler or a screen reader might read.

When you need specific components rather than a preset, the individual tokens localize too — MMMM for the full month, dddd for the weekday, Do for an ordinal day:

$date->isoFormat('dddd, Do MMMM');   // es → "martes, 11º agosto"

Setting the locale so it actually applies

Carbon doesn't read App::setLocale() by itself. In a Laravel app the wiring usually happens in one of three ways, and mixing them is where confusion starts.

Per instance — explicit, and the only one that's safe in a loop over mixed locales:

$date->locale('es')->isoFormat('LL');

Globally, following the app locale — set it wherever you set the app locale, typically middleware:

public function handle(Request $request, Closure $next): Response
{
    $locale = $request->user()?->locale ?? config('app.locale');

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

    return $next($request);
}

In a service provider, if the locale is fixed per request early enough:

public function boot(): void
{
    Carbon::setLocale(config('app.locale'));
}

Two things to watch. Carbon::setLocale() is global mutable state, so a queued job that sets it for one user leaks into the next job on that worker — set it explicitly per job, or use ->locale() per instance in anything that runs outside a request. And Carbon accepts hyphenated locales (pt-BR), unlike Laravel's plural rules, which only match underscores — so a locale string that works for dates may still break trans_choice(). We covered that trap in the trans_choice() deep dive.

Timezones are a separate problem

Localizing the words and getting the instant right are independent, and only one of them is Carbon's default concern:

$date = Carbon::parse('2026-08-11 23:30:00', 'UTC');

$date->locale('es')->isoFormat('LLL');
// "11 de agosto de 2026 23:30" — Spanish, but in UTC

$date->copy()->setTimezone('Europe/Madrid')->locale('es')->isoFormat('LLL');
// "12 de agosto de 2026 1:30" — Spanish, and the right day

Note the date changed. A user in Madrid seeing "11 August" for something that happened on the twelfth in their own timezone is a bug that survives a long time, because it only shows up near midnight. Convert the timezone first, then format:

$user->timezone
    ? $date->copy()->setTimezone($user->timezone)->locale($user->locale)->isoFormat('LLL')
    : $date->locale($user->locale)->isoFormat('LLL');

If your app stores per-user preferences, do this once in an accessor or a Blade component rather than at every call site. It's the kind of two-line transformation that ends up subtly different in fifteen places.

Relative times

diffForHumans() is localized already, and it's genuinely good across languages:

$date->locale('es')->diffForHumans();   // "hace 3 días"
$date->locale('de')->diffForHumans();   // "vor 3 Tagen"
$date->locale('ar')->diffForHumans();   // "منذ 3 أيام"
$date->locale('ja')->diffForHumans();   // "3日前"

You get plural handling for free here, which is worth appreciating — Arabic and Polish plural forms for "3 days ago" are exactly the sort of thing you'd otherwise be hand-writing in a lang file.

The trap is mixing it with your own translations:

{{-- Don't: "hace 3 días" already contains the relative framing --}}
{{ __('Updated') }} {{ $post->updated_at->diffForHumans() }}

In English "Updated 3 days ago" reads fine. In Spanish "Actualizado hace 3 días" happens to work too. In Japanese, prefixing a translated label to "3日前" produces word order that a native speaker would not write. If the sentence needs to be a sentence, make the whole thing a translation key with the relative time as a placeholder:

// lang/es/posts.php
'updated_ago' => 'Actualizado :time',
{{ __('posts.updated_ago', ['time' => $post->updated_at->diffForHumans()]) }}

Now a translator can move :time wherever their language needs it.

Dates in lang files: don't

It's tempting to put format strings in translation files:

// lang/es/formats.php — avoid this
'date_long' => 'd \d\e F \d\e Y',

It looks like it works and it fails in two ways. Escaped literals inside PHP date strings are fragile and easy for a translator to break, and you're reimplementing by hand a table CLDR already maintains for every locale. isoFormat('LL') gives you the same result, correctly, for languages you've never configured.

The exception is a format that's a genuine product decision rather than a locale convention — a compact MMM D for a dashboard column, say. Even then, keep it as a constant in code, not a translatable string.

Quick checklist

  • Never format() for user-facing dates. It ignores locale entirely.
  • Prefer isoFormat() with presets (L, LL, LT, LLL, LLLL) over translatedFormat() — it handles ordering and connectors, not just word substitution.
  • Set Carbon::setLocale() alongside App::setLocale(), and pass ->locale() explicitly in queued jobs.
  • Convert timezone before formatting, or you'll show the wrong day near midnight.
  • Wrap diffForHumans() in a full translation key when it sits inside a sentence.
  • Keep date patterns out of lang files. CLDR already has them.

Dates are the part of localization that lives in code rather than in translation files, which is why they get missed. Everything that is in a file — labels, validation, emails, plural forms — is where a tool earns its keep. LangSyncer imports your existing lang files, shows exactly which languages are behind, fills gaps with AI and publishes to production without a deploy. Per-language specifics for dates and plurals are collected in our Laravel localization guides.

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