Carbon en Laravel: gestionar fechas y zonas horarias

Carbon en Laravel: gestionar fechas y zonas horarias
Respuesta rápida

Laravel convierte automáticamente las columnas de fecha de tus modelos en objetos Carbon. Quédate con tres cosas: guarda todo en UTC y convierte solo al mostrar con ->tz(), declara tus propias columnas en casts(), y ten presente que en Carbon 3 los métodos diffIn* devuelven un decimal con signo y ya no un entero absoluto.

Casi todas las aplicaciones Laravel manejan fechas: una fecha de publicación, el final de un periodo de prueba, un plazo de entrega, el intervalo entre dos eventos. Laravel delega ese trabajo en Carbon, una extensión de la clase DateTime de PHP, y convierte automáticamente las columnas de fecha de tus modelos en instancias de Carbon.

Artículo del itinerario Desarrollo web. Esta guía cubre lo que vas a hacer de verdad: crear una fecha, convertirla a la zona horaria correcta, calcular un intervalo, mostrarla en francés, manipularla en Eloquent y congelarla en tus tests. Todos los resultados que verás abajo se han producido con Laravel 13.30.1 y Carbon 3.13.2.

Qué ha cambiado con Carbon 3

Laravel 11, 12 y 13 incluyen Carbon 3. Si estás releyendo código escrito para Laravel 8 o 9, hay dos cambios que te van a sorprender.

1. Los métodos diffIn* devuelven un número decimal, ya no un entero. En Carbon 2, diffInDays() devolvía 113. En Carbon 3, la misma expresión devuelve el valor exacto:

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. El resultado lleva signo. En Carbon 2, la diferencia era absoluta por defecto. En Carbon 3 es negativa cuando la fecha que pasas como argumento es la anterior:

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

En la práctica, un if ($date->diffInDays(now()) > 30) escrito para Carbon 2 puede dejar de dispararse tras una actualización, porque el valor ha pasado a ser negativo. Es la trampa más cara de la migración.

De paso, floatDiffInDays() y sus variantes han desaparecido: duplicaban el nuevo comportamiento.

Crear una fecha

Los dos helpers globales de Laravel cubren la mayoría de los casos:

php
now();     // 2026-09-02 14:30:00  — el instante actual
today();   // 2026-09-02 00:00:00  — hoy a medianoche

Para todo lo demás, tira de la fachada. Fíjate en el espacio de nombres: Laravel expone su propia subclase, Illuminate\Support\Carbon, que añade algunos métodos a Carbon\Carbon. Es la que hay que importar en una aplicación 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);                     // hora = ahora
Carbon::createMidnightDate(2026, 2, 1);                 // hora = 00:00:00
Carbon::createFromTimeString('10:15:00');               // fecha = hoy
Carbon::createFromFormat('d/m/Y', '01/02/2026');
Cuidado con createFromDate()

createFromDate(2026, 2, 1) no pone la hora a cero: conserva la hora actual. Probado el 2 de septiembre de 2026 a las 14:30, la llamada devuelve 2026-02-01 14:30:00. Usa createMidnightDate() si quieres medianoche.

Carbon::parse() acepta casi cualquier cosa, incluidas expresiones en inglés:

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

Esas expresiones relativas resultan tentadoras, pero son frágiles: last day of next month no devuelve lo mismo según el día en que se ejecute el código. Reserva parse() para las cadenas que vienen de una fuente máquina (ISO 8601, timestamp) y construye el resto de fechas de forma explícita.

Zonas horarias: el único modelo que aguanta

La regla cabe en una frase: guárdalo todo en UTC y convierte solo al mostrar. Laravel la respeta por defecto, con config/app.php fijando 'timezone' => 'UTC'. No cambies ese valor: determina lo que se escribe en la base de datos, y una base en hora local se vuelve ingobernable al primer cambio de hora.

La conversión se hace sobre la instancia, en el momento de mostrarla:

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

Esos tres objetos describen el mismo instante. Se puede comprobar: su timestamp Unix es idéntico. Cambiar la zona horaria no mueve el momento, cambia la forma de leerlo.

Sí conviene distinguir, en cambio, dos escrituras que se parecen mucho:

php
Carbon::tomorrow('Europe/Paris');        // medianoche en París
Carbon::tomorrow()->tz('Europe/Paris');  // medianoche UTC, leída en hora de París (02:00)

La primera crea una fecha en la zona horaria. La segunda crea una fecha en UTC y luego la convierte. La lista de zonas aceptadas es la de PHP, 419 entradas en PHP 8.4.25:

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

Para guardar la zona horaria de un usuario, añade una columna timezone a la tabla users y aliméntala desde un desplegable construido sobre esa lista. Un paquete como jamesmills/laravel-timezone automatiza la detección al iniciar sesión, pero en la mayoría de los casos basta con una columna y un formulario.

Sumar y restar

Cada unidad tiene un método en singular y otro en plural, tanto para sumar como para restar:

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);   // ignora sábado y domingo

Hay dos comportamientos que más vale conocer antes de descubrirlos en producción.

Carbon es mutable. addMonth() modifica el objeto sobre el que lo llamas, no devuelve una copia:

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

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

Los meses desbordan. El 31 de enero más un mes da el 3 de marzo, porque el 31 de febrero no existe y Carbon arrastra el excedente. Si lo que quieres es el final del mes, pídelo:

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

Evitar las mutaciones con CarbonImmutable

En lugar de sembrar copy() por todas partes, puedes hacer inmutables todas las fechas de la aplicación. Cada operación devuelve entonces una instancia nueva y la original no se mueve nunca:

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

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

Después de esa llamada, now() devuelve una instancia de Carbon\CarbonImmutable, y las fechas hidratadas por Eloquent también. Es un cambio de comportamiento global: decídelo al arrancar un proyecto, no sobre una aplicación ya escrita con addDay() que cuentan con la mutación.

Calcular el intervalo entre dos fechas

Con los valores con signo y en coma flotante de Carbon 3, di siempre exactamente qué quieres:

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);        // valor absoluto
(int) $debut->diffInDays($fin);        // 113, días enteros
round($debut->diffInHours($fin), 2);   // 2729.5
$debut->diffInWeekdays($fin);          // 82, sin fines de semana
$debut->diffInMonths($fin);            // 3.733198924731183

Para una salida legible, diffForHumans() se encarga de la redacción, también en francés:

php
$fin->diffForHumans($debut);                 // '3 months after'
$fin->locale('fr')->diffForHumans($debut);   // '3 mois après'
$fin->locale('fr')->diffForHumans();         // 'dans 3 mois' (respecto a ahora)

Mostrar una fecha en francés

format() usa los códigos de PHP y no traduce nada: los nombres de días y meses siguen en inglés. Para el francés necesitas translatedFormat() o 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() mantiene los códigos de PHP y traduce las etiquetas. isoFormat() usa códigos del tipo LLLL, LL o dddd, que se adaptan a las convenciones de cada idioma. Para no escribir locale('fr') por todas partes, ajusta la locale de la aplicación una sola vez:

.env
APP_LOCALE=fr
APP_FAKER_LOCALE=fr_FR

Carbon en los modelos Eloquent

created_at y updated_at se convierten automáticamente, siempre que las columnas existan de verdad en la base de datos. Llegan ya como instancias de Illuminate\Support\Carbon, no como cadenas.

php
$post = Post::first();

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

En una vista Blade, esas mismas llamadas se escriben igual entre llaves.

Para tus propias columnas, añadidas mediante una migración, decláralas en el método casts(). Cuidado: la antigua propiedad protected $dates = [...] se retiró en Laravel 10 y ya no hace absolutamente nada. El código que siga apoyándose en ella te dejará cadenas donde esperabas objetos.

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

El formato que pasas después de datetime: no cambia el almacenamiento, solo la serialización a JSON. Las comparaciones de fechas se escriben entonces con naturalidad en las consultas Eloquent:

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

Congelar el tiempo en los tests

Un test que depende del reloj real acaba fallando siempre un 29 de febrero o pasada la medianoche. Carbon sabe mentir sobre la fecha actual, y Laravel expone helpers para ello:

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();   // adelanta el reloj 5 días
$this->travelBack();        // vuelve a la hora real

Fuera de los tests de Laravel, el equivalente en crudo es Carbon::setTestNow(), que se anula con una llamada sin argumentos. Es el mecanismo que ha servido para producir los valores de esta guía, todos calculados a partir del 2 de septiembre de 2026 a las 14:30 UTC.

php
Carbon::setTestNow('2026-09-02 14:30:00');
// …
Carbon::setTestNow();   // libera el reloj

Errores frecuentes

diffInDays() devuelve un decimal con signo Desde Carbon 3, la diferencia es negativa si el argumento es la fecha anterior, y arrastra decimales. Una condición escrita para Carbon 2 puede dejar de dispararse: pasa absolute: true o castea a (int).
Carbon es mutable $date->addMonth() modifica $date. Llama a copy() antes de la operación, o pasa la aplicación a CarbonImmutable con Date::use().
Los meses desbordan 31 de enero + 1 mes da el 3 de marzo, no el 28 de febrero. Usa addMonthNoOverflow() para quedarte dentro del mes.
La propiedad $dates ya no hace nada Retirada en Laravel 10. Declara tus columnas de fecha en el método casts(), si no, lo que manipulas son cadenas.ncreateFromDate() no pone la hora a cero | Conserva la hora actual. createMidnightDate() sí devuelve 00:00:00.nformat() no traduce | Los nombres de días y meses siguen en inglés. Usa translatedFormat() o isoFormat().

CarbonLaravelPHP

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.