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.Fis 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'sde.
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) overtranslatedFormat()— it handles ordering and connectors, not just word substitution. - Set
Carbon::setLocale()alongsideApp::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.