Fecha y hora en PHP: date(), strtotime() y por qué deberías usar DateTimeImmutable

date() es lo que todos buscan para trabajar con fechas en PHP y casi nunca lo correcto. Te enseño date() y strtotime() para leer código ajeno, y por qué DateTimeImmutable, UTC y diff() evitan los errores que cuestan dinero.

Engranajes de un mecanismo de reloj, en primer plano
Tabla de contenidos

date() es la función que todo el mundo busca para trabajar con fecha y hora en PHP, y casi nunca es la que deberías usar. Lo digo así de directo porque llevo más de veinte años viendo el mismo patrón: alguien necesita imprimir la fecha de hoy, encuentra date('Y-m-d'), lo pega, funciona en local, y tres meses después ese mismo enfoque se ha comido un bug de zona horaria que descuadra un informe, cobra una suscripción un día antes de tiempo o pierde una hora en cada registro. El problema no es date(). El problema es tratar las fechas como texto y como números cuando son otra cosa.

Este artículo hace dos cosas. Primero te enseña date() y strtotime(), porque los vas a encontrar en todo el código heredado de PHP y tienes que saber leerlos. Y después te explica por qué, cuando eres tú quien escribe el código nuevo, la respuesta correcta es DateTimeImmutable: inmutabilidad, zonas horarias explícitas y aritmética de fechas que no te traiciona en el cambio de hora. Todo el código está verificado contra PHP 8.2.

date() en dos minutos: formato, timestamp y por qué lo vas a seguir viendo

date() recibe una cadena de formato y, opcionalmente, un timestamp Unix. Si no le pasas timestamp, usa el instante actual.

echo date('Y-m-d');        // 2026-07-10
echo date('H:i:s');        // 14:32:08
echo date('d/m/Y H:i');    // 10/07/2026 14:32
echo date('Y-m-d', 0);     // 1970-01-01  (el inicio del tiempo Unix)

Los caracteres de formato son los del manual de php.net: Y año de cuatro cifras, m mes con cero delante, d día con cero, H hora en 24, i minutos, s segundos. Ojo con m (mes) frente a i (minutos): no existe M para minutos y i no lleva mayúscula. Es el error tipográfico más repetido con date().

time() te da el timestamp Unix actual, un entero con los segundos transcurridos desde el 1 de enero de 1970 en UTC:

$ahora = time();               // 1778161928
echo date('Y-m-d', $ahora);    // formatea ese instante

Hasta aquí, date() es cómodo para una cosa: convertir un instante en una cadena legible. Para leer código ajeno te vale. Para escribir el tuyo, se queda corto en cuanto necesitas hacer cualquier cálculo, y ahora verás por qué.

strtotime(): cómodo, potente y traicionero

strtotime() interpreta una fecha escrita "en humano" y devuelve un timestamp. Es sorprendentemente capaz:

strtotime('2026-07-10 14:00');   // fecha explícita
strtotime('+1 week');            // dentro de una semana
strtotime('next monday');        // el próximo lunes
strtotime('first day of next month');

El problema es que esa flexibilidad es también su trampa. strtotime() adivina, y cuando adivina mal no avisa: devuelve false o, peor, un timestamp equivocado que parece bueno. Dos casos que muerden en producción:

// El separador cambia la interpretación por completo
date('Y-m-d', strtotime('01/02/2026'));  // 2026-01-02  (mes/día, formato de EE. UU.)
date('Y-m-d', strtotime('01-02-2026'));  // 2026-02-01  (día-mes, formato europeo)

// Fecha que no puede parsear: no lanza excepción, devuelve false
var_dump(strtotime('no es una fecha'));  // bool(false)

Si tu usuario escribe 01/02/2026 pensando "1 de febrero" y tú lo pasas por strtotime(), acabas guardando el 2 de enero sin que nadie se entere. La barra activa el orden americano; el guion, el europeo. Nunca dejes que strtotime() interprete fechas que vienen de un formulario. Para eso está createFromFormat(), que te obliga a declarar el formato exacto.

Por qué date() y strtotime() no deberían ser tu opción por defecto

date() y strtotime() operan sobre timestamps: enteros. Y un entero no lleva encima su zona horaria, no sabe sumar meses de forma calendárica y no distingue entre "no hay fecha" y "el 1 de enero de 1970". Trabajar así te empuja a resolver a mano cosas que el lenguaje ya sabe hacer bien.

La API de objetos, DateTimeImmutable, DateTimeZone, DateInterval, DatePeriod, existe precisamente para eso. Cada objeto lleva su zona horaria dentro, la aritmética es calendárica y los cálculos de diferencia respetan los cambios de hora. La regla que uso y que te ahorra la mayoría de los sustos es simple: date() para leer código ajeno, DateTimeImmutable para escribir el tuyo.

DateTime frente a DateTimeImmutable: el bug de modify() que te destroza un bucle

PHP tiene dos clases casi idénticas: DateTime y DateTimeImmutable. La diferencia es una sola frase del manual, y es la que decide cuál usar. Sobre DateTimeImmutable, php.net dice literalmente: "This class behaves the same as DateTime except new objects are returned when modification methods such as DateTime::modify() are called".

Traducido: DateTime muta el objeto original cuando llamas a modify(), add(), sub(), setDate() o setTime(). DateTimeImmutable no lo toca; devuelve uno nuevo. Esto suena académico hasta que lo metes en un bucle:

// DateTime: modify() muta y devuelve el MISMO objeto
$inicio = new DateTime('2026-01-01');
$fechas = [];
for ($i = 0; $i < 3; $i++) {
    $fechas[] = $inicio->modify('+1 day');
}
// $fechas contiene tres referencias al mismo objeto:
// 01-04, 01-04, 01-04

Los tres elementos del array apuntan al mismísimo objeto, que al final del bucle vale 4 de enero. No has construido tres fechas: has construido una y la has guardado tres veces. Este bug no revienta, no lanza excepción, simplemente produce datos mal y te hace perder una tarde buscando dónde. Con DateTimeImmutable es imposible que ocurra:

$inicio = new DateTimeImmutable('2026-01-01');
$fechas = [];
for ($i = 0; $i < 3; $i++) {
    $inicio = $inicio->modify('+1 day');
    $fechas[] = $inicio;
}
// 01-02, 01-03, 01-04  - objetos distintos, valores distintos

Como cada método devuelve un objeto nuevo, estás obligado a reasignar ($inicio = $inicio->modify(...)), y esa reasignación explícita es justo lo que hace el código legible y correcto. Si olvidas el =, no pasa nada silencioso: la fecha no avanza y lo ves enseguida. Mi recomendación sin matices: usa DateTimeImmutable por defecto y reserva DateTime para cuando una librería te obligue.

Un calendario de sobremesa junto a un despertador analógico
Un día no siempre tiene 86.400 segundos. Ese detalle es el origen de la mitad de los errores de fechas.

Zonas horarias sin sorpresas: date_default_timezone_set(), date.timezone y por qué todo va en UTC

Aquí es donde se pierde el dinero de verdad. Si no fijas la zona horaria, PHP recurre a la que tenga configurada el servidor, y ese valor cambia entre tu máquina, la de staging y la de producción. El mismo código imprime tres horas distintas según dónde corra.

Tienes dos formas de fijarla, con el mismo fin. La directiva date.timezone en php.ini la establece para todo el servidor:

date.timezone = "UTC"

Y date_default_timezone_set() la fija por script, en tiempo de ejecución, según documenta php.net:

date_default_timezone_set('UTC');

Si no configuras ninguna de las dos, las versiones modernas de PHP caen a UTC de forma silenciosa, el aviso ruidoso de PHP 5 ya no está, así que el bug no da la cara: simplemente confías en un valor que no controlas.

La estrategia que funciona y que aplico siempre: el servidor y la aplicación trabajan en UTC de principio a fin, y la zona local del usuario solo entra en juego al presentar la fecha en pantalla. Para eso, cada objeto lleva su DateTimeZone:

$utc = new DateTimeImmutable('2026-07-10 22:30', new DateTimeZone('UTC'));

// Lo mismo, visto desde Madrid:
$madrid = $utc->setTimezone(new DateTimeZone('Europe/Madrid'));
echo $madrid->format('Y-m-d H:i');   // 2026-07-11 00:30

Fíjate en que el instante es el mismo; cambia solo cómo se muestra. Mezclar UTC y hora local en el mismo cálculo, sin declarar cuál es cuál, es la causa número uno de fechas descuadradas en una hora.

Aritmética de fechas: DateInterval, diff() y por qué un día no son 86.400 segundos

La tentación, cuando quieres saber cuántos días hay entre dos fechas, es restar timestamps y dividir entre 86.400. No lo hagas. Un día no siempre tiene 86.400 segundos: en el cambio a horario de verano hay días de 23 horas, y en el de invierno, días de 25. Lo puedes comprobar en la zona de Madrid:

$tz = new DateTimeZone('Europe/Madrid');
$a = new DateTimeImmutable('2025-03-30 00:00', $tz);  // día del cambio a verano
$b = new DateTimeImmutable('2025-03-31 00:00', $tz);
echo $b->getTimestamp() - $a->getTimestamp();  // 82800 segundos = 23 horas

Ese 30 de marzo solo tuvo 23 horas. Si cuentas "días" dividiendo segundos entre 86.400, te sale 0,958 días donde había uno entero, y en el cambio de octubre te sobra medio. diff() sí lo hace bien, porque razona en calendario, no en segundos:

$dias = $a->diff($b)->days;   // 1  - un día, como debe ser

Para sumar y restar, DateInterval con la notación ISO 8601 de duraciones: P de período, luego años/meses/días, y tras una T las horas/minutos/segundos. P1M es un mes; P1D un día; PT2H dos horas.

$hoy = new DateTimeImmutable('2026-07-10');
$manana   = $hoy->add(new DateInterval('P1D'));
$prox_mes = $hoy->add(new DateInterval('P1M'));
$ayer     = $hoy->sub(new DateInterval('P1D'));

Y diff() devuelve un DateInterval que puedes formatear directamente:

$inicio = new DateTimeImmutable('2020-03-01');
$fin    = new DateTimeImmutable('2026-07-10');
echo $inicio->diff($fin)->format('%y años, %m meses, %d días');
// 6 años, 4 meses, 9 días

Recorrer un rango de fechas con DatePeriod

Cuando necesitas iterar día a día (o semana a semana) entre dos fechas, no montes un bucle a mano con timestamps. DatePeriod lo hace y respeta los cambios de hora:

$inicio = new DateTimeImmutable('2026-01-01');
$paso   = new DateInterval('P1D');
$fin    = new DateTimeImmutable('2026-01-05');

foreach (new DatePeriod($inicio, $paso, $fin) as $dia) {
    echo $dia->format('Y-m-d') . "\n";
}
// 2026-01-01, 2026-01-02, 2026-01-03, 2026-01-04

Detalle que sorprende a mucha gente: la fecha final es exclusiva. El bucle llega hasta el 4, no incluye el 5. Si quieres cerrar el rango, sumas un paso al final o usas la constante DatePeriod::INCLUDE_END_DATE en las versiones que la traen. Contar con que el último día entra "por defecto" es un fallo clásico de un elemento de más o de menos.

Los 7 errores con fechas que cuestan dinero

Estos son los que veo una y otra vez en código real, ordenados por lo caros que salen:

# Error Qué pasa Arreglo
1 No fijar la zona horaria La misma línea da horas distintas en local, staging y producción date.timezone = UTC o date_default_timezone_set('UTC')
2 Mezclar UTC y hora local Fechas descuadradas justo en una hora Todo en UTC; convertir solo al mostrar
3 Guardar fechas como texto No puedes ordenar ni comparar sin sorpresas Columna DATETIME/TIMESTAMP, no VARCHAR
4 Restar timestamps y dividir por 86.400 Falla en el cambio de hora (días de 23/25 h) diff() sobre objetos DateTime
5 Sumar meses sin pensar en el desbordamiento "31 de enero + 1 mes" = 3 de marzo Comprobar fin de mes; ver más abajo
6 modify() sobre DateTime dentro de un bucle Todas las referencias apuntan al mismo objeto DateTimeImmutable
7 Comparar fechas como strings '9:00' > '10:00' es verdadero Comparar objetos DateTimeImmutable

El número 5 merece su propio ejemplo porque es contraintuitivo. Sumar un mes calendárico al 31 de enero no da "finales de febrero":

$d = new DateTimeImmutable('2026-01-31');
echo $d->add(new DateInterval('P1M'))->format('Y-m-d');  // 2026-03-03

PHP hace lo lógico dentro de su lógica: 31 de enero más un mes es "31 de febrero", y como febrero de 2026 tiene 28 días, desborda tres días hasta el 3 de marzo. En un año bisiesto el resultado sería 2 de marzo. Si lo que quieres es "el mismo día del mes siguiente, o el último si no existe", tienes que tratar el fin de mes explícitamente; sumar P1M a ciegas en una fecha de facturación es como se cobra a alguien en marzo lo que tocaba en febrero.

Guardar fechas en base de datos: UTC dentro, hora local solo al mostrar

La regla es la misma que en el servidor y no tiene excepciones que merezcan la pena: guarda siempre en UTC. Guardas el instante en UTC, y conviertes a la zona del usuario únicamente en el momento de pintarlo. Así, si mañana tu aplicación atiende a alguien en Canarias, en México o en Berlín, no reescribes nada: el dato almacenado es universal y la presentación es local.

// Al guardar: normaliza a UTC
$entrada = new DateTimeImmutable('2026-07-10 15:00', new DateTimeZone('Europe/Madrid'));
$paraDb  = $entrada->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
// '2026-07-10 13:00:00'  ->  columna DATETIME

// Al leer: reconstruye desde UTC y muestra en local
$db = new DateTimeImmutable('2026-07-10 13:00:00', new DateTimeZone('UTC'));
echo $db->setTimezone(new DateTimeZone('Europe/Madrid'))->format('d/m/Y H:i');
// 10/07/2026 15:00

Y una advertencia que se olvida: la columna tiene que ser de tipo fecha (DATETIME o TIMESTAMP), nunca VARCHAR. Una fecha guardada como texto no se ordena bien, no se compara bien y arrastra el problema al SQL. De hecho, cuando esa columna admite NULL, comparar contra ella tiene su propia trampa de lógica de tres valores que conviene conocer antes de escribir el WHERE: lo cuento con un caso real en por qué NULL no es menor que una fecha en SQL.

Formatos estándar: ISO 8601, ATOM y por qué DATE_ISO8601 no es ISO 8601

Cuando una fecha sale de tu sistema hacia una API, un feed o un JSON, tiene que ir en un formato estándar y con la zona horaria dentro. El formato de referencia es ISO 8601, y PHP trae constantes para no tener que recordar la cadena de formato. Aquí está la trampa clásica, y la confirma el propio manual de php.net: la constante DATE_ISO8601 (o DateTimeInterface::ISO8601) no produce una fecha compatible con ISO 8601.

$d = new DateTimeImmutable('2025-03-30 00:00', new DateTimeZone('Europe/Madrid'));

echo $d->format(DateTimeInterface::ISO8601);  // 2025-03-30T00:00:00+0100  <- NO es ISO 8601 válido
echo $d->format(DateTimeInterface::ATOM);     // 2025-03-30T00:00:00+01:00  <- correcto

La diferencia está en el offset: ISO8601 usa O y escribe +0100 sin los dos puntos; la norma exige +01:00. php.net mantiene esa constante "por razones de compatibilidad hacia atrás" y recomienda expresamente usar ATOM (idéntica a RFC3339) o ISO8601_EXPANDED en su lugar. Regla práctica: para intercambiar fechas usa DateTimeInterface::ATOM, nunca DATE_ISO8601.

Comparar fechas bien (y por qué comparar strings te va a fallar)

Comparar objetos DateTimeImmutable con los operadores normales funciona y es lo correcto:

$a = new DateTimeImmutable('2026-07-10 09:00');
$b = new DateTimeImmutable('2026-07-10 10:00');
var_dump($a < $b);   // bool(true)  - compara el instante, no el texto

Compararlas como cadenas, en cambio, es una mina. Funciona por casualidad con el formato Y-m-d H:i:s (porque ese orden ordena bien alfabéticamente), y falla en cuanto la cadena tiene otro formato:

var_dump('9:00' > '10:00');   // bool(true)  - falso en la realidad, cierto para strcmp

Alfabéticamente '9' es mayor que '1', así que las nueve "ganan" a las diez. Si comparas fechas como texto tarde o temprano te muerde. Compara siempre objetos.

Un último apunte sobre validación: createFromFormat() no rechaza fechas imposibles por sí solo, las normaliza. Si le pasas "2026-13-40" te devuelve un objeto con la fecha rodada a 2027-02-09 y solo una advertencia, no una excepción. Después de parsear una fecha que viene de fuera, comprueba siempre DateTimeImmutable::getLastErrors() antes de fiarte del resultado.

Chuleta: qué usar en cada caso

Necesito… Uso
Leer código heredado con date()/strtotime() Entenderlo, no imitarlo
Escribir código nuevo DateTimeImmutable siempre
Fijar la zona horaria UTC en date.timezone + convertir al mostrar
Sumar o restar tiempo add()/sub() con DateInterval
Diferencia entre fechas diff(), nunca restar timestamps
Recorrer un rango DatePeriod (final exclusivo)
Parsear una fecha de un formulario createFromFormat() + getLastErrors()
Enviar una fecha a una API format(DateTimeInterface::ATOM)
Guardar en base de datos Columna de fecha, en UTC
Comparar dos fechas Objetos con </>, nunca strings

Las fechas parecen el rincón aburrido de PHP hasta que un cambio de hora o una zona sin fijar te cuesta una tarde de depuración o un cobro mal hecho. La inversión que de verdad rinde es pequeña: usar DateTimeImmutable, trabajar en UTC y convertir solo al presentar. Con eso desaparece la mayoría de los bugs de este artículo. Si tienes entre manos una aplicación donde las fechas mueven dinero, facturación, suscripciones, informes, y quieres que ese terreno quede sólido, hablemos de tu proyecto.

Preguntas frecuentes

¿Cómo obtengo la fecha y hora actual en PHP?

Con date('Y-m-d H:i:s') obtienes la fecha y hora actual como cadena, y con time() el timestamp Unix. Para trabajar con ella de forma seria usa new DateTimeImmutable('now'), que devuelve un objeto con su zona horaria y sobre el que puedes calcular sin convertir a mano. Fija antes la zona con date_default_timezone_set('UTC') o la aplicación mostrará una hora distinta en cada servidor.

¿Cuál es la diferencia entre date() y DateTime?

date() es una función procedural que solo formatea un instante (un timestamp) como texto; no calcula ni lleva zona horaria propia. DateTime y DateTimeImmutable son clases que representan una fecha completa con su zona, y ofrecen sumar, restar, comparar y calcular diferencias respetando el calendario y los cambios de hora. La regla: date() para leer código ajeno, DateTimeImmutable para escribir el tuyo.

¿Cuándo usar DateTimeImmutable en lugar de DateTime?

Casi siempre. DateTime muta el objeto original cuando llamas a modify(), add() o sub(), lo que provoca bugs silenciosos cuando reutilizas la variable dentro de un bucle: todas las referencias acaban apuntando al mismo objeto. DateTimeImmutable devuelve un objeto nuevo en cada operación, así que te obliga a reasignar y elimina esa clase de error. Reserva DateTime solo para cuando una librería externa te lo imponga.

¿Cómo calculo la diferencia entre dos fechas en PHP?

Con el método diff(): $a->diff($b) devuelve un DateInterval del que sacas años, meses, días u horas, y ->days te da el total de días. No restes los timestamps y dividas entre 86.400: en el cambio a horario de verano hay días de 23 horas y en el de invierno de 25, así que esa cuenta falla justo dos veces al año. diff() razona en calendario y lo hace bien.

¿Por qué al sumar 1 mes al 31 de enero me da el 3 de marzo?

Porque add(new DateInterval('P1M')) suma un mes calendárico: 31 de enero más un mes es "31 de febrero", y como febrero solo tiene 28 días (29 en bisiesto), la fecha desborda hasta el 3 de marzo (el 2 en un año bisiesto). Es el comportamiento documentado de PHP. Si necesitas "el último día del mes siguiente cuando el día no existe", tienes que comprobar el fin de mes de forma explícita en vez de sumar P1M a ciegas.

¿En qué zona horaria debo guardar las fechas en la base de datos?

Siempre en UTC, y en una columna de tipo fecha (DATETIME o TIMESTAMP), nunca en un VARCHAR. Normaliza a UTC al guardar y convierte a la zona del usuario solo al mostrar la fecha en pantalla con setTimezone(). Así el dato almacenado es universal y la presentación es local: si mañana atiendes a usuarios en otro huso horario no tienes que migrar nada.

¿Te ha servido?

Si tienes un problema parecido y quieres una opinión técnica, cuéntamelo. Te respondo en menos de 24 h laborables.

WhatsApp