Fuso horário no PHP: instante, data e agendamento
Fuso horário no PHP 8.5 testado: instante em UTC, data de calendário sem conversão, agendamento com hora local e fuso, Etc/GMT invertido e base de fusos velha.
Datas num sistema PHP não são todas do mesmo tipo, e quase todo bug de fuso nasce de tratar uma como se fosse a outra. Há três tipos: o instante (quando o pedido foi pago), a data de calendário (o vencimento do boleto, o aniversário) e o agendamento futuro (a reunião das 9h em São Paulo daqui a seis meses). Cada um pede uma forma diferente de gravar e de converter.
Este guia é sobre essa modelagem. Se o problema é descobrir qual fuso o PHP-FPM, o cron e o MySQL estão usando agora no seu servidor, o passo a passo de diagnóstico está em date() no PHP e o relatório que vira o dia às 21h. Todos os testes abaixo rodaram no PHP 8.5.8 (CLI), com a base de fusos que vem embutida nele.

Instante: UTC no banco, fuso só na tela
Um instante é um ponto único na linha do tempo. "22h de segunda em São Paulo" e "01h de terça em UTC" são o mesmo instante escrito de dois jeitos. O jeito previsível de guardar é sempre o mesmo: gerar no PHP com fuso explícito, gravar em UTC e converter para o fuso de quem lê só na exibição.
O fuso entra pelo nome da região, como America/Sao_Paulo, e nunca como "-03:00". O nome carrega o histórico de regras daquele lugar. O Brasil não tem horário de verão desde fevereiro de 2019, mas o seu banco pode ter dados de antes, e um deslocamento fixo erra todos eles em uma hora. O teste 1 do script mais abaixo mostra isso: meio-dia de 01/12/2018 em São Paulo era 14:00 UTC; com "-03:00" o PHP diz 15:00.
O horário de verão também deixou horas que não existiram. Em 04/11/2018 os relógios de São Paulo pularam de 00:00 para 01:00. Peça ao PHP "00:30" desse dia e ele devolve 01:30 no horário de verão, sem erro nem aviso (teste 2). Se um sistema importa planilha antiga com horários locais, esse é o tipo de linha que precisa de revisão.
Uma armadilha de nomes: os fusos Etc/GMT+3 têm o sinal invertido por convenção da base da IANA. Etc/GMT+3 é três horas atrás de UTC, ou seja, o mesmo deslocamento de Brasília (teste 3). Mais um motivo para usar o nome da cidade. A lista de fusos aceitos pelo PHP traz também America/Manaus, America/Cuiaba, America/Rio_Branco e America/Noronha, que existem porque o Brasil tem mais de um fuso.
Na API, mande o instante com o deslocamento junto, com format(DATE_ATOM), que gera algo como 2026-10-30T01:00:00+00:00. Texto sem fuso, como "2026-10-30 01:00:00", cada cliente interpreta de um jeito. O mesmo cuidado em PostgreSQL e JavaScript está em grave em UTC, mostre no fuso local.
Data de calendário: não converta
Data de nascimento, vencimento, feriado e prazo legal são datas sem hora. Elas não estão em fuso nenhum e não devem passar por conversão. O erro clássico é criar a data como "meia-noite UTC" e depois converter para São Paulo: 15/03/1990 vira 14/03/1990 na tela (teste 4).
No PHP, leia a data pura com createFromFormat e o prefixo !, que zera os campos de hora em vez de pegar a hora atual, e use o fuso de quem vai ver só para que o objeto fique consistente. No banco, coluna DATE. Conta de prazo em dias corridos também é conta de calendário, e é por isso que o cálculo do prazo de 10 dias da rescisão trata o término do contrato como data pura.
Agendamento futuro: hora local mais o nome do fuso
"Consulta às 9h de 10/03/2027 em São Paulo" não é um instante ainda. O instante depende da regra de fuso que estiver valendo naquele dia, e regras mudam por lei, às vezes com poucas semanas de aviso. Se o sistema converte para UTC hoje e grava só o UTC, uma mudança de regra até lá faz a consulta disparar na hora errada.
Grave duas colunas: a hora local ("2027-03-10 09:00") e o fuso ("America/Sao_Paulo"). Calcule o instante quando for usar, perto da data, com a base de fusos atualizada. O teste 6 faz isso e mostra também que, na base do nosso PHP, a última mudança de regra de São Paulo foi em 17/02/2019, o fim do último horário de verão.
A base de fusos também envelhece
As regras vêm da base tz da IANA, que publica versões novas várias vezes por ano. A versão 2026f, de 27/10/2026, adiou a saída do horário de verão em Cuba e corrigiu dados do Brasil de 1965 e 1966: em 1966, o horário de verão terminou em 01/03 à 01h, não à 00h.
O PHP traz uma cópia própria dessa base, e a função timezone_version_get() diz qual. O nosso PHP 8.5.8 respondeu 2026.1, de março de 2026. Por isso o teste 7 ainda mostra 01/03/1966 00:30 com offset -03:00, quando pela 2026f aquele horário ainda era de verão. Para dado de 1966, o impacto é pequeno. Para uma mudança de regra futura, como a de Cuba, um servidor com base velha agenda tudo uma hora fora.
A documentação do PHP indica dois caminhos: atualizar o PHP ou instalar a extensão timezonedb do PECL, que substitui a base embutida e saiu na versão 2026.6 em 29/10/2026. Algumas distribuições Linux compilam o PHP para ler o tzdata do sistema; nesse caso a função devolve "0.system" e quem atualiza a base é o pacote tzdata do sistema operacional.
O script dos testes
Rodado com php tipos.php:
<?php
declare(strict_types=1);
$sp = new DateTimeZone('America/Sao_Paulo');
$utc = new DateTimeZone('UTC');
echo "PHP ", PHP_VERSION, " | base de fusos ", timezone_version_get(), "\n\n";
echo "1) Nome da região x deslocamento fixo (datas com horário de verão)\n";
foreach (['2018-12-01 12:00', '2019-12-01 12:00', '2026-10-29 12:00'] as $local) {
$nome = new DateTimeImmutable($local, $sp);
$fixo = new DateTimeImmutable($local, new DateTimeZone('-03:00'));
printf(" %s nome: %s UTC | -03:00: %s UTC\n", $local,
$nome->setTimezone($utc)->format('H:i'), $fixo->setTimezone($utc)->format('H:i'));
}
echo "\n2) Hora que não existiu: 04/11/2018 00:30 em São Paulo\n";
$buraco = new DateTimeImmutable('2018-11-04 00:30', $sp);
echo " PHP entendeu: ", $buraco->format('Y-m-d H:i T (P)'), "\n";
echo "\n3) Etc/GMT+3 tem o sinal invertido\n";
$etc = new DateTimeImmutable('2026-10-29 12:00', new DateTimeZone('Etc/GMT+3'));
echo " 12:00 em Etc/GMT+3 = ", $etc->setTimezone($utc)->format('H:i'), " UTC (offset ", $etc->format('P'), ")\n";
echo "\n4) Data de calendário tratada como instante\n";
$nasc = new DateTimeImmutable('1990-03-15', $utc); // 'meia-noite UTC'
echo " aniversário gravado: 1990-03-15 | mostrado em SP: ", $nasc->setTimezone($sp)->format('d/m/Y'), "\n";
$certo = DateTimeImmutable::createFromFormat('!Y-m-d', '1990-03-15', $sp);
echo " lido como data pura: ", $certo->format('d/m/Y'), "\n";
echo "\n5) DateTime mutável x imutável\n";
$a = new DateTime('2026-10-29 00:00', $sp); $fimA = $a->modify('+1 day');
$b = new DateTimeImmutable('2026-10-29 00:00', $sp); $fimB = $b->modify('+1 day');
echo " DateTime: início ", $a->format('d/m'), " fim ", $fimA->format('d/m'), "\n";
echo " DateTimeImmutable: início ", $b->format('d/m'), " fim ", $fimB->format('d/m'), "\n";
echo "\n6) Agendamento futuro: guardar hora local + fuso\n";
$ag = ['local' => '2027-03-10 09:00', 'fuso' => 'America/Sao_Paulo'];
$inst = new DateTimeImmutable($ag['local'], new DateTimeZone($ag['fuso']));
echo " ", $ag['local'], " ", $ag['fuso'], " -> ", $inst->setTimezone($utc)->format(DATE_ATOM),
" (calculado com a regra de hoje)\n";
$tr = $sp->getTransitions(strtotime('2019-01-01'), strtotime('2027-12-31'));
$ult = end($tr);
echo " última mudança de regra em SP na base: ", substr($ult['time'], 0, 10), " (offset ", $ult['offset'] / 3600, "h)\n";
echo "\n7) Dado histórico que a IANA corrigiu na versão 2026f (27/10/2026)\n";
$h = new DateTimeImmutable('1966-03-01 00:30', $sp);
echo " 01/03/1966 00:30 em SP, com a base ", timezone_version_get(), ": offset ", $h->format('P'), "\n";
Saída no PHP 8.5.8:
PHP 8.5.8 | base de fusos 2026.1 1) Nome da região x deslocamento fixo (datas com horário de verão) 2018-12-01 12:00 nome: 14:00 UTC | -03:00: 15:00 UTC 2019-12-01 12:00 nome: 15:00 UTC | -03:00: 15:00 UTC 2026-10-29 12:00 nome: 15:00 UTC | -03:00: 15:00 UTC 2) Hora que não existiu: 04/11/2018 00:30 em São Paulo PHP entendeu: 2018-11-04 01:30 -02 (-02:00) 3) Etc/GMT+3 tem o sinal invertido 12:00 em Etc/GMT+3 = 15:00 UTC (offset -03:00) 4) Data de calendário tratada como instante aniversário gravado: 1990-03-15 | mostrado em SP: 14/03/1990 lido como data pura: 15/03/1990 5) DateTime mutável x imutável DateTime: início 30/10 fim 30/10 DateTimeImmutable: início 29/10 fim 30/10 6) Agendamento futuro: guardar hora local + fuso 2027-03-10 09:00 America/Sao_Paulo -> 2027-03-10T12:00:00+00:00 (calculado com a regra de hoje) última mudança de regra em SP na base: 2019-02-17 (offset -3h) 7) Dado histórico que a IANA corrigiu na versão 2026f (27/10/2026) 01/03/1966 00:30 em SP, com a base 2026.1: offset -03:00
O teste 5 merece uma linha a mais. Com DateTime, modify() altera o próprio objeto e devolve ele mesmo, então "fim = início + 1 dia" empurra o início junto: os dois viram 30/10. Com DateTimeImmutable, cada operação devolve um objeto novo e o início fica em 29/10. Em código de filtro por período, use sempre a versão imutável.
Onde definir o fuso padrão
O fuso padrão das funções de data vem da diretiva date.timezone ou de date_default_timezone_set(); sem nenhum dos dois, o PHP usa UTC. Com a modelagem acima, o fuso padrão importa menos, porque todo objeto de data é criado com um DateTimeZone explícito. Ainda assim, deixe o mesmo valor em todos os ambientes, web e linha de comando, para que um date() esquecido no código não dê resultado diferente no cron.
Referência: http://codigofonte.uol.com.br/codigos/setar-fuso-horario-do-servidor