Output Buffering in PHP: ob_start and the Output Buffer Functions Explained
AI generated
8.4
PHP · Output Buffering
Output Buffering in PHP: ob_start Explained
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.

10 min read Output buffer stack Headers after output

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.

11. FAQ: Output Buffering: The Essentials at a Glance

1What happens if ob_start is called twice without an ob_end call in between?
PHP simply opens a second, nested buffer level on the stack. That is not an error, but it increases ob_get_level by one, and both levels must later be closed individually, in reverse order.
2Why does header() sometimes still fail even with ob_start active?
Usually because bytes were already output before the ob_start call, for example due to whitespace before the opening PHP tag in an included file. The buffer only protects output produced after it was opened.
3Is output buffering safe across parallel requests under PHP-FPM?
Yes, because every php-fpm worker process handles requests sequentially and has its own output buffer stack. There is no shared global state between different requests.
4What does PHPUnit use output buffering for internally in tests?
The expectOutputString method and related assertions open a buffer themselves to capture a test's actual output and compare it against the expected value, without that output appearing on the console.
5What is the difference between ob_flush and the global flush function?
ob_flush sends the current output buffer level's content onward, but depending on the SAPI it may still end up in a further, system-level buffer. flush additionally attempts to empty that system-level buffer too, though that is not guaranteed to work under every SAPI.
6Does output buffering noticeably affect performance?
For normal web requests the overhead is negligible, since strings are merely collected in memory instead of being output immediately. With very large output or deeply nested buffers, memory usage increases measurably.
7What happens to an open buffer on a fatal error?
PHP automatically closes any remaining open buffer levels at script end and outputs their content, unless an explicit ob_end_clean happened beforehand. A fatal error does not fundamentally prevent that.
8Should ob_start be called globally in every project's bootstrap as a matter of course?
No, that tends to mask problems rather than solve them, and it delays fixing real bugs such as accidental output before redirects. It is cleaner to fix the actual cause of premature output.
9How does output buffering affect streaming responses such as Server-Sent Events?
An active buffer that is not flushed regularly delays exactly the continuous delivery that streaming requires. For genuine streaming, either avoid output buffering entirely or explicitly clear it with ob_flush and flush after every chunk.
10Is there a practical limit to the number of nested buffer levels?
There is no fixed, documented limit, but every additional level costs memory and bookkeeping overhead. In practice, more than three or four nested levels is almost always a sign of an overly complex architecture.