Заголовки идут до тела ответа

HTTP-ответ состоит из статусной строки, набора заголовков и тела. PHP-код обычно пишет тело обычным выводом: HTML вне <?php ?>, echo, шаблон, readfile(). Заголовки же нужно отправить до первого байта тела. Поэтому header() работает только пока PHP/SAPI ещё может менять HTTP-метаданные.

<?php

declare(strict_types=1);

header('Content-Type: text/html; charset=UTF-8');
http_response_code(200);

echo '<h1>Профиль</h1>';

Если до header() уже был вывод, PHP выдаст типичную проблему «headers already sent». Частые причины: пустая строка перед <?php, BOM в начале файла, случайный echo, пробел после закрывающего ?> в подключённом файле. Поэтому в чистых PHP-файлах закрывающий тег обычно опускают. Это напрямую связано с Синтаксис, теги и подключение файлов: include-файл может сломать заголовки не логикой, а одним лишним символом.

flowchart TD A[PHP-код начинает обработку] --> B{Был ли вывод в body?} B -- Нет --> C[Можно задавать status и headers] C --> D[http_response_code / header] D --> E[Первый echo, шаблон или readfile] E --> F[Headers зафиксированы SAPI] B -- Да --> G[headers already sent] G --> H[Искать файл и строку через headers_sent]
flowchart TD
    A[PHP-код начинает обработку] --> B{Был ли вывод в body?}
    B -- Нет --> C[Можно задавать status и headers]
    C --> D[http_response_code / header]
    D --> E[Первый echo, шаблон или readfile]
    E --> F[Headers зафиксированы SAPI]
    B -- Да --> G[headers already sent]
    G --> H[Искать файл и строку через headers_sent]
Когда PHP ещё может менять HTTP-заголовки, а когда уже поздно.
Быстрое повторение
Почему `header()` в PHP нужно вызывать до любого `echo`, HTML вне `<?php ?>` или `readfile()`?

header() и http_response_code()

header() отправляет сырой HTTP-заголовок:

header('Content-Type: application/json; charset=UTF-8');
header('X-Content-Type-Options: nosniff');

По умолчанию заголовок с тем же именем заменяет предыдущий. Третий аргумент можно использовать для статуса, но в обычном коде понятнее разделять статус и заголовки:

http_response_code(404);
header('Content-Type: application/json; charset=UTF-8');

echo json_encode([
    'error' => 'not_found',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);

Статус-коды группируются по классам: 2xx — успех, 3xx — редиректы, 4xx — ошибка клиента, 5xx — ошибка сервера. В PHP http_response_code() получает или задаёт код ответа; если код не задан в веб-SAPI, практический дефолт — 200. Не стоит одновременно руками отправлять статусную строку через header('HTTP/1.1 404 Not Found') и затем менять статус через http_response_code(403): в разных SAPI и веб-серверах это может дать неочевидный результат.

Для диагностики есть служебные функции:

if (headers_sent($file, $line)) {
    error_log("Headers already sent in $file:$line");
}

foreach (headers_list() as $headerLine) {
    error_log($headerLine);
}

header_remove('X-Debug-Token');

headers_sent() особенно полезна в легаси-коде, где HTML, include-файлы и бизнес-логика перемешаны.

Быстрое повторение
Почему в обычном PHP-коде лучше разделять `http_response_code(404)` и `header('Content-Type: ...')`, а не отправлять статусную строку вручную через `header()`?

Content-Type: не оставляйте браузеру угадывание

Если вы отдаёте HTML, JSON, CSV или файл, явно задавайте Content-Type. Без него браузер, proxy или клиентская библиотека могут попытаться угадать формат. Для пользовательского контента это опасно: файл, который вы считали текстом, может быть интерпретирован как HTML.

header('Content-Type: application/json; charset=UTF-8');
header('X-Content-Type-Options: nosniff');

Для HTML-страниц charset тоже лучше указывать явно:

header('Content-Type: text/html; charset=UTF-8');

Для скачивания файла добавляют Content-Disposition, но имя файла всё равно должно быть безопасно сформировано сервером, а не взято напрямую из внешнего ввода. Это продолжает тему Загрузка файлов: сохранённый upload безопасен только вместе с правильной выдачей.

Быстрое повторение
Зачем для JSON-ответа явно ставить `Content-Type: application/json; charset=UTF-8` и `X-Content-Type-Options: nosniff`?

Редиректы: Location плюс правильный статус

Редирект — это не «вывести другую страницу», а HTTP-ответ с Location. В PHP минимальный вариант выглядит так:

header('Location: /login', true, 302);
exit;

exit здесь не украшение. После Location PHP не прекращает выполнение сам. Без exit код ниже может изменить данные, вывести тело или перезаписать заголовки. Для state-changing форм обычно используют Post/Redirect/Get: обработать POST, сохранить изменения, затем отправить пользователя на GET-страницу результата.

<?php

declare(strict_types=1);

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    header('Allow: POST');
    echo 'Method Not Allowed';
    exit;
}

// Проверить CSRF, провалидировать ввод, сохранить данные.

header('Location: /profile?updated=1', true, 303);
exit;

Почему 303, а не всегда 302? 303 See Other явно говорит клиенту сделать следующий запрос методом GET. Это удобно после успешного POST: обновление страницы не повторит отправку формы. 307 Temporary Redirect и 308 Permanent Redirect сохраняют исходный метод и тело запроса, поэтому подходят для других сценариев, например временного переноса API endpoint без превращения POST в GET. 301 и 308 браузеры и поисковики могут запомнить надолго, так что permanent-редиректы лучше не ставить на временные маршруты.

Сами URL в Location могут быть относительными, но проверяйте open redirect: если параметр ?next=https://evil.example без валидации попадает в Location, логин или платежный flow можно использовать для фишинга.

$next = $_GET['next'] ?? '/dashboard';

$allowed = ['/dashboard', '/settings', '/billing'];
if (!in_array($next, $allowed, true)) {
    $next = '/dashboard';
}

header('Location: ' . $next, true, 303);
exit;

Ввод для такого решения приходит из тех же недоверенных источников, что и данные в GET, POST и фильтрация ввода.

Cache-Control: персональное и публичное кэшируются по-разному

Кэш-заголовки отвечают не только за скорость, но и за приватность. Для статичных ассетов с хэшами в имени файла можно дать долгий публичный кэш:

header('Cache-Control: public, max-age=31536000, immutable');

Для персональной страницы после логина такой заголовок недопустим. Минимальный дефолт для чувствительных ответов:

header('Cache-Control: no-store');

no-store просит кэши не сохранять ответ. no-cache звучит похоже, но означает другое: ответ может быть сохранён, однако перед повторным использованием должен быть перепроверен у origin-сервера. Для страницы с приватными данными это слишком слабый сигнал, особенно если между пользователем и origin есть shared cache. Для обычных HTML-страниц после логина часто используют:

header('Cache-Control: private, no-store');

private говорит, что ответ предназначен для браузерного кэша конкретного пользователя, а не для общего proxy/CDN. Если страница зависит от $_COOKIE или $_SESSION из Куки и сессии, думайте о кэше как о части security-модели, а не как о настройке производительности.

Практический шаблон ответа

В небольшом PHP-приложении полезно завести тонкие функции-обёртки и не размазывать header() по всему коду:

<?php

declare(strict_types=1);

function jsonResponse(array $data, int $status = 200): never
{
    http_response_code($status);
    header('Content-Type: application/json; charset=UTF-8');
    header('X-Content-Type-Options: nosniff');

    echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
    exit;
}

function redirectTo(string $path, int $status = 303): never
{
    if (!str_starts_with($path, '/')) {
        $path = '/';
    }

    header('Location: ' . $path, true, $status);
    exit;
}

jsonResponse(['ok' => true]);

Это ещё не PSR-7, middleware и HTTP-клиенты, где response — объект, а не немедленная отправка заголовков. Но даже такой слой уже изолирует SAPI-детали из SAPI и суперглобалы: приложение принимает решение «404 JSON» или «303 redirect», а низкоуровневый код единообразно превращает его в HTTP-ответ.

См. также

Источники

  1. PHP Manual: header
  2. PHP Manual: http_response_code
  3. PHP Manual: headers_sent
  4. PHP Manual: header_remove
  5. MDN: Cache-Control header
  6. MDN: HTTP response status codes
  7. MDN: Location header