PHP header e Redirecionamento
Depois de salvar um cadastro, o site leva você para a página "Obrigado!". Uma página antiga mudou de endereço e o site manda você para a nova. Uma área restrita manda quem não fez login para a tela de entrar. Tudo isso é redirecionamento: em vez de uma página, o servidor responde "vá para outro endereço". Em PHP, ele é feito com a função header, que também serve para mandar outros cabeçalhos da resposta, como "isto é JSON" ou "isto é um arquivo para baixar".
- O que são os cabeçalhos de uma resposta HTTP e o código de status.
- Como redirecionar com
header('Location: …'), e por que oexitlogo depois é obrigatório. - O problema do F5 que reenvia um formulário, e a solução: o padrão Post/Redirect/Get.
- Os códigos 301, 302 e 303 (e 307/308), e quando usar cada um.
- Como mudar o código da resposta com
http_response_code(o 404, por exemplo). - Outros cabeçalhos:
Content-Typepara JSON e texto puro, eContent-Dispositionpara downloads. - De novo, o erro headers already sent, agora com
header.
A ideia: "mudou-se para…"
Quando alguém se muda, deixa um aviso no correio: "toda carta para a Rua A, entregue na Rua B". O carteiro chega na Rua A, vê o aviso e vai até a Rua B, sem o remetente precisar fazer nada.
O redirecionamento é esse aviso. O navegador pede velho.php; o PHP responde, em vez de uma página, com um cabeçalho Location: novo.php ("localização: novo.php"). O navegador lê o aviso e faz, sozinho, um segundo pedido para novo.php. A barra de endereço muda para o endereço novo.
NAVEGADOR SERVIDOR
1. GET /velho.php ──────────────▶ header('Location: novo.php'); exit;
◀────────────── 302 Found
Location: novo.php
2. GET /novo.php ──────────────▶ (roda o novo.php)
◀────────────── 200 OK + a página
Location). O navegador faz o segundo pedido sozinho.Cabeçalhos e código de status
Toda resposta HTTP tem duas partes: os cabeçalhos (informações sobre a resposta, uma por linha) e, depois de uma linha em branco, o corpo (a página). A primeira linha traz o código de status: um número que resume o resultado. Você já viu alguns nas aulas anteriores com o curl -i:
| Faixa | Significa | Exemplos |
|---|---|---|
2xx | Deu certo | 200 OK |
3xx | Vá para outro lugar (redirecionamento) | 301, 302, 303 |
4xx | Erro de quem pediu | 404 Not Found (não encontrado), 403 Forbidden (proibido) |
5xx | Erro do servidor | 500 Internal Server Error |
A função header acrescenta um cabeçalho à resposta. Como os cabeçalhos viajam antes do corpo, ela só funciona antes de qualquer saída, a mesma regra do setcookie e do session_start (Cookies).
Redirecionar: header('Location: …') + exit
<?php
header('Location: novo.php');
exit;
<?php echo "Você está na página nova.\n";
- header(
- "Cabeçalho": acrescenta uma linha aos cabeçalhos da resposta.
- 'Location: novo.php'
- O cabeçalho, no formato
Nome: valor.Locationdiz para onde ir: um arquivo do mesmo site (novo.php,/loja/carrinho.php,obrigado.php?id=5) ou outro site (https://exemplo.com/). Ao ver umLocation, o PHP muda o código da resposta para302sozinho. - exit;
- "Sair": termina o programa aqui. Sem ele, o PHP continua rodando o resto do arquivo (veja logo abaixo por que isso é perigoso).
$ curl -si localhost:8000/velho.php HTTP/1.1 302 Found Host: localhost:8000 Date: Sat, 17 Oct 2026 00:10:57 GMT Connection: close X-Powered-By: PHP/8.5.8 Location: novo.php Content-type: text/html; charset=UTF-8 $ curl -sL localhost:8000/velho.php Você está na página nova.
O primeiro curl mostra a resposta crua: código 302 Found ("encontrado", em outro lugar), o Location e o corpo vazio. O segundo usa -L (location), que faz o curl seguir o aviso como um navegador. No terminal do php -S, dá para ver os dois pedidos:
[Fri Oct 16 21:10:57 2026] 127.0.0.1:44274 [302]: GET /velho.php [Fri Oct 16 21:10:57 2026] 127.0.0.1:44282 [200]: GET /novo.php
Por que o exit é obrigatório
O header só anota o cabeçalho; ele não para o programa. Veja uma "área restrita" que manda para o login quem não está logado, mas esqueceu o exit:
<?php
$logado = false;
if (!$logado) {
header('Location: login.php');
}
echo "Saldo do caixa: R$ 1.234,00\n";
$ curl -si localhost:8000/semexit.php HTTP/1.1 302 Found … Location: login.php Content-type: text/html; charset=UTF-8 Saldo do caixa: R$ 1.234,00 « o segredo foi enviado junto!
O navegador obedece ao Location tão rápido que a pessoa nem vê o corpo. Mas ele foi enviado: qualquer um que use curl (ou desligue o redirecionamento) lê o saldo. E se depois do header houvesse um código que apaga ou salva algo, ele rodaria também. Regra: header('Location: …') e exit andam sempre juntos.
Dois arquivos nesta caixa: index.php (o que roda) e novo.php. Rode: o editor segue o redirecionamento, como um navegador, e mostra a página nova. Depois apague a linha do header, ou troque o destino para novo.php?de=index, e veja a diferença.
<?php
header('Location: novo.php');
exit;
<?php echo "<p>Você está na página nova.</p>"; echo "<p>Endereço pedido: " . htmlspecialchars($_SERVER['REQUEST_URI']) . "</p>";
O problema do F5 e o padrão Post/Redirect/Get
A Ana fez um mural de recados para a equipe da padaria. O formulário envia por POST, o PHP grava o recado num arquivo (com file_put_contents, de Ler e Escrever Arquivos) e mostra a lista:
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$texto = trim($_POST['recado'] ?? '');
file_put_contents('recados.txt', $texto . "\n", FILE_APPEND);
echo "Recado salvo!\n";
}
echo "Recados:\n", file_get_contents('recados.txt');
A funcionária envia "Bolo pronto às 15h" e vê a confirmação. Depois aperta F5 para atualizar a página. O navegador avisa: "Para exibir esta página, o navegador precisa reenviar as informações. Deseja continuar?". Muita gente clica em "Reenviar" sem ler, e o navegador repete o mesmo POST. É o que este segundo curl igualzinho faz:
$ curl -s localhost:8000/recados.php -d "recado=Bolo+pronto+às+15h" Recado salvo! Recados: Bolo pronto às 15h $ curl -s localhost:8000/recados.php -d "recado=Bolo+pronto+às+15h" Recado salvo! Recados: Bolo pronto às 15h Bolo pronto às 15h « duplicado pelo "F5"
Num mural, é só um recado repetido. Numa loja, é um pedido em dobro; num banco, uma transferência em dobro. O problema: a página que a pessoa está vendo é o resultado de um POST, e atualizar uma página é repetir o pedido que a gerou.
A solução: Post/Redirect/Get (PRG)
Nunca responda um POST com uma página. Responda com um redirecionamento, e mostre a página num GET:
- Post: o formulário envia por POST. O PHP processa (grava o recado).
- Redirect: em vez de mostrar algo, o PHP responde
303+Location: mural.php. - Get: o navegador pede
mural.phppor GET, e mostra a lista.
Agora a página na tela é resultado de um GET. O F5 repete o GET, que só lê: nada é gravado de novo, e o navegador nem pergunta nada.
E a mensagem "Recado salvo!"? Ela precisa sobreviver ao redirecionamento, então vai para a sessão (Sessões) e é apagada assim que mostrada (a "mensagem de uma vez só", ou flash):
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$texto = trim($_POST['recado'] ?? '');
file_put_contents('mural.txt', $texto . "\n", FILE_APPEND);
$_SESSION['aviso'] = 'Recado salvo!';
header('Location: mural.php', true, 303);
exit;
}
if (isset($_SESSION['aviso'])) {
echo $_SESSION['aviso'], "\n";
unset($_SESSION['aviso']);
}
echo "Recados:\n", file_get_contents('mural.txt');
| Trecho | O que faz |
|---|---|
if (… === 'POST') { … } | Parte do Post: grava e guarda o aviso. Não mostra nada. |
header('Location: mural.php', true, 303) | Parte do Redirect. O segundo argumento (true) substitui um cabeçalho igual, se já houver; o terceiro escolhe o código: 303 See Other ("veja outro"), que diz explicitamente "agora peça com GET". |
exit; | Para aqui. O resto do arquivo é a parte do GET. |
$_SESSION['aviso'] + unset | Mostra o aviso uma vez só e o apaga. |
echo "Recados:…" | Parte do Get: só lê e mostra. |
$ curl -si -c pote -b pote localhost:8000/mural.php -d "recado=Bolo+pronto+às+15h" HTTP/1.1 303 See Other … Set-Cookie: PHPSESSID=57e6e0e639ce571ca8e543cf1fe6f167; path=/ … Location: mural.php Content-type: text/html; charset=UTF-8 $ curl -s -c pote -b pote localhost:8000/mural.php « o GET que o navegador faria Recado salvo! Recados: Bolo pronto às 15h $ curl -s -c pote -b pote localhost:8000/mural.php « o "F5" Recados: Bolo pronto às 15h « nada duplicado, e o aviso não se repete
Com curl -L, que segue o redirecionamento sozinho como o navegador, o envio inteiro fica numa linha. O log do servidor mostra o par POST → GET:
$ curl -sL -c pote2 -b pote2 localhost:8000/mural.php -d "recado=Pão+saindo" Recado salvo! Recados: Bolo pronto às 15h Pão saindo
[Fri Oct 16 21:10:57 2026] 127.0.0.1:44358 [303]: POST /mural.php [Fri Oct 16 21:10:57 2026] 127.0.0.1:44366 [200]: GET /mural.php
Os códigos de redirecionamento
Há vários códigos 3xx, e a diferença entre eles importa para navegadores e buscadores (Google):
| Código | Nome | Significa | O próximo pedido | Use para |
|---|---|---|---|---|
301 | Moved Permanently | Mudou-se para sempre. Navegadores e buscadores guardam o novo endereço. | GET (na prática) | Página que trocou de endereço de vez (contato.html → contato.php) |
302 | Found | Está em outro lugar por agora. O padrão do header('Location: …'). | GET (na prática) | Redirecionamentos temporários: "faça login primeiro" |
303 | See Other | "Veja outra página", sempre com GET. | Sempre GET | Depois de um POST (o PRG) |
307 | Temporary Redirect | Como o 302, mas o navegador repete o mesmo método (um POST continua POST). | O mesmo método | Raro em páginas; usado em APIs |
308 | Permanent Redirect | Como o 301, mantendo o método. | O mesmo método | Raro em páginas; usado em APIs |
Duas formas de escolher o código, com o mesmo resultado:
<?php
header('Location: novo.php', true, 301);
exit;
<?php
http_response_code(301);
header('Location: novo.php');
exit;
$ curl -si localhost:8000/codigos.php | head -1 HTTP/1.1 301 Moved Permanently $ curl -si localhost:8000/codigos2.php | head -1 HTTP/1.1 301 Moved Permanently
http_response_code: o código de qualquer resposta
O código de status não serve só para redirecionar. Quando um produto não existe, a página deve responder 404, e não 200 com a frase "não encontrado": assim buscadores não guardam a página de erro, e programas que leem o seu site sabem que deu errado. A função http_response_code muda o código (e, sem argumentos, diz qual é o atual):
<?php
$id = (int) ($_GET['id'] ?? 0);
$produtos = [1 => 'Bolo de cenoura', 2 => 'Pão de queijo'];
if (!isset($produtos[$id])) {
http_response_code(404);
echo "Produto não encontrado.\n";
exit;
}
echo $produtos[$id], "\n";
$ curl -si "localhost:8000/naoachou.php?id=9" | sed -n '1p;$p' HTTP/1.1 404 Not Found Produto não encontrado. $ curl -s "localhost:8000/naoachou.php?id=1" Bolo de cenoura
(O sed -n '1p;$p' mostra só a primeira e a última linha da resposta.) Outros códigos comuns que você mesmo vai mandar: 403 (proibido: logado, mas sem permissão), 400 (pedido malformado), 500 (erro no servidor).
Outros cabeçalhos
Content-Type: que tipo de conteúdo é este
Por padrão, o PHP avisa que a resposta é uma página HTML (Content-type: text/html; charset=UTF-8, que você viu em todo curl -i). Quando o seu PHP devolve outra coisa, diga o tipo certo:
<?php
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['produto' => 'Bolo de cenoura', 'preco' => 25.9]);
$ curl -si localhost:8000/api.php | grep -v "^Date\|^Host\|^Conn"
HTTP/1.1 200 OK
X-Powered-By: PHP/8.5.8
Content-Type: application/json; charset=utf-8
{"produto":"Bolo de cenoura","preco":25.9}
Com application/json, o JavaScript que pediu os dados (e o navegador, ao abrir o endereço) sabe que a resposta é JSON (veja JSON). Para texto puro, sem HTML:
<?php
header('Content-Type: text/plain; charset=utf-8');
echo "<b>Isto não vira negrito</b>\n";
$ curl -si localhost:8000/texto.php | grep -i "content-type" Content-Type: text/plain; charset=utf-8
No navegador, essa página aparece com os <b> à mostra, em letra de máquina de escrever: com text/plain, o navegador não interpreta HTML.
Content-Disposition: baixar em vez de mostrar
<?php
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="pedidos.csv"');
echo "cliente,produto\nBia,Bolo de cenoura\n";
$ curl -si localhost:8000/baixar.php | grep -iv "^Date\|^Host\|^Conn" HTTP/1.1 200 OK X-Powered-By: PHP/8.5.8 Content-Type: text/csv; charset=utf-8 Content-Disposition: attachment; filename="pedidos.csv" cliente,produto Bia,Bolo de cenoura
attachment ("anexo") faz o navegador abrir a janela de download, com o nome sugerido em filename. É assim que se gera uma planilha para baixar (veja CSV).
| Cabeçalho | Exemplo | Para quê |
|---|---|---|
Location | header('Location: obrigado.php') | Redirecionar |
Content-Type | application/json; charset=utf-8, text/plain; charset=utf-8, text/csv | Dizer o tipo do conteúdo |
Content-Disposition | attachment; filename="x.csv" | Forçar download com um nome |
Cache-Control | no-store | Pedir para não guardar a página em cache |
Refresh | header('Refresh: 5; url=index.php') | Redirecionar depois de 5 segundos (os navegadores entendem; prefira o Location sempre que possível) |
"Headers already sent", de novo
<!doctype html>
<?php
header('Location: novo.php');
exit;
$ curl -si localhost:8000/erro.php HTTP/1.1 200 OK Host: localhost:8000 Date: Sat, 17 Oct 2026 00:10:57 GMT Connection: close X-Powered-By: PHP/8.5.8 Content-type: text/html; charset=UTF-8 <!doctype html> <br /> <b>Warning</b>: Cannot modify header information - headers already sent by (output started at /home/ana/site/r/erro.php:1) in <b>/home/ana/site/r/erro.php</b> on line <b>3</b><br />
A resposta saiu como 200 OK, sem Location: o redirecionamento não aconteceu. O <!doctype html> da linha 1 (fora do <?php) é saída, e os cabeçalhos foram enviados ali. Tudo o que você aprendeu em Cookies vale igual: a pista é o "output started at"; culpados comuns são HTML antes do PHP, echo, linhas em branco, o BOM e avisos.
<?php … ?> que decide (lê o formulário, valida, grava, redireciona, define cookies e cabeçalhos); depois o HTML que mostra. Se precisar conferir, headers_sent() diz se já é tarde demais.Quando dá errado
Cannot modify header information
O que aconteceu: alguma saída antes do header. Veja o exemplo logo acima.
Como resolver: vá ao arquivo e linha do "output started at" e remova a saída, ou mova o header para antes dela.
Location: login.php … Saldo do caixa: R$ 1.234,00
O que aconteceu: faltou o exit depois do header('Location: …'). O programa continuou, e o resto da página foi enviado no corpo do 302.
Como resolver: header('Location: …'); sempre seguido de exit;.
ERR_TOO_MANY_REDIRECTS
O que aconteceu: um laço de redirecionamentos: login.php manda para painel.php, que manda de volta para login.php… (por exemplo, o painel exige login e o login acha que a pessoa já está logada). Ou a página redireciona para ela mesma sem condição. O navegador desiste depois de umas 20 voltas.
Como resolver: confira as condições de cada header('Location'). O curl -sIL endereço mostra cada salto (-I pede só os cabeçalhos), e o log do php -S também.
O que aconteceu: o formulário POST respondeu com uma página, e o F5 repetiu o POST.
Como resolver: Post/Redirect/Get: depois de processar o POST com sucesso, header('Location: …', true, 303); exit;.
O que aconteceu: a resposta saiu com o Content-Type padrão (text/html).
Como resolver: header('Content-Type: application/json; charset=utf-8'); antes do echo json_encode(…).
Referência
| Código | O que faz |
|---|---|
header('Location: x.php'); exit; | Redireciona (302) |
header('Location: x.php', true, 303); exit; | Redireciona com outro código (aqui, 303, o do PRG) |
http_response_code(404) | Define o código da resposta |
http_response_code() | Diz o código atual |
header('Content-Type: application/json; charset=utf-8') | Resposta JSON |
header('Content-Type: text/plain; charset=utf-8') | Resposta em texto puro |
header('Content-Disposition: attachment; filename="x.csv"') | Força o download |
headers_sent() | true se os cabeçalhos já saíram |
headers_list() | Lista os cabeçalhos preparados até agora |
curl -si / curl -sL / curl -sIL | Ver cabeçalhos / seguir redirecionamentos / só cabeçalhos de cada salto |
Exercícios
1. Redirecionar
Complete para mandar o visitante para login.php:
exit;
2. Depois do POST
Complete com o código de status do padrão Post/Redirect/Get:
3. Não encontrado
Complete para a resposta sair com o código 404:
4. O que acontece se você esquecer o exit depois de header('Location: login.php')?
5. A página de contato mudou de contato.html para contato.php, de vez. Qual código usar no redirecionamento?
6. Por que o Post/Redirect/Get impede pedidos duplicados no F5?
Missão: aplique o Post/Redirect/Get no mural. A página foi aberta com o envio de um recado. Depois de gravar, em vez de mostrar a mensagem, guarde 'Recado salvo!' em $_SESSION['aviso'], redirecione para index.php com o código 303 e pare com exit. Na parte de mostrar, exiba o aviso (se houver) e apague-o. Teste enviando recados na aba Resultado.
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$texto = trim($_POST['recado'] ?? '');
file_put_contents('mural.txt', $texto . "\n", FILE_APPEND);
echo "<p>Recado salvo!</p>";
}
$recados = is_file('mural.txt') ? file('mural.txt', FILE_IGNORE_NEW_LINES) : [];
?>
<form method="post">
<input name="recado"> <button>Publicar</button>
</form>
<ul>
<?php foreach ($recados as $r): ?>
<li><?= htmlspecialchars($r) ?></li>
<?php endforeach; ?>
</ul>
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$texto = trim($_POST['recado'] ?? '');
file_put_contents('mural.txt', $texto . "\n", FILE_APPEND);
$_SESSION['aviso'] = 'Recado salvo!';
header('Location: index.php', true, 303);
exit;
}
if (isset($_SESSION['aviso'])) {
echo "<p>" . htmlspecialchars($_SESSION['aviso']) . "</p>";
unset($_SESSION['aviso']);
}
$recados = is_file('mural.txt') ? file('mural.txt', FILE_IGNORE_NEW_LINES) : [];
?>
<form method="post">
<input name="recado"> <button>Publicar</button>
</form>
<ul>
<?php foreach ($recados as $r): ?>
<li><?= htmlspecialchars($r) ?></li>
<?php endforeach; ?>
</ul>
Missão: transforme a página numa pequena API: mande o cabeçalho Content-Type: application/json; charset=utf-8 e troque o print_r por echo json_encode($produto).
<?php $produto = ['produto' => 'Bolo de cenoura', 'preco' => 25.9, 'disponivel' => true]; print_r($produto);
<?php
header('Content-Type: application/json; charset=utf-8');
$produto = ['produto' => 'Bolo de cenoura', 'preco' => 25.9, 'disponivel' => true];
echo json_encode($produto);
Resumo
- Uma resposta HTTP = código de status + cabeçalhos + corpo.
header()acrescenta cabeçalhos, sempre antes de qualquer saída. - Redirecionar:
header('Location: destino.php'); exit;. Oexité obrigatório. - Códigos:
301mudou para sempre;302temporário (o padrão);303depois de POST;307/308mantêm o método. - Post/Redirect/Get: processe o POST, redirecione com 303, mostre num GET. O F5 deixa de duplicar pedidos. Mensagens de confirmação vão pela sessão.
http_response_code(404)para respostas de erro de verdade.Content-Typediz o que é a resposta (application/json,text/plain);Content-Disposition: attachmentforça o download.- Redirecionamento que não acontece +
Cannot modify header information= saída antes doheader: siga o "output started at".