The situation
It's 2 AM. A redirect isn't working. Users hit a blank page instead of the dashboard. The logs show this:
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
That message tells you almost everything. PHP tried to call header() โ or setcookie(), or session_start() โ but something already pushed bytes to the browser. Once output is sent, HTTP headers are locked. Done.
What triggers this
The cause is always the same: something produced output before your header call. The tricky part is that "output" can be almost invisible.
Common culprits:
- A space or newline before the opening
<?phptag - A UTF-8 BOM at the start of a file (invisible in most editors โ 3 silent bytes:
EF BB BF) - An
echoorprintcalled beforeheader() - HTML outside PHP tags printed before a redirect
- A
session_start()called after output has already started - An
includeorrequirethat silently outputs something
Debug process
Step 1: Read the error message carefully
PHP tells you exactly where output started. Focus on this part:
(output started at /var/www/html/includes/config.php:1)
Go to that file, that line. That's your crime scene.
Step 2: Check for whitespace before <?php
Open the file in a hex editor, or run:
cat -A /var/www/html/includes/config.php | head -5
See ^M or a $ on a line before <?php? Any character at all before the tag? That's your problem. One stray space is enough to break everything.
Step 3: Check for UTF-8 BOM
Run:
file /var/www/html/includes/config.php
If the output says UTF-8 Unicode (with BOM), that's it. Those three invisible bytes get sent as output before PHP even starts.
Strip them with:
sed -i '1s/^\xEF\xBB\xBF//' /var/www/html/includes/config.php
Or open in VS Code โ bottom-right corner โ and switch encoding from UTF-8 with BOM to plain UTF-8.
Step 4: Hunt for premature output in code
Search for any echo, print, or raw HTML appearing before your header calls:
grep -n 'echo\|print\|?>' /var/www/html/login.php
This is the classic pattern that breaks everything:
<!-- bad: HTML before redirect -->
<html>
<head><title>Login</title></head>
<?php
session_start(); // TOO LATE โ HTML already sent
header('Location: /dashboard.php'); // This will fail
?>
Solutions
Fix 1: Move all headers before any output
Simple rule: header(), setcookie(), and session_start() must come before anything that produces output โ including a single blank line.
<?php
// CORRECT: session and headers first, nothing before this
session_start();
if (!isset($_SESSION['user'])) {
header('Location: /login.php');
exit(); // Always exit after a redirect
}
?>
<!DOCTYPE html>
<html>
...
Fix 2: Use output buffering
Restructuring the file at 2 AM feels risky. Output buffering buys you time:
<?php
ob_start(); // Hold all output in memory
// echo/print no longer immediately sends headers
echo 'something';
// This works now
header('Location: /dashboard.php');
ob_end_flush();
exit();
?>
ob_start() holds output in memory instead of flushing it to the browser. Headers stay modifiable until the buffer is flushed. It's not a permanent fix โ but it's a solid stopgap.
You can also enable output buffering globally in php.ini:
output_buffering = 4096
Then restart your PHP process:
sudo systemctl restart php8.2-fpm
# or
sudo systemctl restart apache2
Fix 3: Drop the closing PHP tag
Any trailing newline after a closing ?> counts as output. The fix: remove it. The closing tag is optional in PHP โ and for pure PHP files, omitting it is the recommended practice:
<?php
// Pure PHP file โ no closing ?> needed
define('DB_HOST', 'localhost');
define('DB_NAME', 'myapp');
// End of file โ no ?>
Fix 4: Find BOMs hiding in included files
The BOM might not be in the main file at all. Check every included file:
find /var/www/html -name '*.php' | xargs grep -l $'\xef\xbb\xbf'
Strip the BOM from all PHP files in one shot:
find /var/www/html -name '*.php' -exec sed -i '1s/^\xEF\xBB\xBF//' {} \;
Verify the fix
Test the broken flow directly. For a redirect, check the response headers with curl:
curl -I http://yoursite.com/login.php
A working redirect looks like this:
HTTP/1.1 302 Found
Location: /dashboard.php
Still seeing 200 OK with no Location header? Output is still leaking somewhere.
Temporarily enable full error reporting to expose every warning:
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
PHP will tell you the exact file and line where the rogue output originates. Find it, kill it, done.
Lessons learned
- Put
session_start()andheader()at the absolute top โ before any HTML, any echo, any whitespace. - Drop the closing
?>tag from PHP-only files. It's a whitespace trap waiting to fire. - Configure your editor to save as UTF-8 without BOM. UTF-8 BOM is invisible, silent, and will cost you an hour of debugging.
- Use
ob_start()at your app's entry point as a safety net during development โ not as a permanent architectural decision. - When an include file is blamed in the error, remember: even one blank line outside a PHP tag in that file is enough to trigger this.

