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".

Você vai aprender
  • 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 o exit logo 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-Type para JSON e texto puro, e Content-Disposition para 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
O primeiro pedido recebe só um "vá para lá" (código 302 e o cabeçalho 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:

FaixaSignificaExemplos
2xxDeu certo200 OK
3xxVá para outro lugar (redirecionamento)301, 302, 303
4xxErro de quem pediu404 Not Found (não encontrado), 403 Forbidden (proibido)
5xxErro do servidor500 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. Location diz 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 um Location, o PHP muda o código da resposta para 302 sozinho.
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:

  1. Post: o formulário envia por POST. O PHP processa (grava o recado).
  2. Redirect: em vez de mostrar algo, o PHP responde 303 + Location: mural.php.
  3. Get: o navegador pede mural.php por 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');
TrechoO 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'] + unsetMostra 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
Regra prática: todo formulário POST que muda algo (grava, apaga, compra, envia) termina com um redirecionamento quando dá certo. Quando há erro de validação (Validar Dados), aí sim mostre o formulário de novo, com as mensagens: nada foi gravado, então reenviar não duplica nada.

Os códigos de redirecionamento

Há vários códigos 3xx, e a diferença entre eles importa para navegadores e buscadores (Google):

CódigoNomeSignificaO próximo pedidoUse para
301Moved PermanentlyMudou-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)
302FoundEstá em outro lugar por agora. O padrão do header('Location: …').GET (na prática)Redirecionamentos temporários: "faça login primeiro"
303See Other"Veja outra página", sempre com GET.Sempre GETDepois de um POST (o PRG)
307Temporary RedirectComo o 302, mas o navegador repete o mesmo método (um POST continua POST).O mesmo métodoRaro em páginas; usado em APIs
308Permanent RedirectComo o 301, mantendo o método.O mesmo métodoRaro em páginas; usado em APIs
Cuidado com o 301. Como ele é "para sempre", o navegador guarda e nem pergunta mais ao servidor. Se você errar o destino de um 301, as pessoas que já visitaram continuam indo para o lugar errado mesmo depois de você corrigir. Na dúvida, use 302 (ou 303 depois de POST).

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çalhoExemploPara quê
Locationheader('Location: obrigado.php')Redirecionar
Content-Typeapplication/json; charset=utf-8, text/plain; charset=utf-8, text/csvDizer o tipo do conteúdo
Content-Dispositionattachment; filename="x.csv"Forçar download com um nome
Cache-Controlno-storePedir para não guardar a página em cache
Refreshheader('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.

A organização que evita o problema: em cada página, primeiro um bloco <?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

O redirecionamento não acontece e aparece 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.

Redirecionou, mas o código de depois rodou
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;.

"Esta página não está redirecionando corretamente" / 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.

Atualizei a página e o pedido foi duplicado

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 JSON aparece como texto corrido, ou o JavaScript não o reconhece

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ódigoO 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 -sILVer cabeçalhos / seguir redirecionamentos / só cabeçalhos de cada salto

Exercícios

1. Redirecionar

Complete para mandar o visitante para login.php:

header('');
exit;

2. Depois do POST

Complete com o código de status do padrão Post/Redirect/Get:

header('Location: mural.php', true, );

3. Não encontrado

Complete para a resposta sair com o código 404:

(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;. O exit é obrigatório.
  • Códigos: 301 mudou para sempre; 302 temporário (o padrão); 303 depois de POST; 307/308 mantê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-Type diz o que é a resposta (application/json, text/plain); Content-Disposition: attachment força o download.
  • Redirecionamento que não acontece + Cannot modify header information = saída antes do header: siga o "output started at".