Gérer les dates et les fuseaux horaires avec Carbon dans Laravel

Gérer les dates et les fuseaux horaires avec Carbon dans Laravel
Réponse rapide

Laravel convertit automatiquement les colonnes de date de vos modèles en objets Carbon. Retenez trois choses : stockez tout en UTC et ne convertissez qu’à l’affichage avec ->tz(), déclarez vos propres colonnes dans casts(), et sachez qu’en Carbon 3 les méthodes diffIn* renvoient un flottant signé et non plus un entier absolu.

Presque toutes les applications Laravel manipulent des dates : une date de publication, une fin d’essai, un délai de livraison, un écart entre deux évènements. Laravel confie ce travail à Carbon, une extension de la classe DateTime de PHP, et convertit automatiquement les colonnes de date de vos modèles en instances Carbon.

Article du parcours Développement web. Ce guide couvre ce que vous ferez réellement : créer une date, la convertir dans le bon fuseau, calculer un écart, l’afficher en français, la manipuler dans Eloquent et la figer dans vos tests. Tous les résultats affichés ci-dessous ont été produits sur Laravel 13.30.1 et Carbon 3.13.2.

Ce qui a changé avec Carbon 3

Laravel 11, 12 et 13 embarquent Carbon 3. Si vous relisez du code écrit pour Laravel 8 ou 9, deux changements vont vous surprendre.

1. Les méthodes diffIn* renvoient un nombre à virgule, plus un entier. En Carbon 2, diffInDays() renvoyait 113. En Carbon 3, la même expression renvoie la valeur exacte :

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. Le résultat est signé. En Carbon 2, la différence était absolue par défaut. En Carbon 3, elle est négative quand la date passée en argument est antérieure :

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

Concrètement, un if ($date->diffInDays(now()) > 30) écrit pour Carbon 2 peut cesser de se déclencher après une mise à jour, parce que la valeur est devenue négative. C’est le piège le plus coûteux de la migration.

Au passage, floatDiffInDays() et ses variantes ont disparu : elles faisaient double emploi avec le nouveau comportement.

Créer une date

Les deux helpers globaux de Laravel couvrent la majorité des besoins :

php
now();     // 2026-09-02 14:30:00  — instant présent
today();   // 2026-09-02 00:00:00  — aujourd’hui à minuit

Pour tout le reste, passez par la façade. Notez l’espace de noms : Laravel expose sa propre sous-classe, Illuminate\Support\Carbon, qui ajoute quelques méthodes à Carbon\Carbon. C’est celle qu’il faut importer dans une application Laravel.

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);                     // heure = maintenant
Carbon::createMidnightDate(2026, 2, 1);                 // heure = 00:00:00
Carbon::createFromTimeString('10:15:00');               // date = aujourd’hui
Carbon::createFromFormat('d/m/Y', '01/02/2026');
Attention à createFromDate()

createFromDate(2026, 2, 1) ne remet pas l’heure à zéro : elle conserve l’heure courante. Testé le 2 septembre 2026 à 14h30, l’appel renvoie 2026-02-01 14:30:00. Utilisez createMidnightDate() si vous voulez minuit.

Carbon::parse() accepte à peu près tout, y compris des expressions en anglais :

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

Ces expressions relatives sont séduisantes mais fragiles : last day of next month ne renvoie pas la même chose selon le jour où le code s’exécute. Réservez parse() aux chaînes venant d’une source machine (ISO 8601, timestamp) et construisez les autres dates explicitement.

Fuseaux horaires : le seul modèle qui tient

La règle tient en une phrase : stockez tout en UTC, convertissez seulement à l’affichage. Laravel s’y conforme par défaut, config/app.php fixant 'timezone' => 'UTC'. Ne changez pas cette valeur : elle détermine ce qui est écrit en base, et une base en heure locale devient ingérable au premier changement d’heure.

La conversion se fait sur l’instance, au moment de l’affichage :

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

Ces trois objets décrivent le même instant. C’est vérifiable : leur timestamp Unix est identique. Changer le fuseau ne déplace pas le moment, il change la façon de le lire.

Il faut en revanche distinguer deux écritures qui se ressemblent :

php
Carbon::tomorrow('Europe/Paris');        // minuit à Paris
Carbon::tomorrow()->tz('Europe/Paris');  // minuit UTC, lu à l’heure de Paris (02:00)

La première crée une date dans le fuseau. La seconde crée une date en UTC puis la convertit. La liste des fuseaux acceptés est celle de PHP, 419 entrées sur PHP 8.4.25 :

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

Pour stocker le fuseau d’un utilisateur, ajoutez une colonne timezone à la table users et alimentez-la depuis un menu déroulant construit sur cette liste. Un paquet comme jamesmills/laravel-timezone automatise la détection à la connexion, mais une colonne et un formulaire suffisent dans la plupart des cas.

Ajouter et soustraire

Chaque unité dispose d’une méthode au singulier et d’une au pluriel, en ajout et en soustraction :

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);   // ignore samedi et dimanche

Deux comportements méritent d’être connus avant de les découvrir en production.

Carbon est mutable. addMonth() modifie l’objet sur lequel vous l’appelez, il ne renvoie pas une copie :

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

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

Les mois débordent. Le 31 janvier plus un mois donne le 3 mars, parce que le 31 février n’existe pas et que Carbon reporte le surplus. Si vous voulez la fin du mois, demandez-la :

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

Éviter les mutations avec CarbonImmutable

Plutôt que de semer des copy() partout, vous pouvez rendre toutes les dates de l’application immuables. Chaque opération renvoie alors une nouvelle instance et l’originale ne bouge jamais :

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

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

Après cet appel, now() renvoie une instance de Carbon\CarbonImmutable et les dates hydratées par Eloquent le sont aussi. C’est un changement de comportement global : à décider en début de projet, pas sur une application déjà écrite avec des addDay() qui comptent sur la mutation.

Calculer un écart entre deux dates

Avec les valeurs signées et flottantes de Carbon 3, précisez toujours ce que vous voulez :

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);        // valeur absolue
(int) $debut->diffInDays($fin);        // 113, jours entiers
round($debut->diffInHours($fin), 2);   // 2729.5
$debut->diffInWeekdays($fin);          // 82, hors week-ends
$debut->diffInMonths($fin);            // 3.733198924731183

Pour un affichage lisible, diffForHumans() se charge de la formulation, y compris en français :

php
$fin->diffForHumans($debut);                 // '3 months after'
$fin->locale('fr')->diffForHumans($debut);   // '3 mois après'
$fin->locale('fr')->diffForHumans();         // 'dans 3 mois' (par rapport à maintenant)

Afficher une date en français

format() utilise les codes de PHP et ne traduit rien : les noms de jours et de mois restent en anglais. Pour du français, il faut translatedFormat() ou 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() garde les codes PHP et traduit les libellés. isoFormat() utilise les codes de type LLLL, LL, dddd, qui s’adaptent aux conventions de chaque langue. Pour éviter d’écrire locale('fr') partout, réglez la locale de l’application une fois :

.env
APP_LOCALE=fr
APP_FAKER_LOCALE=fr_FR

Carbon dans les modèles Eloquent

created_at et updated_at sont converties automatiquement, à condition que les colonnes existent bien en base. Elles arrivent déjà sous forme d’instances de Illuminate\Support\Carbon, pas de chaînes.

php
$post = Post::first();

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

Dans une vue Blade, ces appels s’écrivent de la même façon entre accolades.

Pour vos propres colonnes, ajoutées par une migration, déclarez-les dans la méthode casts(). Attention : l’ancienne propriété protected $dates = [...] a été retirée de Laravel 10 et n’a plus aucun effet. Du code qui s’en sert vous laissera des chaînes de caractères là où vous attendiez des objets.

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

Le format passé après datetime: ne change pas le stockage, seulement la sérialisation en JSON. Les comparaisons de dates s’écrivent alors naturellement dans les requêtes Eloquent :

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

Figer le temps dans les tests

Un test qui dépend de l’heure réelle finit toujours par échouer un 29 février ou à minuit passé. Carbon sait mentir sur la date courante, et Laravel expose des helpers pour cela :

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();   // avance l’horloge de 5 jours
$this->travelBack();        // revient à l’heure réelle

En dehors des tests Laravel, l’équivalent brut est Carbon::setTestNow(), à annuler avec un appel sans argument. C’est ce mécanisme qui a servi à produire les valeurs de ce guide, toutes calculées à partir du 2 septembre 2026 à 14h30 UTC.

php
Carbon::setTestNow('2026-09-02 14:30:00');
// …
Carbon::setTestNow();   // libère l’horloge

Erreurs fréquentes

diffInDays() renvoie un flottant signé Depuis Carbon 3, la différence est négative si l’argument est antérieur, et comporte des décimales. Une condition écrite pour Carbon 2 peut cesser de se déclencher : passez absolute: true ou castez en (int).
Carbon est mutable $date->addMonth() modifie $date. Utilisez copy() avant l’opération, ou basculez l’application sur CarbonImmutable via Date::use().
Les mois débordent 31 janvier + 1 mois donne le 3 mars, pas le 28 février. Utilisez addMonthNoOverflow() pour rester dans le mois.
La propriété $dates ne fait plus rien Retirée en Laravel 10. Déclarez vos colonnes de date dans la méthode casts(), sinon vous manipulez des chaînes.
createFromDate() ne remet pas l’heure à zéro Elle conserve l’heure courante. createMidnightDate() donne bien 00:00:00.
format() ne traduit pas Les noms de jours et de mois restent en anglais. Passez par translatedFormat() ou isoFormat().

CarbonLaravelPHP

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.