Programação Por Equipe DevBras

Capa do artigo: Fuso horário no PHP: instante, data e agendamento

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.

Avalie este artigo

Referência: http://codigofonte.uol.com.br/codigos/setar-fuso-horario-do-servidor