Tình huống
2 giờ sáng. Một redirect không hoạt động. Người dùng thấy trang trắng thay vì dashboard. Log hiển thị:
Warning: Cannot modify header information - headers already sent by (output started at /var/www/html/includes/config.php:1) in /var/www/html/login.php on line 45
Thông báo đó nói lên gần như tất cả. PHP đã cố gọi header() — hoặc setcookie(), hoặc session_start() — nhưng có gì đó đã đẩy dữ liệu ra trình duyệt trước đó. Một khi output đã được gửi, HTTP header bị khóa lại. Xong.
Nguyên nhân gây ra lỗi
Nguyên nhân luôn giống nhau: có thứ gì đó tạo ra output trước khi bạn gọi header. Phần khó là "output" đó có thể gần như vô hình.
Các thủ phạm thường gặp:
- Một dấu cách hoặc xuống dòng trước thẻ mở
<?php - UTF-8 BOM ở đầu file (vô hình trong hầu hết các editor — 3 byte âm thầm:
EF BB BF) - Một lệnh
echohoặcprintđược gọi trướcheader() - HTML ngoài thẻ PHP được in ra trước khi redirect
- Gọi
session_start()sau khi output đã bắt đầu - Một lệnh
includehoặcrequireâm thầm xuất ra nội dung nào đó
Quy trình debug
Bước 1: Đọc kỹ thông báo lỗi
PHP cho bạn biết chính xác nơi output bắt đầu. Tập trung vào phần này:
(output started at /var/www/html/includes/config.php:1)
Mở file đó, đến dòng đó. Đó là hiện trường vụ án.
Bước 2: Kiểm tra khoảng trắng trước <?php
Mở file bằng hex editor, hoặc chạy lệnh:
cat -A /var/www/html/includes/config.php | head -5
Thấy ^M hoặc dấu $ trên một dòng trước <?php? Bất kỳ ký tự nào trước thẻ? Đó là vấn đề của bạn. Chỉ một dấu cách lạc lõng là đủ để phá hỏng mọi thứ.
Bước 3: Kiểm tra UTF-8 BOM
Chạy lệnh:
file /var/www/html/includes/config.php
Nếu kết quả hiển thị UTF-8 Unicode (with BOM), đó chính là thủ phạm. Ba byte vô hình đó được gửi như output trước khi PHP thậm chí bắt đầu chạy.
Xóa chúng bằng lệnh:
sed -i '1s/^\xEF\xBB\xBF//' /var/www/html/includes/config.php
Hoặc mở trong VS Code — góc dưới bên phải — và chuyển encoding từ UTF-8 with BOM sang UTF-8 thông thường.
Bước 4: Tìm output xuất hiện sớm trong code
Tìm kiếm bất kỳ lệnh echo, print, hoặc HTML thuần xuất hiện trước các lệnh gọi header:
grep -n 'echo\|print\|?>' /var/www/html/login.php
Đây là mẫu code kinh điển phá hỏng mọi thứ:
<!-- sai: HTML trước redirect -->
<html>
<head><title>Login</title></head>
<?php
session_start(); // QUÁ MUỘN — HTML đã được gửi rồi
header('Location: /dashboard.php'); // Lệnh này sẽ thất bại
?>
Giải pháp
Cách 1: Chuyển tất cả header lên trước mọi output
Quy tắc đơn giản: header(), setcookie(), và session_start() phải đến trước bất cứ thứ gì tạo ra output — kể cả một dòng trống.
<?php
// ĐÚNG: session và header đặt đầu tiên, không có gì trước dòng này
session_start();
if (!isset($_SESSION['user'])) {
header('Location: /login.php');
exit(); // Luôn exit sau khi redirect
}
?>
<!DOCTYPE html>
<html>
...
Cách 2: Dùng output buffering
Tái cấu trúc file lúc 2 giờ sáng cảm giác khá rủi ro. Output buffering giúp bạn có thêm thời gian:
<?php
ob_start(); // Giữ toàn bộ output trong bộ nhớ
// echo/print không còn gửi header ngay lập tức nữa
echo 'something';
// Lệnh này hoạt động được rồi
header('Location: /dashboard.php');
ob_end_flush();
exit();
?>
ob_start() giữ output trong bộ nhớ thay vì đẩy ngay ra trình duyệt. Header vẫn có thể chỉnh sửa cho đến khi buffer được xả ra. Đây không phải giải pháp lâu dài — nhưng là biện pháp tình thế hiệu quả.
Bạn cũng có thể bật output buffering toàn cục trong php.ini:
output_buffering = 4096
Sau đó khởi động lại tiến trình PHP:
sudo systemctl restart php8.2-fpm
# hoặc
sudo systemctl restart apache2
Cách 3: Bỏ thẻ đóng PHP
Bất kỳ ký tự xuống dòng nào sau thẻ đóng ?> đều được tính là output. Cách khắc phục: xóa nó đi. Thẻ đóng là tùy chọn trong PHP — và với các file PHP thuần, bỏ thẻ đóng là thực hành được khuyến nghị:
<?php
// File PHP thuần — không cần thẻ đóng ?>
define('DB_HOST', 'localhost');
define('DB_NAME', 'myapp');
// Kết thúc file — không có ?>
Cách 4: Tìm BOM ẩn trong các file được include
BOM có thể không nằm trong file chính. Hãy kiểm tra tất cả các file được include:
find /var/www/html -name '*.php' | xargs grep -l $'\xef\xbb\xbf'
Xóa BOM khỏi tất cả file PHP trong một lần:
find /var/www/html -name '*.php' -exec sed -i '1s/^\xEF\xBB\xBF//' {} \;
Xác nhận đã sửa xong
Kiểm tra trực tiếp luồng bị lỗi. Với redirect, kiểm tra response header bằng curl:
curl -I http://yoursite.com/login.php
Một redirect hoạt động đúng trông như thế này:
HTTP/1.1 302 Found
Location: /dashboard.php
Vẫn thấy 200 OK mà không có header Location? Output vẫn đang rò rỉ ở đâu đó.
Tạm thời bật báo cáo lỗi đầy đủ để phát hiện mọi cảnh báo:
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
PHP sẽ cho bạn biết chính xác file và dòng nào là nguồn gốc của output ngoài ý muốn. Tìm ra nó, loại bỏ nó, xong.
Bài học rút ra
- Đặt
session_start()vàheader()ở vị trí tuyệt đối đầu tiên — trước bất kỳ HTML, echo, hay khoảng trắng nào. - Bỏ thẻ đóng
?>trong các file PHP thuần. Đó là cái bẫy khoảng trắng chực chờ kích hoạt. - Cấu hình editor lưu file dưới dạng UTF-8 không có BOM. UTF-8 BOM vô hình, âm thầm, và có thể ngốn của bạn cả tiếng đồng hồ debug.
- Dùng
ob_start()ở điểm vào của ứng dụng như một mạng lưới an toàn trong quá trình phát triển — không phải quyết định kiến trúc lâu dài. - Khi một file include bị chỉ ra trong lỗi, hãy nhớ: ngay cả một dòng trống bên ngoài thẻ PHP trong file đó là đủ để kích hoạt lỗi này.

