date() no PHP e o relatório que vira o dia às 21h: fuso do servidor na prática
Descubra qual fuso o seu PHP está usando no FPM e no cron, defina-o num lugar só e monte o filtro de "pedidos de hoje" convertendo o dia de Brasília para UTC.
Toda noite, a partir das 21h, o relatório "Vendas de hoje" da loja começa a zerar. O pedido feito às 22h15 aparece no dia seguinte, e o fechamento do caixa nunca bate com o sistema. Ninguém mudou o código. O servidor está em UTC, três horas à frente de Brasília, e para ele o dia já virou às 21h no horário da loja.
A resposta curta: date() formata o horário no fuso padrão do PHP, que vem da configuração do servidor, não do lugar onde a pessoa está. Defina o fuso de propósito, num lugar só, e confira se o cron usa o mesmo que o site. Melhor ainda: grave o instante em UTC e, para filtrar "hoje", calcule o início e o fim do dia no fuso de São Paulo e converta esses dois limites para UTC antes da consulta.
Por que o dia vira às 21h
O Brasil continental no horário de Brasília está em UTC-3, sem horário de verão desde 2019. Quando são 21h em São Paulo, são 0h em UTC. Se o PHP está configurado para UTC, date('Y-m-d') às 21h01 da loja já devolve a data de amanhã. Às 22h15, o pedido nasce com a data seguinte, e o relatório de "hoje" não o encontra.
De onde vem o fuso do PHP? Da diretiva date.timezone no php.ini, ou de uma chamada a date_default_timezone_set() no código. Se nada for configurado, o PHP usa UTC. Muitas imagens Docker e servidores em nuvem chegam assim, e o sistema funciona perfeitamente das 0h às 21h. O bug só aparece à noite, quando ninguém está testando.
Há uma armadilha extra: o PHP da web e o PHP da linha de comando costumam ler arquivos de configuração diferentes. Em Debian e Ubuntu, por exemplo, o PHP-FPM e o CLI têm cada um o seu php.ini. O site pode estar em São Paulo e o cron que gera o relatório das 23h50 em UTC. E o MySQL tem o seu próprio fuso de sessão, que decide o que NOW() devolve.
Como descobrir o fuso que cada parte está usando
Antes de corrigir, meça. Na linha de comando:
php -r 'echo date_default_timezone_get(), PHP_EOL;'
php --ini
php -i | grep date.timezone
O primeiro comando mostra o fuso efetivo do CLI. O segundo mostra quais arquivos php.ini foram carregados. O terceiro mostra o valor da diretiva. Para o PHP da web, crie temporariamente uma rota que imprima date_default_timezone_get() e date('c'), confira e apague. No MySQL:
SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP();
Na loja do exemplo, o resultado foi revelador: web em America/Sao_Paulo, configurado por alguém anos atrás; CLI em UTC; MySQL com o fuso do sistema operacional, também UTC. O site gravava pedidos com date('Y-m-d H:i:s') no horário de Brasília, e o cron filtrava com NOW() do MySQL, em UTC. Duas verdades diferentes sobre que horas eram.
Como corrigir em três passos
- Defina o fuso da aplicação num lugar só, no arquivo de inicialização que tanto a web quanto o CLI carregam, com
date_default_timezone_set('America/Sao_Paulo'), ou nophp.inide cada ambiente com o mesmo valor. - Grave o instante em UTC, gerado pelo PHP com fuso explícito, numa coluna
DATETIME. Não dependa doNOW()do banco, a não ser que o fuso da sessão do MySQL também esteja definido. - Converta só nas pontas: transforme o "dia" de quem pede em um intervalo UTC para consultar, e transforme o instante UTC em horário local para mostrar.
Gravando o pedido com fuso explícito
<?php
$utc = new DateTimeZone('UTC');
$agora = new DateTimeImmutable('now', $utc);
$stmt = $pdo->prepare('INSERT INTO pedidos (cliente_id, total_centavos, criado_em) VALUES (?, ?, ?)');
$stmt->execute([$clienteId, $totalCentavos, $agora->format('Y-m-d H:i:s')]);
O pedido das 22h15 de 16 de outubro em São Paulo fica gravado como 2026-10-17 01:15:00. Isso está certo: é o mesmo instante, escrito em UTC. O "dia" do pedido não está na coluna; ele depende de quem pergunta, e é calculado na consulta. Os valores em centavos seguem a lógica do guia de dinheiro em centavos.
O filtro de "pedidos de hoje" que respeita Brasília
<?php
$fusoLoja = new DateTimeZone('America/Sao_Paulo');
$utc = new DateTimeZone('UTC');
$inicioLocal = new DateTimeImmutable('today', $fusoLoja);
$fimLocal = $inicioLocal->modify('+1 day');
$stmt = $pdo->prepare(
'SELECT COUNT(*) AS pedidos, SUM(total_centavos) AS total
FROM pedidos
WHERE criado_em >= ? AND criado_em < ?'
);
$stmt->execute([
$inicioLocal->setTimezone($utc)->format('Y-m-d H:i:s'),
$fimLocal->setTimezone($utc)->format('Y-m-d H:i:s'),
]);
$resumo = $stmt->fetch(PDO::FETCH_ASSOC);
Para 16 de outubro em São Paulo, o intervalo enviado ao banco é de 2026-10-16 03:00:00 até antes de 2026-10-17 03:00:00 em UTC. O pedido das 22h15, gravado como 01h15 do dia 17 em UTC, cai dentro. O intervalo semiaberto, com >= no início e < no fim, evita contar duas vezes o pedido feito exatamente à meia-noite. E como a comparação é direta na coluna, um índice em criado_em continua sendo usado.
Para exibir, faça o caminho inverso:
$criado = new DateTimeImmutable($linha['criado_em'], $utc);
echo $criado->setTimezone($fusoLoja)->format('d/m/Y H:i');
DATETIME ou TIMESTAMP no MySQL
Uma coluna TIMESTAMP converte do fuso da sessão para UTC ao gravar e de volta ao ler. Uma DATETIME guarda o que você mandou, sem conversão. As duas funcionam; o importante é saber qual você usa. Com DATETIME e o PHP sempre gravando em UTC, o comportamento não depende da configuração do banco. O guia de data e fuso no PHP aprofunda a configuração, e o post sobre gravar o instante em UTC discute o princípio em outras linguagens.
Teste perto da meia-noite, não ao meio-dia
O bug de fuso é invisível durante o expediente. Escreva um teste com um instante fixo, por exemplo new DateTimeImmutable('2026-10-16 22:15', $fusoLoja), grave, rode o filtro do dia 16 e confira que o pedido aparece. Repita com 23h59 e 0h00 do dia 17. Se o código usa 'now' espalhado, isole a obtenção da hora atual numa função que o teste possa substituir.
Erros comuns
- Confiar no fuso padrão do servidor: o sistema funciona de dia e erra todo pedido feito depois das 21h.
- Configurar o fuso só no PHP da web: o cron do relatório roda no CLI, em UTC, e corta o dia em outro horário.
- Misturar
date()do PHP comNOW()do MySQL: cada um usa o seu fuso, e os números nunca batem. - Filtrar com
DATE(criado_em) = CURDATE(): o dia é o do banco, não o da loja, e a função na coluna impede o uso do índice. - Gravar horário local sem indicar o fuso: uma migração de servidor ou de região muda o significado de todos os registros antigos.
- Testar só em horário comercial: o erro passa por todos os testes e aparece no fechamento do caixa.
Checklist
- Você sabe o fuso efetivo do PHP na web, no CLI e da sessão do MySQL.
- O fuso da aplicação está definido num único lugar, carregado por web e cron.
- Instantes são gravados em UTC, gerados com fuso explícito.
- Filtros por dia convertem início e fim do dia local para UTC.
- A consulta usa intervalo semiaberto, sem função aplicada na coluna.
- A exibição converte de UTC para o fuso de quem lê.
- Existe teste com pedidos às 22h15, 23h59 e 0h00.
Abra o relatório de hoje do seu sistema às 21h30: o "hoje" que ele mostra é o dia de Brasília ou o dia do servidor?
Referência: http://denecir.blogspot.com.br/2010/09/data-hora-php.html