Arrays asociativos en PHP: la guía práctica de un senior
En PHP no hay dos tipos de array: hay uno, un mapa ordenado. Entender esa frase evita la mayoría de bugs con claves, foreach por referencia, orden e in_array.
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.
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() 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() 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.
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.
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.
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.
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
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.
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.
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.
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 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.
| 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.
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.
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.
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.
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.
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.
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.
Si tienes un problema parecido y quieres una opinión técnica, cuéntamelo. Te respondo en menos de 24 h laborables.