Carbon in Laravel: working with dates and time zones

Carbon in Laravel: working with dates and time zones
Quick answer

Laravel automatically casts the date columns of your models into Carbon objects. Remember three things: store everything in UTC and convert only for display with ->tz(), declare your own columns in casts(), and keep in mind that in Carbon 3 the diffIn* methods return a signed float instead of an absolute integer.

Almost every Laravel application handles dates: a publication date, the end of a trial, a delivery deadline, the gap between two events. Laravel hands that job to Carbon, an extension of PHP’s DateTime class, and automatically casts the date columns of your models into Carbon instances.

Part of the Web development reading path. This guide covers what you will actually do: create a date, convert it to the right time zone, measure a gap, display it in French, work with it in Eloquent and freeze it in your tests. Every result shown below was produced on Laravel 13.30.1 and Carbon 3.13.2.

What changed in Carbon 3

Laravel 11, 12 and 13 ship with Carbon 3. If you are reading code written for Laravel 8 or 9, two changes will catch you out.

1. The diffIn* methods return a float, no longer an integer. In Carbon 2, diffInDays() returned 113. In Carbon 3, the same expression returns the exact value:

php
$a = Carbon::parse('2026-09-02 14:30');
$b = Carbon::parse('2026-12-25 08:00');

$a->diffInDays($b);   // 113.72916666666667  (float)
(int) $a->diffInDays($b);   // 113

2. The result is signed. In Carbon 2 the difference was absolute by default. In Carbon 3 it comes back negative when the date passed as an argument is the earlier one:

php
$a->diffInDays($b);         //  113.72916666666667
$b->diffInDays($a);         // -113.72916666666667
$b->diffInDays($a, true);   //  113.72916666666667   (absolute: true)

In practice, an if ($date->diffInDays(now()) > 30) written for Carbon 2 can stop firing after an upgrade, because the value has turned negative. That is the most expensive trap of the migration.

Along the way, floatDiffInDays() and its variants are gone: they duplicated the new behaviour.

Creating a date

Laravel’s two global helpers cover most of what you need:

php
now();     // 2026-09-02 14:30:00  — the current instant
today();   // 2026-09-02 00:00:00  — today at midnight

For everything else, go through the facade. Mind the namespace: Laravel exposes its own subclass, Illuminate\Support\Carbon, which adds a few methods to Carbon\Carbon. That is the one to import in a Laravel application.

app/Services/Planning.php
<?php

use Illuminate\Support\Carbon;

Carbon::now();
Carbon::today();
Carbon::tomorrow('Europe/Paris');
Carbon::yesterday();

Carbon::create(2026, 2, 1, 10, 0, 0, 'Europe/Paris');   // 2026-02-01 10:00:00
Carbon::createFromDate(2026, 2, 1);                     // time = now
Carbon::createMidnightDate(2026, 2, 1);                 // time = 00:00:00
Carbon::createFromTimeString('10:15:00');               // date = today
Carbon::createFromFormat('d/m/Y', '01/02/2026');
Watch out for createFromDate()

createFromDate(2026, 2, 1) does not reset the time to zero: it keeps the current time. Tested on 2 September 2026 at 14:30, the call returns 2026-02-01 14:30:00. Use createMidnightDate() if you want midnight.

Carbon::parse() accepts just about anything, including plain English expressions:

php
Carbon::parse('2026-09-02T14:30:00+02:00');
Carbon::parse('last day of February 2024');   // 2024-02-29 00:00:00

Those relative expressions are tempting but brittle: last day of next month does not return the same thing depending on the day the code runs. Keep parse() for strings coming from a machine source (ISO 8601, timestamp) and build the other dates explicitly.

Time zones: the only model that holds up

The rule fits in one sentence: store everything in UTC, convert only for display. Laravel follows it by default, with config/app.php setting 'timezone' => 'UTC'. Do not change that value: it decides what is written to the database, and a database kept in local time becomes unmanageable at the first daylight saving switch.

The conversion happens on the instance, at display time:

php
$moment = Carbon::create(2026, 7, 1, 12, 0, 0, 'UTC');

$moment->copy()->tz('Europe/Paris');       // 2026-07-01 14:00:00
$moment->copy()->setTimezone('Asia/Tokyo'); // 2026-07-01 21:00:00

These three objects describe the same instant. You can check it: their Unix timestamp is identical. Changing the time zone does not move the moment, it changes the way you read it.

Two calls that look alike do need to be told apart, though:

php
Carbon::tomorrow('Europe/Paris');        // midnight in Paris
Carbon::tomorrow()->tz('Europe/Paris');  // midnight UTC, read in Paris time (02:00)

The first creates a date in the time zone. The second creates a date in UTC and then converts it. The list of accepted time zones is PHP’s, 419 entries on PHP 8.4.25:

php
timezone_identifiers_list();   // ['Africa/Abidjan', 'Africa/Accra', …]

To store a user’s time zone, add a timezone column to the users table and fill it from a drop-down built on that list. A package such as jamesmills/laravel-timezone automates detection at login, but a column and a form are enough in most cases.

Adding and subtracting

Every unit has a singular and a plural method, for adding as well as subtracting:

php
now()->addDay();
now()->addDays(30);
now()->subDay();
now()->subDays(30);

now()->addWeeks(2);
now()->addMonths(3);
now()->addYears(5);
now()->addHours(6);
now()->addMinutes(90);
now()->subWeekdays(3);   // skips Saturday and Sunday

Two behaviours are worth knowing before you meet them in production.

Carbon is mutable. addMonth() modifies the object you call it on, it does not return a copy:

php
$date = Carbon::create(2026, 1, 31);
$date->addMonth();
echo $date;   // 2026-03-03  —  $date has changed

$date = Carbon::create(2026, 1, 31);
echo $date->copy()->addMonth();   // 2026-03-03
echo $date;                       // 2026-01-31  —  $date is untouched

Months overflow. 31 January plus one month gives 3 March, because 31 February does not exist and Carbon carries the surplus over. If you want the end of the month, ask for it:

php
Carbon::create(2026, 1, 31)->copy()->addMonth();            // 2026-03-03
Carbon::create(2026, 1, 31)->copy()->addMonthNoOverflow();  // 2026-02-28

Avoiding mutation with CarbonImmutable

Rather than scattering copy() calls everywhere, you can make every date in the application immutable. Each operation then returns a new instance and the original never moves:

app/Providers/AppServiceProvider.php
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;

public function boot(): void
{
    Date::use(CarbonImmutable::class);
}

After that call, now() returns an instance of Carbon\CarbonImmutable, and so do the dates hydrated by Eloquent. It is a global change of behaviour: decide it at the start of a project, not on an application already written around addDay() calls that rely on mutation.

Measuring the gap between two dates

With the signed, floating-point values of Carbon 3, always spell out what you want:

php
$debut = Carbon::parse('2026-09-02 14:30');
$fin   = Carbon::parse('2026-12-25 08:00');

$debut->diffInDays($fin);              // 113.72916666666667
$debut->diffInDays($fin, true);        // absolute value
(int) $debut->diffInDays($fin);        // 113, whole days
round($debut->diffInHours($fin), 2);   // 2729.5
$debut->diffInWeekdays($fin);          // 82, weekends excluded
$debut->diffInMonths($fin);            // 3.733198924731183

For a readable output, diffForHumans() takes care of the wording, in French as well:

php
$fin->diffForHumans($debut);                 // '3 months after'
$fin->locale('fr')->diffForHumans($debut);   // '3 mois après'
$fin->locale('fr')->diffForHumans();         // 'dans 3 mois' (relative to now)

Displaying a date in French

format() uses the PHP codes and translates nothing: day and month names stay in English. For French you need translatedFormat() or isoFormat().

php
$d = Carbon::parse('2026-09-02 14:30');

$d->toDateTimeString();                      // '2026-09-02 14:30:00'
$d->toDateString();                          // '2026-09-02'
$d->toIso8601String();                       // '2026-09-02T14:30:00+00:00'
$d->format('d/m/Y H:i');                     // '02/09/2026 14:30'
$d->locale('fr')->translatedFormat('l j F Y'); // 'mercredi 2 septembre 2026'
$d->locale('fr')->isoFormat('LLLL');         // 'mercredi 2 septembre 2026 14:30'

translatedFormat() keeps the PHP codes and translates the labels. isoFormat() uses codes such as LLLL, LL and dddd, which follow the conventions of each language. To avoid writing locale('fr') everywhere, set the application locale once:

.env
APP_LOCALE=fr
APP_FAKER_LOCALE=fr_FR

Carbon in Eloquent models

created_at and updated_at are cast automatically, provided the columns really do exist in the database. They come back as instances of Illuminate\Support\Carbon, not as strings.

php
$post = Post::first();

$post->created_at->locale('fr')->isoFormat('LL');   // '2 septembre 2026'
$post->created_at->diffForHumans();
$post->created_at->isToday();

In a Blade view, the same calls are written the same way inside curly braces.

For your own columns, added by a migration, declare them in the casts() method. Careful: the old protected $dates = [...] property was removed in Laravel 10 and does nothing at all any more. Code that still relies on it will leave you with strings where you expected objects.

app/Models/Post.php
protected function casts(): array
{
    return [
        'published_at' => 'datetime',
        'expires_at'   => 'datetime:Y-m-d',
        'starts_at'    => 'immutable_datetime',
    ];
}

The format passed after datetime: does not change how the value is stored, only how it is serialised to JSON. Date comparisons are then written naturally in Eloquent queries:

php
Post::where('published_at', '<=', now())->get();
Post::whereDate('published_at', today())->get();
Post::whereBetween('published_at', [now()->subWeek(), now()])->get();

Freezing time in tests

A test that depends on the real clock always ends up failing on a 29 February or just after midnight. Carbon can lie about the current date, and Laravel exposes helpers for it:

tests/Feature/TrialTest.php
$this->travelTo(Carbon::parse('2026-09-02 14:30'));

expect(now()->toDateTimeString())->toBe('2026-09-02 14:30:00');

$this->travel(5)->days();   // moves the clock forward by 5 days
$this->travelBack();        // back to the real clock

Outside Laravel tests, the raw equivalent is Carbon::setTestNow(), which you cancel with a call without arguments. That is the mechanism used to produce the values in this guide, all computed from 2 September 2026 at 14:30 UTC.

php
Carbon::setTestNow('2026-09-02 14:30:00');
// …
Carbon::setTestNow();   // releases the clock

Common errors

diffInDays() returns a signed float Since Carbon 3, the difference is negative when the argument is the earlier date, and it carries decimals. A condition written for Carbon 2 can stop firing: pass absolute: true or cast to (int).
Carbon is mutable $date->addMonth() modifies $date. Call copy() before the operation, or switch the application to CarbonImmutable with Date::use().
Months overflow 31 January + 1 month gives 3 March, not 28 February. Use addMonthNoOverflow() to stay inside the month.
The $dates property no longer does anything Removed in Laravel 10. Declare your date columns in the casts() method, otherwise you are handling strings.
createFromDate() does not reset the time It keeps the current time. createMidnightDate() does give 00:00:00.
format() does not translate Day and month names stay in English. Use translatedFormat() or isoFormat() instead.

CarbonLaravelPHP

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.