PHP でリダイレクトを書くとき、多くの人が最初に覚えるのは header('Location: ...') の一行でしょう。たった一行で転送できるので、手軽で便利です。しかし、この書き方で返ってくるのは、必ず 302 です。メソッドを保つ 307 や、恒久的な 301・308 を返したいときは、もうひと工夫が必要になります。

この記事では、PHP マニュアルの記載に沿って、307 を返すための正しい書き方と、そこに潜む落とし穴を整理します。確認日は 2026 年 10 月 9 日です。

基本

header('Location: URL', true, 307); と書きます。

既定

番号を指定しないと、302 が返ります。

必須

転送の直後に exit; を呼び、後続の処理を止めます。

定番の失敗

出力のあとに header() を呼ぶと、警告が出て転送されません。

一行で済むはずが、うまくいかない理由

header('Location: ...') は簡単ですが、実際には失敗の入り口がいくつもあります。

失敗 1: 302 が返ってしまう

PHP マニュアルには、Location ヘッダーを送ると、ブラウザーに転送の指示を出すだけでなく、201 か 3xx の状態コードがすでに設定されている場合を除き、302 の状態コードを返すと明記されています。番号を何も指定しなければ、302 です。POST を保ちたい場面で、これに気づかず運用すると、クライアントによっては POST が GET に変わります。

失敗 2: 転送したあとも処理が続く

header() は、「転送してください」という指示を出すだけです。PHP のスクリプトは、その後も最後まで実行され続けます。マニュアルの例にも、転送のあとに後続のコードが実行されないよう exit; を書く旨のコメントがあります。exit を忘れると、転送したにもかかわらず、データベースの更新や、メールの送信などの処理が走ってしまいます。

失敗 3: 「headers already sent」の警告

マニュアルは、header() は実際の出力が始まる前に呼ぶ必要があると、明記しています。HTML のタグだけでなく、空行や、PHP の開始タグの前の空白でも、出力が始まったとみなされます。出力のあとに呼ぶと、警告が出て、転送の指示は届きません。

失敗 4: 外部から渡された値をそのまま使ってしまう

「ログイン後に元のページへ戻す」ために、?redirect=... のような値を受け取り、そのまま Location に使う実装があります。この値を検証しないと、外部の悪意あるサイトへ利用者を誘導できてしまう「オープンリダイレクト」の脆弱性になります。

どれも、番号の扱いや転送の仕組みを理解していれば避けられます。順に見ていきます。

307 を返す基本の書き方

header() 関数は、第 2 引数で、同じ種類のヘッダーを置き換えるかどうか、第 3 引数で応答の状態コードを指定できます。マニュアルによれば、第 3 引数は、応答コードを指定した値に強制するもので、ヘッダーが空でない場合にだけ効果があります。

<?php
header('Location: https://example.com/new-page', true, 307);
exit;

これで、307 Temporary Redirect と Location ヘッダーが返ります。恒久的な転送は 301 か 308、POST の後の結果表示は 303 を同じ書き方で指定します。

<?php
// 恒久的な転送(メソッドを保つ)
header('Location: https://example.com/new-page', true, 308);
exit;

// POST の後に、結果ページへ GET で転送する
header('Location: https://example.com/order/complete', true, 303);
exit;

状態コードを先に設定する方法

もう一つの書き方は、http_response_code() で先に番号を設定してから、Location を送る方法です。

<?php
http_response_code(307);
header('Location: https://example.com/new-page');
exit;

マニュアルのとおり、Location ヘッダーは「201 か 3xx がすでに設定されていない限り 302 を返す」ので、先に 307 を設定しておけば、302 に上書きされません。ただし、header('HTTP/1.1 ...') の形で状態行を書いた場合との併用では、挙動が分かりにくくなります。マニュアルの利用者投稿には、状態行を header() で設定したあとに http_response_code() を呼んでも反映されないという報告がありますが、公式の記述ではないため、混ぜて使わないのが無難です。どちらか一方の書き方に統一してください。

転送の直前に確認したいこと

exit を必ず呼ぶ

exit;(または die;)は、header() の直後に必ず付けます。転送を返す URL で、データを更新するような処理が走ってはいけません。

出力済みかどうかを確認する

出力が始まっているかどうかは、headers_sent() で調べられます。マニュアルによれば、すでにヘッダーが送られていれば true を返し、引数に変数を渡せば、出力が始まったファイル名と行番号を取得できます。

<?php
if (headers_sent($file, $line)) {
    // 出力が始まっている。原因の場所を記録して、転送をあきらめる
    error_log("headers already sent in {$file} on line {$line}");
    exit;
}
header('Location: https://example.com/new-page', true, 307);
exit;

「headers already sent」のエラーが出たら、まずこの方法で、出力が始まった場所を突き止めます。典型的な原因は、PHP の開始タグの前の空白や空行、ファイルの先頭に付いた BOM(バイト順マーク)、そして転送より前に書いた echo です。

マニュアルでは、出力バッファリング(ob_start())を使えば、この問題を避けられるとも案内されています。ただし、出力をサーバー側で溜めることになるため、通常は、転送の処理を出力より前に移すほうが素直な解決です。

絶対 URL か、相対 URL か

マニュアルには、近年のほとんどのクライアントは Location の相対 URI を受け付けるが、一部の古いクライアントはスキーム、ホスト名、絶対パスを含む絶対 URI を必要とする、とあります。RFC 9110 でも、転送先は元のリクエストの URL に対して解決されるため、相対的な指定は使えます。ただし、古い環境も含めて確実にしたいなら、絶対 URL を組み立てて渡すのが安全です。

<?php
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$url = $scheme . '://' . $_SERVER['HTTP_HOST'] . '/new-page';
header('Location: ' . $url, true, 307);
exit;

ただし、HTTP_HOST は利用者が送る値です。信頼できるホスト名を設定に固定しておき、そちらを使うほうが安全です。

転送先を安全に決める

外部から受け取った値を転送先にするときは、必ず検証します。確実なのは、許可する転送先を、あらかじめ列挙しておく方法です。

<?php
function safe_redirect(string $target, int $status = 307): never
{
    // 許可する転送先(サイト内のパスのみ)
    $allowed = ['/mypage', '/order/complete', '/search'];

    if (!in_array($target, $allowed, true)) {
        $target = '/';   // 許可されていなければ、トップへ
    }
    header('Location: ' . $target, true, $status);
    exit;
}

safe_redirect($_GET['redirect'] ?? '/');

上の関数の戻り値の型 never は、PHP 8.1 以降で使えます。それより前のバージョンで使う場合は、戻り値の型の宣言を外してください。関数の中で exit を呼んでいるため、呼び出した側の処理は、この関数の先には進みません。また、転送先を許可リストに照合する処理を一か所にまとめておけば、サイトのあちこちに同じ検証を書き散らす必要がなくなり、検証の漏れも防げます。あわせて、転送が実行された事実を記録しておくと、あとで「なぜこの URL に飛ばされたのか」を調べるときの手がかりになります。

パスの先頭が / で始まるかどうかだけを見る検証は、//evil.example のような書き方をすり抜けるおそれがあるため、十分ではありません。列挙できない場合は、転送先を解析し、ホスト名が自サイトのものと一致することを確かめます。

ケース別の使い分け

場面 書き方
一時的に別の URL へ(メソッドを保つ) header('Location: URL', true, 307);
恒久的に別の URL へ(メソッドを保つ) header('Location: URL', true, 308);
恒久的に別の URL へ(GET のみ) header('Location: URL', true, 301);
POST の後に結果ページを表示 header('Location: URL', true, 303);
一時的に別の URL へ(メソッドは問わない) header('Location: URL');(302)

POST を受ける処理では、番号を指定せずに header('Location: ...') と書くと 302 になり、クライアントによって動作が変わります。307 と POST リクエストで解説したとおり、意図に合わせて番号を選んでください。

動作の確認

PHP を書いたら、実際に応答を確認します。

curl -i https://example.com/old.php
HTTP/2 307
location: https://example.com/new-page

-i は、応答のヘッダーも表示します。番号が 302 で返ってきたら、第 3 引数の指定漏れか、別の場所で Location が上書きされていないかを確認します。転送後の画面にも本文が出力されている場合は、exit を忘れていないかを確認してください。

PHP で 307 を返すための要点

  • header('Location: URL', true, 307); と、第 3 引数で番号を明示します。
  • 番号を省略すると 302 になります。
  • 直後に exit; を付け、後続の処理を止めます。
  • 出力より前に呼びます。エラーが出たら headers_sent() で出力の開始位置を調べます。
  • 外部から受け取った値は、許可リストで検証してから転送先に使います。

サーバー側の設定で転送する方法は、nginxとApacheの解説に、番号の選び方は301・302・303・307・308 の違いにまとめています。