Add a new static analysis check to Plugin Check that detects calls to ob_start() that are not paired with a corresponding buffer-closing function (ob_get_clean(), ob_end_clean(), ob_get_flush(), ob_end_flush(), etc.) within the same logical scope.
This check is inspired by the existing ob_start check in the Plugin Team's Internal Scanner, which has been catching this issue across plugin submissions for a while.
Background
Using ob_start() without explicitly closing the buffer within the same logical flow can lead to unpredictable behaviour.
Output buffering is a valid technique, but a buffer should not be left open. WordPress is a shared environment in which core, plugins and themes run in a coordinated sequence that is not always predictable, particularly due to the way hooks work. If another component opens or closes a buffer that does not align with the plugin's own buffer, the buffer stack becomes misaligned, resulting in:
- Headers being sent at unexpected moments.
- Output being captured by the wrong component (or lost entirely).
- Side effects in unrelated areas of the request lifecycle (REST responses, AJAX, redirects, etc.).
- Hard-to-debug "white screen" or "headers already sent" errors.
Since WordPress 6.9, the recommended way to modify the full response output is the new template enhancement output buffer (introduced by Fran Torres, who proposed and led this work — see the WordPress 6.9 Frontend Performance Field Guide referenced at the end of this issue).
Check definition
- Name (proposed):
Unclosed_Ob_Start_Check
- Category:
Performance (mirrors current Plugin Check organisation)
- Type: Static (extends
Abstract_File_Check)
- Severity: Warning
- Scope: PHP files inside the plugin
Detection logic
The check should locate every call to ob_start() and verify that, within the same logical context (same function/method body, same closure, or same top-level file scope), there is at least one matching closing call:
ob_get_clean()
ob_end_clean()
ob_get_flush()
ob_end_flush()
ob_get_contents() // only as part of a clean/end pair — optional, see notes
This mirrors callback_ob_start() in class-parser_calls.php of the Internal Scanner:
function callback_ob_start( $element, $key, &$foundNames ) {
$closing_ob_start = array(
'ob_get_clean',
'ob_end_clean',
'ob_get_flush',
'ob_end_flush',
);
$functions_in_the_context = $this->get_functions_in_the_context(
$element,
$closing_ob_start,
'after'
);
if ( ! empty( $functions_in_the_context ) ) {
return false; // Paired → not an issue.
}
return $key; // Unpaired → flag it.
}
Consider it an issue if
ob_start() is left open with no corresponding ob_end_clean(), ob_end_flush() or similar closing function in the same scope.
ob_start() is open and not closed within the same logical flow in a way that other code can be affected by it.
- The closing call cannot be located by static analysis (since plugin-check is fully static, this is the realistic ceiling — flag as a warning, not an error).
Do NOT consider it an issue if
ob_start() is open and closed within the same logical flow.
- The buffer is intentionally closed via a hook callback registered immediately next to it, in a way that is statically obvious (best-effort heuristic).
User-facing message
When the check triggers, surface a message similar to:
Unclosed ob_start() detected
ob_start() was found without a corresponding closing call (ob_get_clean(), ob_end_clean(), ob_get_flush() or ob_end_flush()) in the same scope.
Output buffering is a valid technique, but a buffer must not be left open. WordPress is a shared environment where core, themes and other plugins may also open or close buffers, and a misaligned buffer stack causes unpredictable behaviour (headers already sent, lost output, broken redirects, etc.).
Please ensure every ob_start() is paired with a closing function within the same function scope, and that nothing (including hooks or early returns) can bypass that closing logic.
If you need to modify the full response output, use the new template enhancement output buffer available since WordPress 6.9.
Acceptance criteria
This check is inspired by the work of Fran Torres (@frantorres) on the ob_start check in the Internal Scanner.
Add a new static analysis check to Plugin Check that detects calls to
ob_start()that are not paired with a corresponding buffer-closing function (ob_get_clean(),ob_end_clean(),ob_get_flush(),ob_end_flush(), etc.) within the same logical scope.This check is inspired by the existing
ob_startcheck in the Plugin Team's Internal Scanner, which has been catching this issue across plugin submissions for a while.Background
Using
ob_start()without explicitly closing the buffer within the same logical flow can lead to unpredictable behaviour.Output buffering is a valid technique, but a buffer should not be left open. WordPress is a shared environment in which core, plugins and themes run in a coordinated sequence that is not always predictable, particularly due to the way hooks work. If another component opens or closes a buffer that does not align with the plugin's own buffer, the buffer stack becomes misaligned, resulting in:
Since WordPress 6.9, the recommended way to modify the full response output is the new template enhancement output buffer (introduced by Fran Torres, who proposed and led this work — see the WordPress 6.9 Frontend Performance Field Guide referenced at the end of this issue).
Check definition
Unclosed_Ob_Start_CheckPerformance(mirrors current Plugin Check organisation)Abstract_File_Check)Detection logic
The check should locate every call to
ob_start()and verify that, within the same logical context (same function/method body, same closure, or same top-level file scope), there is at least one matching closing call:This mirrors
callback_ob_start()in class-parser_calls.php of the Internal Scanner:Consider it an issue if
ob_start()is left open with no correspondingob_end_clean(),ob_end_flush()or similar closing function in the same scope.ob_start()is open and not closed within the same logical flow in a way that other code can be affected by it.Do NOT consider it an issue if
ob_start()is open and closed within the same logical flow.User-facing message
When the check triggers, surface a message similar to:
Acceptance criteria
WordPress\Plugin_Check\Checker\Checks\Performance\Unclosed_Ob_Start_CheckextendingAbstract_File_Check.Default_Check_Repository.ob_start()calls per scope (function, method, closure, file).php/tests/calls/ob_start/test.php:ob_start()at the top of the file with no closing call → issue.ob_start()in the same scope, only one closed → issue on the unpaired one.ob_start()inside a closure, closed inside the same closure → no issue.ob_start()inside a function, closed only conditionally (if) → flagged as warning.This check is inspired by the work of Fran Torres (@frantorres) on the
ob_startcheck in the Internal Scanner.