How the output buffer actually works internally, and why it does more than just rescue headers
Most PHP developers only know ob_start as a workaround for the 'headers already sent' error, yet the output buffer is a full mechanism with its own stack, its own callback phases, and its own pitfalls. Understanding how PHP inserts a buffering layer between script output and the actual SAPI output lets you write template engines, wire up compression cleanly, and avoid the classic memory leaks in long-running CLI workers.
Table of Contents
- 1. How the output buffer works internally
- 2. The callback parameter of ob_start and its quirks
- 3. Nested buffer levels and ob_get_level
- 4. Practical example: template capturing without return values
- 5. Setting headers after content has already been output
- 6. Compression with ob_gzhandler and zlib.output_compression
- 7. The function family at a glance: get, clean, flush and end
- 8. Common pitfalls: open buffers, CLI workers, and exceptions
- 9. When output buffering makes sense, and when return values are the better choice
- 10. Summary
- 11. FAQ
1. How the output buffer works internally
Without output buffering, PHP forwards every byte produced by echo, print, or printf straight to the SAPI output layer, meaning the built-in web server, php-fpm, or the CLI console. That output reaches the client immediately, before the script has even finished running, and it can no longer be taken back afterward. Calling ob_start inserts an additional layer in between: from that point on, an internal memory buffer collects all output instead of forwarding it directly, until the buffer is explicitly flushed or discarded.
Internally, PHP manages these buffers as a stack, not as a single global state. Every call to ob_start pushes a new level onto that stack, and functions such as ob_get_contents or ob_end_flush always operate only on the topmost, currently active level. That is the key difference from a plain global variable: library code can open its own buffer without needing to know whether an outer buffer is already active, and without accidentally affecting it, as long as it sticks to its own level and closes it cleanly.
2. The callback parameter of ob_start and its quirks
The first parameter of ob_start accepts a callable that gets invoked whenever the buffer is flushed or closed. That callback receives the collected content as a string plus a bitmask flag indicating which phase the call is happening in, namely start, an intermediate flush, or the final close, and it must itself return a string, which is then actually output. That lets you transform any output after the fact, for example to minify HTML or replace placeholders, without touching the actual rendering code.
The second parameter, historically called chunk_size, has become largely meaningless in modern PHP versions: it used to trigger an automatic intermediate flush once a certain buffer size was reached, whereas today that behavior is handled differently internally, and the parameter mostly remains for backward compatibility. If you rely on a specific chunking behavior to force streaming output, you should instead work explicitly with ob_flush and flush rather than depend on an implicit threshold.
<?php
declare(strict_types=1);
/**
* Minifies HTML by stripping unnecessary whitespace between tags
* before the content is actually delivered to the client.
*
* @param string $buffer The output buffer content collected so far
* @param int $phase Bitmask describing the current callback phase
* @return string The transformed content that is actually output
*/
function minifyHtmlBuffer(string $buffer, int $phase): string
{
return (string) preg_replace('/>\s+</', '><', trim($buffer));
}
ob_start('minifyHtmlBuffer');
echo "<html>\n <body>\n <p>Hello world</p>\n </body>\n</html>";
ob_end_flush();
3. Nested buffer levels and ob_get_level
Because the output buffer is organized as a stack, multiple ob_start calls can be nested, and each one opens its own, independent level. The function ob_get_level returns the current stack depth as an integer, making it the most important tool for defensively checking, inside library code, how many levels are already open before opening yet another one or accidentally closing someone else's. A value of zero means no buffer is currently active at all.
In practice, nested buffers are used to isolate a sub-region within an already buffered context, such as a layout template, capture it, transform it, and only then write it back into the outer buffer. Closing order matters here: levels must be closed strictly in the reverse order they were opened, because ob_end_flush and ob_end_clean always act only on the topmost level and would otherwise close the wrong buffer.
<?php
declare(strict_types=1);
$baseLevel = ob_get_level();
ob_start();
echo "<header>Site</header>";
// Inner buffer for a single widget area.
ob_start();
echo "<aside>Widget</aside>";
$widget = ob_get_clean();
echo strtoupper($widget);
$page = ob_get_clean();
assert(ob_get_level() === $baseLevel);
echo $page;
4. Practical example: template capturing without return values
Many older template files are written to echo their content directly instead of returning a string, often because they were originally intended for a synchronous response body only. If such a template suddenly needs to end up in an email, a cache entry, or an API response, there is no way around output buffering, since retrofitting every single template file to return a string would touch far too much code.
The solution is a small wrapper that includes the template file in an isolated scope, opens the output buffer, lets the file execute, and then returns the collected content as a string instead of outputting it directly. That turns a pure echo side effect into a pure function with a return value after the fact, without modifying the template file itself, and the caller is free to output the string directly, cache it, or process it further.
<?php
declare(strict_types=1);
/**
* Includes a template file in an isolated scope and captures its
* direct output as a string via output buffering.
*
* @param string $templatePath Absolute path to the template file
* @param array<string, mixed> $variables Variables passed into the template
* @return string The captured template content as a string
*/
function captureTemplate(string $templatePath, array $variables = []): string
{
extract($variables, EXTR_SKIP);
ob_start();
require $templatePath;
return ob_get_clean() ?: '';
}
$html = captureTemplate(__DIR__ . '/templates/invoice.phtml', ['orderId' => 4711]);
5. Setting headers after content has already been output
The classic use case that made output buffering famous in the first place is the 'Cannot modify header information, headers already sent' error. It occurs as soon as any byte, whether a visible character, a stray whitespace before the opening PHP tag, or a warning, has already reached the client, because HTTP headers must strictly precede the actual body and cannot be sent afterward.
With an active output buffer, no byte leaves the server before the buffer is explicitly flushed, so a header call still works mid-script even if echo calls happened earlier. That is convenient, but it should not be treated as a substitute for clean architecture: a program that wants to set headers only after content has already been output has usually failed to separate business logic from the output layer, and ob_start here only fixes the symptom, not the underlying cause.
6. Compression with ob_gzhandler and zlib.output_compression
The function ob_gzhandler is a ready-made callback that works exactly like a hand-written callback function, except that it automatically gzip-compresses the collected buffer content, provided the client signals support for it via the Accept-Encoding header. You simply pass it as the first parameter to ob_start, and PHP takes care of setting the appropriate Content-Encoding header and compressing the buffer on flush internally.
In practice, ob_gzhandler frequently collides with the PHP ini setting zlib.output_compression, which already performs the same job at a lower level when enabled, and having both mechanisms active at the same time leads either to double compression or to an error when setting the header. On modern deployments where a reverse proxy such as nginx already sits in front of php-fpm anyway, it is almost always the better choice to configure compression centrally there and leave both PHP-side mechanisms disabled, since a proxy compresses more efficiently and independently of the PHP process lifetime.
7. The function family at a glance: get, clean, flush and end
The names of the output buffer functions follow a clear pattern that breaks down along two axes: first, whether the buffer content is read (get) or actually sent to the client (flush), and second, whether the current buffer level is closed (clean or end) or stays open. ob_get_contents reads the content without emptying or closing the buffer, whereas ob_get_clean returns the same content but fully resets the buffer afterward and closes the level.
ob_flush actually sends the content collected so far to the client but reopens the buffer for further output, whereas ob_end_flush performs the same send operation and then closes the level for good. If you want to discard the content instead of sending it, you reach for ob_clean for the open variant or ob_end_clean if the level should also be closed. This matrix of four combinations covers virtually every conceivable use case once you think of it as a system rather than a set of individual functions.
8. Common pitfalls: open buffers, CLI workers, and exceptions
The most common mistake is a forgotten ob_end_flush or ob_get_clean: if a buffer stays open, the collected output goes nowhere, and the script appears silent even though echo was clearly called somewhere else. This becomes particularly tricky in PHPUnit tests, which themselves use output buffering internally to implement expectOutputString: an improperly closed buffer left over from test code can produce confusing errors there that seem at first glance to have nothing to do with output buffering.
In long-running CLI workers, such as a message consumer processing messages in an infinite loop, forgotten ob_start calls accumulate over time into a measurable memory leak, since every open level keeps its own buffer in memory. If an exception is additionally thrown inside a buffered section, the regular control flow skips any subsequent ob_end_flush unless it sits inside a finally block, leaving the buffer level open and adding up further with every failed iteration.
<?php
declare(strict_types=1);
/**
* Processes a single message and ensures an open output buffer is
* reliably closed even when an exception is thrown.
*
* @param string $payload The message payload to process
* @return string The captured output of the processing step
* @throws RuntimeException When processing fails
*/
function processMessage(string $payload): string
{
ob_start();
try {
handleLegacyOutput($payload);
return ob_get_clean() ?: '';
} catch (RuntimeException $exception) {
// Reliably close the buffer level even in the error case.
ob_end_clean();
throw $exception;
}
}
9. When output buffering makes sense, and when return values are the better choice
Newly written code should avoid output buffering wherever possible and instead design functions and methods to return their content as a string rather than output it directly. Modern template engines such as Twig follow exactly this principle, and a caller receiving a return value can freely process, cache, or test it without depending on an implicit global state like the output buffer.
Output buffering nonetheless remains a legitimate tool for three specific cases: interoperability with legacy code that cannot be rewritten without significant effort, capturing output from functions you have no control over, such as var_dump or phpinfo, and the late header handling described in the previous section. Keeping those three cases clearly separate from ordinary output logic means using the output buffer deliberately instead of letting it accidentally become the default architecture.
| Function | Effect on the buffer | Closes the level? | Typical use |
|---|---|---|---|
| ob_start() | Opens a new buffer level | No, opens a new one | Start of capturing |
| ob_get_contents() | Reads content, buffer stays intact | No | Checking intermediate state |
| ob_get_clean() | Reads content and clears the buffer | Yes | Template capturing |
| ob_flush() | Sends the content to the client | No | Streaming intermediate state |
| ob_end_flush() | Sends the content and ends the level | Yes | Regular completion |
| ob_end_clean() | Discards the content and ends the level | Yes | Error case, rollback |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
Output Buffering: The Essentials at a Glance
Buffer as a stack
Every ob_start call opens its own level, which must be closed independently of any outer level.
Callback on flush
The callback parameter transforms the entire buffer content once, on flush or close.
Header rescue
As long as the buffer is open, header() can still be called successfully even after a prior echo.
Cleanup is mandatory
Every open level must be closed reliably, in a finally block if necessary, to avoid memory leaks.