← Все статьи

SiteGuard12 мин чтения

Headers already sent в PHP: почему не работают cookies и перенаправления

После сохранения настроек страница должна перенаправить пользователя, но остаётся на месте. Вход в кабинет неожиданно перестаёт сохраняться, а в журнале появляется Cannot modify header information. Частая причина — приложение уже начало отдавать содержимое ответа и только затем попыталось установить cookie или отправить заголовок перенаправления.

Чтобы исправить headers already sent в PHP, найдите первый вывод и проследите, почему он появился до вызова header(). Иногда виноват один невидимый символ в подключённом файле, иногда — порядок работы целого обработчика. Ниже приведён путь диагностики: найти момент начала ответа, устранить источник вывода и проверить результат в реальном HTTP-сценарии. Все пути, имена файлов и примеры условные.

Почему после начала ответа нельзя добавить заголовок

Заголовки сообщают клиенту статус, тип содержимого, cookie и адрес перехода. Тело ответа содержит HTML, JSON или другие данные. Приложение должно закончить формирование необходимых заголовков до их отправки. Если эта граница уже пройдена, последующий вызов не может вернуться назад и дополнить отправленный блок.

Документация header() прямо предупреждает о выводе HTML, пустых строк и данных из подключаемых файлов до установки заголовков. Для проблемы достаточно незаметного вывода: пользователь не обязан увидеть целую страницу или даже отдельный символ.

При этом наличие echo выше по коду ещё не доказывает, что отправка уже произошла. Вывод мог задержаться в буфере. Поэтому сравнивайте фактическое состояние ответа и настройки среды, а не только номера строк.

Для бизнеса последствия различаются. Настройки могли сохраниться, но переход не сработал. Cookie могла не установиться, хотя форма показала сообщение об успехе. Исправление должно подтвердить нужный результат операции, а не просто отсутствие предупреждения на экране. Повторное нажатие кнопки без проверки состояния может создать отдельную проблему дублей.

В сообщении об ошибке найдите два разных места

Полезное предупреждение содержит место, где начался вывод, и место, где приложение позднее попыталось изменить заголовки. Второе обычно привлекает внимание первым, хотя причина находится раньше. Сохраните обе координаты и версию приложения, на которой воспроизведён сбой.

Учебная запись для разбора:

Cannot modify header information - headers already sent
output started at /app/config.php:1
попытка header(): /app/preferences.php, строка 42

Здесь строка 42 показывает место проявления ошибки. Первой проверяют строку 1 файла конфигурации: кодировку, символы перед открывающим тегом и то, почему конфигурация вообще производит вывод. Переносить вызов перенаправления на строку выше без такого разбора недостаточно.

НаблюдениеЧто проверить первым
Вывод начинается на первой строкеBOM, пробелы до PHP, автоматически добавленный текст
Указана строка шаблонаНе начался ли рендеринг до решения о переходе
Перед ошибкой есть другое предупреждениеЕго причину и вывод диагностического текста в ответ
Сбой появляется только в одном сценарииУсловную ветку, состав подключений и объём вывода

В отчёт включите маршрут и время запроса. Номер строки из другой версии файла может привести к неверному исправлению, особенно если между сбоем и диагностикой состоялся релиз.

Используйте headers_sent для поиска первого вывода

Функция headers_sent() сообщает, отправлены ли заголовки. Через аргументы она возвращает файл и строку начала вывода. Если вывод появился ещё до выполнения исходного PHP-файла, например при ошибке запуска, имя файла может оказаться пустым.

Ограниченный диагностический фрагмент размещают перед проблемной операцией и направляют сведения во внутренний журнал:

if (headers_sent($sourceFile, $sourceLine)) {
    error_log(sprintf(
        'response_started source=%s line=%d',
        $sourceFile,
        $sourceLine
    ));
}

Здесь нет печати в ответ, которая сама могла бы создать новую ошибку. Журнал должен быть доступен команде, а не посетителям. Не добавляйте к записи содержимое cookie, пароли и полное тело формы.

Если функция возвращает true, проблема уже произошла. Проверка не восстанавливает возможность перенаправить клиента. Если возвращает false, она подтверждает состояние только в этот момент; следующий вызов шаблона или сброс буфера может пересечь границу отправки.

Небольшое число таких контрольных точек до и после подозрительного подключения помогает сузить участок. Не превращайте диагностику в постоянную печать каждого шага и не используйте условие «если заголовки отправлены — просто пропустить cookie» как окончательное исправление бизнес-сценария.

Проверьте BOM и символы вне PHP

У файла может быть невидимое начало. В справке Unicode о BOM сигнатура UTF-8 представлена байтами EF BB BF. Если такие байты оказываются перед открывающим PHP-тегом в обычном подключаемом файле, они могут попасть в вывод. Визуально редактор при этом показывает привычную первую строку.

Проверьте кодировку конкретного файла и первые байты на тестовой копии. Если причина подтверждена, сохраните его в UTF-8 без BOM и убедитесь, что текст не изменился. Не заменяйте кодировку всего проекта массово: задача состоит в удалении ненужного вывода, а не в случайном преобразовании данных.

Аналогично проверяют пробелы перед <?php и текст после ?>. Для файлов, содержащих только PHP-код, руководство по PHP-тегам рекомендует опускать завершающий тег, чтобы избежать случайного вывода после него.

Не удаляйте все пробелы внутри программы. Отступы в PHP-коде не равны выводу пробелов в ответ. Исправление должно касаться установленного источника: например, BOM конфигурации или нескольких символов за пределами PHP-блока. Затем проверьте собранный файл в релизе, а не только локальную копию редактора.

Проследите include, шаблоны и отладочные сообщения

Главный обработчик может выглядеть правильным: сначала подключение библиотек, затем установка cookie, затем HTML. Но одно подключение печатает отладочный массив, другое подключает общий шаблон, а третий файл содержит текст за пределами PHP. Поэтому ищите вывод по всей выполненной цепочке, а не только в основном скрипте.

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

В условном примере общий шаблон подключили в начале страницы настроек. После этого обработчик проверял форму и при успехе пытался перейти к результату. Правильное изменение — сначала определить исход обработки, затем сформировать перенаправление либо обычную страницу. Перенос только header() выше проверки данных способен отправить ложное сообщение об успехе.

В проекте с фреймворком придерживайтесь его способа создания ответа. Если часть кода возвращает объект ответа, а другая напрямую печатает HTML, ответственность за отправку становится неясной. Назначьте один участок, который завершает подготовку статуса, заголовков и тела.

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

Устраните предупреждение, которое стало выводом

Первым выводом может оказаться сообщение о неопределённой переменной, неудачном подключении файла или другой ошибке. Тогда headers already sent — последствие более раннего сбоя. Начинайте чтение журнала с первой причины в пределах запроса, а не с последней строки.

Документация обработки ошибок PHP рекомендует для рабочего сайта журналирование вместо показа ошибок посетителю. Настройка display_errors управляет отображением, а log_errors — записью сообщений. Эти задачи следует проверять отдельно.

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

Проверяйте действующую конфигурацию веб-процесса. Командная строка и сайт могут использовать разные окружения. Ошибки, возникшие до выполнения кода настройки, нельзя надёжно исправить строкой, до которой выполнение не дошло.

Для обращения к подрядчику достаточно времени, маршрута, версии приложения, безопасного идентификатора запроса и очищенного стека. Полные заголовки авторизации и значения сессионных cookie для поиска первого вывода обычно не нужны.

Почему буферизация иногда скрывает проблему

Функция ob_start() включает буферизацию вывода. Пока данные остаются в соответствующем буфере, они не уходят дальше как обычный вывод скрипта. У буферов могут быть вложенные уровни и условия сброса; параметр размера позволяет отправлять накопленное после достижения заданного объёма.

Из-за этого один и тот же порядок кода может работать на небольшой странице и ломаться на более крупной или в другом окружении. Например, сначала печатается фрагмент шаблона, затем вызывается перенаправление. В одном случае данные ещё удерживаются, в другом — уже переданы. Отсутствие предупреждения в первом случае не делает порядок надёжным.

В диагностике зафиксируйте настройки буферизации, активные уровни и места явного сброса. Не вызывайте сброс специально в рабочем пользовательском запросе, чтобы «проверить»: это меняет поведение ответа. Воспроизводите такие различия на тестовом маршруте.

Условный опыт: приложение использует единственный буфер с порогом 4096 байт и не сбрасывает его явно. Вывод блока в 3000 байт не достигает порога, а блока в 5000 байт — достигает. Поэтому тест только на коротком сообщении может пропустить ошибку порядка. Эти числа описывают выбранные условия опыта, а не стандарт любого PHP-сервера; при вложенных буферах передача из одного уровня ещё не обязательно означает отправку клиенту.

Буфер может быть осознанной частью рендеринга, но обёртка ob_start() вокруг всего приложения не заменяет разделение логики и вывода. Она также не возвращает уже отправленные заголовки. Решение должно объяснять, кто владеет буфером, когда он завершается и что происходит при ошибке, иначе следующий шаблон снова изменит момент отправки.

Отправляйте перенаправление после решения, но до тела

Для учебного обработчика настроек порядок такой: проверить запрос, сохранить допустимые изменения, подтвердить результат, сформировать переход. Если сохранение не удалось, нельзя отправлять пользователя на страницу успешного завершения. Ветви успеха и ошибки проектируют до рендеринга HTML.

После успешного POST может использоваться следующий фрагмент:

header('Location: /account/preferences?saved=1', true, 303);
exit;

Статус 303 See Other описан в HTTP Semantics как переход к отдельному ресурсу, который можно получить запросом чтения. Он подходит для выбранного примера, но не является универсальным статусом для каждого перенаправления.

exit прекращает дальнейшее выполнение обработчика. Сам вызов header() не означает, что расположенный ниже код перестал работать. Поэтому после перехода не должен случайно выполняться повторный шаг сохранения или вывод другого шаблона.

Адрес в примере фиксирован и принадлежит приложению. Если целевой путь зависит от входных данных, его проверяют по правилам проекта; произвольный адрес от пользователя не следует напрямую переносить в заголовок. В браузере оценивайте исходный ответ с перенаправлением отдельно от итоговой страницы, которую клиент открыл автоматически.

Что на самом деле подтверждает setcookie

Cookie передаётся клиенту через заголовок Set-Cookie, поэтому ранний вывод мешает и её установке. Согласно описанию setcookie(), успешный возврат функции не подтверждает, что пользовательский агент принял cookie. Это результат формирования заголовка на стороне PHP, а не подтверждение последующего поведения браузера.

Разделяйте три этапа: приложение подготовило cookie, клиент получил соответствующий заголовок, затем клиент отправил cookie в подходящем следующем запросе. Ошибка на каждом этапе требует своего объяснения. Ожидать новое значение в исходном массиве $_COOKIE того же запроса неправильно: он представляет данные, с которыми запрос уже пришёл.

Для проверки используйте безопасную тестовую настройку, например режим отображения, без реального сессионного секрета. Запишите только имя cookie и результат вызова. Затем посмотрите сетевой ответ, состояние хранилища браузера и следующий подходящий запрос.

Если в журнале есть предупреждение об отправленных заголовках, сначала устраните преждевременный вывод. Если предупреждения нет и заголовок доходит, переходите к атрибутам cookie и политике клиента. Добавление буферизации не исправит неверный домен или неподходящий контекст отправки.

Отделите отказ браузера от ошибки PHP

Документация Set-Cookie описывает ограничения Domain, Path, Secure и SameSite. Например, cookie для чужого домена не принимается, а SameSite=None требует Secure. Атрибут Secure связан с HTTPS; отдельные исключения для localhost не следует переносить на рабочий домен.

Что наблюдаетсяНаправление проверки
Set-Cookie отсутствует и есть PHP warningПервый вывод и порядок формирования ответа
Set-Cookie пришёл, браузер сообщил об отклоненииУказанную клиентом причину и атрибуты
Cookie сохранена, но не отправлена запросомДомен, путь, схему и контекст SameSite
Заголовок не читается через JavaScriptОграничения доступа к Set-Cookie, а не только наличие заголовка

Для анализа используйте сетевую панель и сообщения браузера. Отсутствие Set-Cookie в доступных JavaScript заголовках не доказывает его отсутствие в HTTP-ответе: этот заголовок скрыт от клиентского кода соответствующими правилами.

Не расширяйте Domain и не ослабляйте SameSite наугад. Сначала определите, какой запрос должен получить cookie и почему. Изменение атрибутов должно соответствовать сценарию приложения, а не только делать один эксперимент успешным.

Сопоставьте состояние PHP с фактическим HTTP-ответом

Функция headers_list() возвращает список заголовков, подготовленных или отправленных PHP. Чтобы установить факт отправки, её сопоставляют с headers_sent(). Обе возможности зависят от SAPI, поддерживающего HTTP-заголовки; вывод обычного консольного запуска не заменяет проверку веб-запроса.

Не выводите весь список через var_dump перед перенаправлением. Это может создать ровно ту проблему, которую вы исследуете. Для внутреннего журнала выбирайте нужные имена и безопасные признаки, исключая значения cookie и другие секреты.

Затем сравните данные PHP с ответом, который получил браузер. Между приложением и клиентом может находиться веб-сервер или прокси. Важен итоговый статус, наличие ожидаемого Location или Set-Cookie и отсутствие лишнего тела перед нормальным содержимым.

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

Если браузер автоматически прошёл перенаправление, сохраните в сетевой панели оба запроса. Успешный итоговый ответ не объясняет, с каким статусом и заголовками завершился исходный обработчик.

Как убедиться, что причина устранена

Проверяйте исправление на том же маршруте и типе запроса, где возникал сбой. Успешное открытие главной страницы не подтверждает работу установки cookie после POST. Используйте тестовые данные и отдельно воспроизведите ветви успешного сохранения, ошибки валидации и отказа зависимости.

  • До формирования нужных заголовков нет случайного вывода из конфигурации и подключений.
  • Первичное предупреждение устранено, а ошибки рабочего окружения доступны в журнале.
  • Перенаправление имеет выбранный статус и правильный адрес; код после завершения ответа не выполняет лишние действия.
  • Cookie проходит проверку формирования, приёма и отправки в следующем подходящем запросе.
  • Поведение проверено с реальными настройками буферизации и объёмом ответа.
  • Исправление присутствует в релизном файле, а не только в редакторе разработчика.

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

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

Для внешнего контроля HTTP-доступности сайта подключите SiteGuard. Доставку конкретных cookie и корректность перенаправлений в пользовательском сценарии команда проверяет отдельно, сопоставляя HTTP-ответы с журналами PHP.