Fecha y hora en PHP: formato, zonas horarias y DateTimeImmutable

Obtener y formatear una fecha en PHP: ajustar la zona horaria, dar formato con date(), calcular con DateTimeImmutable, los nombres de días y meses en francés con IntlDateFormatter y las funciones obsoletas que conviene abandonar.

Fecha y hora en PHP: formato, zonas horarias y DateTimeImmutable
Respuesta rápida

date() da formato a una marca de tiempo, DateTimeImmutable es lo que necesitas en cuanto hay un cálculo e IntlDateFormatter devuelve los nombres de días y meses en francés. Ajusta la zona horaria de forma explícita: sin date.timezone, PHP usa UTC. strftime() está obsoleta desde PHP 8.1 y su sustituto no es date(), que no traduce nada.

Mostrar una fecha en PHP es sencillo. Mostrar la fecha correcta, en la zona horaria correcta, en francés, y calcular un intervalo sin equivocarse en el cambio de hora lo es bastante menos. Este artículo cubre las funciones básicas y luego las clases DateTimeImmutable e IntlDateFormatter, que resuelven los casos que date() no sabe tratar. Todas las salidas se han obtenido con PHP 8.5.10.

La zona horaria, antes que nada

Es la fuente de errores número uno, y además es silenciosa. Si date.timezone no está configurada en php.ini, PHP usa UTC. Un sitio francés muestra entonces una hora desfasada una o dos horas según la estación, sin el menor aviso.

php
echo ini_get('date.timezone'), "\n";        // (vacío) en muchas instalaciones
echo date_default_timezone_get(), "\n";     // UTC

Se configura en php.ini, o al arrancar la aplicación:

php
date_default_timezone_set('Europe/Paris');

La regla de fondo: almacenar en UTC, mostrar en la zona horaria del usuario. Una base de datos con horas locales queda inservible en cuanto un usuario cambia de zona o entra el horario de verano.

date() y time()

time() devuelve el número de segundos transcurridos desde el 1.º de enero de 1970 a medianoche UTC. date() da formato a una marca de tiempo de ese tipo, o al instante actual si no le pasas ninguna.

php
date_default_timezone_set('Europe/Paris');

echo date('d/m/Y'), "\n";           // 02/09/2026
echo date('d/m/Y H:i:s'), "\n";     // 02/09/2026 21:47:03
echo time(), "\n";                  // 1788378423

echo date('d/m/Y', 1645303037), "\n";  // 19/02/2022

Los caracteres de formato más utilizados:

Fecha Hora
d día con 2 cifras (01 a 31) H hora en formato 24 h (00 a 23)
j día sin cero inicial (1 a 31) h hora en formato 12 h (01 a 12)
m mes con 2 cifras (01 a 12) i minutos (00 a 59)
n mes sin cero inicial (1 a 12) s segundos (00 a 59)
Y año con 4 cifras A AM o PM
N día de la semana (1 = lunes) P desfase, por ejemplo +02:00
t número de días del mes U marca de tiempo Unix
L 1 si el año es bisiesto c formato ISO 8601 completo

Para insertar un carácter que también es un código de formato hay que escaparlo con una barra invertida:

php
echo date('\L\e d/m/Y'), "\n";      // Le 02/09/2026
// sin la barra invertida, «L» daría 0 o 1 y «e» el nombre de la zona horaria

date() no habla francés

Esta es la segunda trampa clásica. Los nombres de días y meses que devuelve date() están siempre en inglés, y setlocale() no cambia nada.

php
$ts = mktime(0, 0, 0, 4, 1, 2022);

echo date('l', $ts), "\n";                    // Friday
setlocale(LC_TIME, 'fr_FR.UTF-8', 'fr_FR');
echo date('l', $ts), "\n";                    // Friday — sin cambios

La solución es IntlDateFormatter, de la extensión intl:

php
$formateur = new IntlDateFormatter(
    'fr_FR',
    IntlDateFormatter::FULL,
    IntlDateFormatter::NONE,
    'Europe/Paris',
    IntlDateFormatter::GREGORIAN,
);

echo $formateur->format($ts), "\n";   // vendredi 1 avril 2022

Para un formato propio, la clase acepta un patrón ICU, cuyos códigos no son los de date():

php
$formateur->setPattern("EEEE d MMMM y '\u{e0}' HH'h'mm");
echo $formateur->format(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')));
// mercredi 2 septembre 2026 à 21h47

Si intl no está disponible, una tabla de equivalencias saca del apuro en francés, pero no escala a varios idiomas.

DateTimeImmutable, la clase que hay que usar

Para todo lo que va más allá de mostrar una fecha, las clases valen más que las funciones. DateTimeImmutable es preferible a DateTime: sus métodos devuelven un objeto nuevo en lugar de modificar el actual, lo que descarta las modificaciones a distancia.

php
$paris = new DateTimeZone('Europe/Paris');
$date  = new DateTimeImmutable('2026-09-02 14:30:00', $paris);

echo $date->format('d/m/Y H:i P'), "\n";                     // 02/09/2026 14:30 +02:00
echo $date->modify('+3 days')->format('d/m/Y'), "\n";        // 05/09/2026
echo $date->format('d/m/Y'), "\n";                           // 02/09/2026 — sin cambios

// Con DateTime (mutable), la tercera línea mostraría 05/09/2026
php
// Sumar y restar duraciones
$dans30Mois = $date->add(new DateInterval('P30M'));
$ilYa2Sem   = $date->sub(new DateInterval('P2W'));

// Diferencia entre dos fechas
$ecart = $date->diff(new DateTimeImmutable('2026-12-25', $paris));
echo $ecart->days, " jours\n";
echo $ecart->format('%m mois et %d jours'), "\n";

// Una serie de fechas
$periode = new DatePeriod(
    $date,
    new DateInterval('P1D'),
    new DateTimeImmutable('2026-09-07', $paris),
);
foreach ($periode as $jour) {
    echo $jour->format('D d/m'), "\n";
}

La trampa del último día del mes

Sumar un mes a un fin de mes no da lo que esperas, y el comportamiento es el mismo con DateInterval que con la aritmética sobre mktime().

php
$fin = new DateTimeImmutable('2026-01-31');
echo $fin->add(new DateInterval('P1M'))->format('d/m/Y'), "\n";  // 03/03/2026

El 31 de febrero no existe: PHP lo desborda hacia marzo. Para obtener el último día del mes siguiente hay que pedirlo de forma explícita:

php
echo $fin->modify('last day of next month')->format('d/m/Y'), "\n";  // 28/02/2026

Un día no siempre dura 24 horas

Al entrar el horario de verano un día dura 23 horas, al volver al de invierno, 25. Sumar 86400 segundos a una marca de tiempo da entonces una hora falsa.

php
$paris = new DateTimeZone('Europe/Paris');
$avant = new DateTimeImmutable('2026-03-28 12:00:00', $paris);
$apres = $avant->add(new DateInterval('P1D'));

echo $apres->format('d/m/Y H:i P'), "\n";   // 29/03/2026 12:00 +02:00
echo ($apres->getTimestamp() - $avant->getTimestamp()) / 3600, " heures\n";  // 23

DateInterval razona en días naturales y devuelve correctamente el mediodía del día siguiente, aunque solo hayan pasado 23 horas. Es el comportamiento correcto para un recordatorio de «mañana a mediodía» y el equivocado para una duración facturada. Son dos nociones distintas: P1D para un día natural, PT24H para veinticuatro horas.

Leer una fecha introducida por el usuario

strtotime() entiende muchas cosas, pero interpreta los formatos ambiguos a la americana: 03/04/2026 es el 4 de marzo, no el 3 de abril. Cuando conoces el formato, createFromFormat() no deja lugar a dudas.

php
$date = DateTimeImmutable::createFromFormat(
    '!d/m/Y',                        // el «!» pone la hora a 00:00:00
    '03/04/2026',
    new DateTimeZone('Europe/Paris'),
);
echo $date->format('d F Y'), "\n";   // 03 April 2026

Sin el ! inicial, los campos que no facilitas toman el valor del instante actual: dos ejecuciones del mismo código dan dos horas diferentes.

Y lo más peligroso: una fecha inválida no falla, desborda en silencio.

php
$saisie = '31/02/2026';
$date   = DateTimeImmutable::createFromFormat('!d/m/Y', $saisie, $paris);

echo $date->format('d/m/Y'), "\n";                    // 03/03/2026
print_r(DateTimeImmutable::getLastErrors());          // ['warnings' => [10 => 'The parsed date was invalid'], …]
var_dump(checkdate(2, 31, 2026));                     // bool(false)

Hay que comprobar getLastErrors() después de cada análisis, o comparar la fecha reformateada con lo que se introdujo:

php
function lireDate(string $saisie, DateTimeZone $fuseau): ?DateTimeImmutable
{
    $date = DateTimeImmutable::createFromFormat('!d/m/Y', $saisie, $fuseau);

    if ($date === false) {
        return null;
    }

    $erreurs = DateTimeImmutable::getLastErrors();
    if ($erreurs !== false && ($erreurs['warning_count'] || $erreurs['error_count'])) {
        return null;
    }

    return $date->format('d/m/Y') === $saisie ? $date : null;
}

Desde PHP 8.2, getLastErrors() devuelve false cuando no hay nada que señalar, en lugar de un array con los contadores a cero. La comprobación tiene que tenerlo en cuenta.

Novedades recientes

PHP 8.4 añadió createFromTimestamp(), que acepta marcas de tiempo fraccionarias:

php
$d = DateTimeImmutable::createFromTimestamp(1645303037.5);
echo $d->format('d/m/Y H:i:s.u P'), "\n";   // 19/02/2022 20:37:17.500000 +00:00

Antes había que pasar por '@' . $ts o setTimestamp(), y los microsegundos se perdían.

Las funciones obsoletas que ya no hay que usar

Varias funciones de fecha siguen presentes pero emiten un aviso de obsolescencia. Funcionan en PHP 8.5.10, pero su retirada ya está programada.

Función Obsoleta desde Sustituto
strftime() PHP 8.1 IntlDateFormatter::format()
gmstrftime() PHP 8.1 IntlDateFormatter::format()
strptime() PHP 8.2 date_parse_from_format() o IntlDateFormatter::parse()
date_sunrise() / date_sunset() PHP 8.1 date_sun_info()
utf8_encode() PHP 8.2 mb_convert_encoding()

Los mensajes exactos, obtenidos tanto en PHP 8.5.10 como en PHP 8.4.25:

code
Deprecated: Function strftime() is deprecated since 8.1,
use IntlDateFormatter::format() instead

Deprecated: Function strptime() is deprecated since 8.2,
use date_parse_from_format() (for locale-independent parsing),
or IntlDateFormatter::parse() (for locale-dependent parsing) instead

Deprecated: Constant SUNFUNCS_RET_STRING is deprecated since 8.4,
as date_sunrise() and date_sunset() were deprecated in 8.1

strftime() era la forma habitual de obtener una fecha en francés. Su sustituto no es date(), que no traduce nada, sino IntlDateFormatter.

En cambio, mktime(), checkdate(), getdate() e idate() no están obsoletas y funcionan con normalidad. Siguen siendo útiles para casos sencillos, aunque las clases cubren todo lo que hacen.

Para recordar

  • Configura la zona horaria de forma explícita, almacena en UTC y muestra en hora local.
  • date() para dar formato a un instante, DateTimeImmutable para cualquier cálculo.
  • IntlDateFormatter para los nombres de días y meses en francés.
  • Después de createFromFormat(), comprueba getLastErrors(): una fecha inválida desborda en silencio.
  • Un día natural no son 24 horas. P1D y PT24H no son intercambiables.

Para analizar fechas por millones en un fichero de importación, consulta leer ficheros grandes con PHP, y la conexión a una base de datos para almacenarlas en UTC.

Consulta también validar una fecha con una expresión regular, gestionar fechas con Carbon en Laravel y el hub de Desarrollo web.

Errores frecuentes

Zona horaria sin configurar Sin date.timezone en php.ini, PHP recurre a UTC y muestra una hora desfasada sin el menor aviso.
date('l') sigue en inglés setlocale() no tiene ningún efecto sobre date(). El sustituto de strftime() es IntlDateFormatter, no date().
createFromFormat acepta una fecha inválida 31/02/2026 se convierte en 03/03/2026 con un simple aviso en getLastErrors(). Hay que comprobarlo después de cada análisis.
createFromFormat sin el «!» Los campos que no se facilitan toman la hora actual: dos ejecuciones del mismo código dan dos resultados.
strtotime lee las fechas a la americana 03/04/2026 es el 4 de marzo, no el 3 de abril.
Sumar un mes a un fin de mes 31/01/2026 + P1M da 03/03/2026. Para el último día del mes, usa modify('last day of next month').
Un día no dura 24 horas En el cambio de hora, P1D avanza efectivamente un día natural aunque solo hayan pasado 23 horas. P1D y PT24H no son intercambiables.
getLastErrors devuelve false Desde PHP 8.2, el método devuelve false cuando no hay nada que señalar, en lugar de un array de contadores a cero.

DateDateTimeIntlPHP

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.