Trocou DateTime por DateTimeImmutable e o ->modify('+1 day') parou de fazer efeito. Não parou: ele nunca alterou o objeto. Todo método de mudança da classe imutável devolve uma instância nova e deixa a original intacta. Se você não guardar o retorno, jogou fora o resultado.
<?php
$d = new DateTimeImmutable('2026-08-31 10:00', new DateTimeZone('America/Sao_Paulo'));
$d->modify('+1 day');
echo $d->format('Y-m-d'), PHP_EOL; // 2026-08-31 — retorno descartado
$d = $d->modify('+1 day');
echo $d->format('Y-m-d'), PHP_EOL; // 2026-09-01
Vale o mesmo para add, sub, setTime, setDate e setTimezone. A regra prática: nessa classe, o retorno é o único lugar onde a mudança existe.
O bug que isso evita em loop
<?php
$inicio = new DateTimeImmutable('2026-09-01');
$dias = [];
for ($i = 0; $i < 5; $i++) {
$dias[] = $inicio->add(new DateInterval('P' . $i . 'D'));
}
foreach ($dias as $dia) {
echo $dia->format('d/m'), PHP_EOL; // 01/09 02/09 03/09 04/09 05/09
}
Troque a primeira linha por DateTime e o mesmo laço imprime 11/09 cinco vezes. O array guarda cinco referências ao único objeto que existe, e cada add empurra ele mais pra frente até somar os dez dias do acumulado. É o clássico do relatório que sai com todas as linhas na mesma data, e o motivo de eu usar a versão imutável como padrão em cálculo de prazo.
Uma diferença nova no 8.3
Até o PHP 8.2, string de data inválida em modify() soltava um Warning e devolvia false — com display_errors desligado, o erro só aparecia lá na frente, na forma de uma data errada. No 8.3 ela lança DateMalformedStringException. Se o texto vem do usuário ou de planilha importada, embrulhe a chamada.
try {
$ate = $inicio->modify($_POST['ate'] ?? '');
} catch (DateMalformedStringException $e) { // PHP 8.3+
$ate = $inicio->modify('+30 days');
}
Se o projeto ainda roda 8.2, o teste é contra false antes de usar o valor. Migrar pro 8.3 só troca onde o erro aparece: agora ele derruba a requisição em vez de contaminar o cálculo silenciosamente.