Skip to content

Increase tracked ob_get_level() only in ob_start()'s truthy branch instead of unconditionally#6087

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-iol6tg9
Open

Increase tracked ob_get_level() only in ob_start()'s truthy branch instead of unconditionally#6087
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-iol6tg9

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

ob_start() returns false when it fails to open an output buffer, but PHPStan treated every ob_start() call as if it always succeeded: it unconditionally raised the tracked ob_get_level(), which in turn removed false from the return type of ob_get_contents(), ob_get_clean(), ob_get_flush() and ob_get_length(). As a result, code that checks the buffer contents without checking ob_start()'s result got a false positive:

ob_start();
$a = ob_get_clean();
if ($a === false) { // "Strict comparison ... will always evaluate to false" (false positive)
    echo 'false';
}

The fix ties the buffer-opening side effect to ob_start()'s return value: the level is only raised in the branch where ob_start() is known to have returned a truthy value.

Changes

  • Added src/Type/Php/ObStartFunctionTypeSpecifyingExtension.php, a FunctionTypeSpecifyingExtension that, in the truthy context of ob_start(), overwrites the tracked ob_get_level() expression with level + 1. Because it goes through TypeSpecifier, it works for every condition form: if (ob_start()), if (!ob_start()) { return; }, if (ob_start() === false) { return; }, etc.
  • src/Analyser/ExprHandler/Helper/OutputBufferHelper.php: added opensBufferOnSuccess() to identify the level-incrementing functions (ob_start).
  • src/Analyser/ExprHandler/FuncCallHandler.php: no longer applies the unconditional level increase for functions that only open the buffer on success — that increase is now applied to the truthy branch by the extension. Level-decrementing functions are unchanged.
  • Updated tests/PHPStan/Analyser/nsrt/output-buffering.php to open the buffer via a checked ob_start() where an active buffer is expected, and added explicit assertions for the new unchecked-ob_start() behavior.
  • Added tests/PHPStan/Analyser/nsrt/bug-14985.php.

Root cause

The output-buffer level tracking modeled ob_start()'s side effect (buffer opened → ob_get_level() increased) as unconditional, while ob_start() actually reports success/failure through its bool return value. Since the false-removal narrowing of the whole ob_get_*() family is derived from the tracked level, an unchecked ob_start() incorrectly narrowed all of them to non-false. Applying the level increase only to ob_start()'s truthy branch fixes the entire family in one place.

Analogous constructs probed:

  • ob_get_contents(), ob_get_clean(), ob_get_flush(), ob_get_length() — all fixed by the single root-cause change (they read the shared tracked level).
  • Level-decrementing functions (ob_get_clean, ob_get_flush, ob_end_clean, ob_end_flush): assuming a close succeeded only lowers the level and therefore reduces narrowing, which cannot produce this class of false positive, so they intentionally keep their unconditional behavior.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14985.php reproduces the reported case: after a bare ob_start(), ob_get_clean() is inferred as string|false (the === false check is no longer flagged), while after a checked if (!ob_start()) { return; } it is string.
  • tests/PHPStan/Analyser/nsrt/output-buffering.php gained assertions for unchecked ob_start() (buffer not assumed active) and for narrowing through the truthy branch of if (ob_start()), the !ob_start() guard, and the ob_start() === false guard.

Fixes phpstan/phpstan#14985

…h instead of unconditionally

- Add `ObStartFunctionTypeSpecifyingExtension` that raises the tracked
  `ob_get_level()` by one only in the branch where `ob_start()` is known to have
  returned a truthy value (`if (ob_start())`, `if (!ob_start()) { return; }`,
  `if (ob_start() === false) { return; }`, ...). An unchecked/bare `ob_start()`
  no longer assumes an active buffer, so `ob_get_contents()`, `ob_get_clean()`,
  `ob_get_flush()` and `ob_get_length()` keep `false`/`int|false` in their
  return type and comparisons like `ob_get_clean() === false` are no longer
  reported as always-false.
- `OutputBufferHelper::opensBufferOnSuccess()` marks the level-incrementing
  functions; `FuncCallHandler` skips the unconditional level increase for them
  (the extension handles the truthy branch). Level-decrementing functions keep
  their unconditional behavior - assuming a close succeeded only reduces
  narrowing, so it cannot introduce false positives.
- The fix covers the whole `ob_get_*()` family at once, since all of them derive
  their non-`false` narrowing from the tracked buffer level.
- Update `output-buffering.php` to establish the active buffer with a checked
  `ob_start()`, and add `bug-14985.php`.
@VincentLanglet
VincentLanglet requested a review from staabm July 22, 2026 13:59
@staabm

staabm commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

IIRC we did intentionally not implement it this way, because a ob_start() which is failling is very unlikely. and lots of code out their does not check for the success of ob_start(), see e.g. https://github.com/search?q=repo%3Asymfony%2Fsymfony%20ob_start&type=code

@VincentLanglet

Copy link
Copy Markdown
Contributor

IIRC we did intentionally not implement it this way, because a ob_start() which is failling is very unlikely. and lots of code out their does not check for the success of ob_start(), see e.g. github.com/search?q=repo%3Asymfony%2Fsymfony%20ob_start&type=code

This create issues like phpstan/phpstan#14985

I don't have strong opinion about this topic. I wonder if we

  • have to close the issue because it's very unlikely to fail
  • or we have to merge this fix and tell people which want a good inference that they need to check the result of ob_start

I also wonder if the whole ob_ inferences was worth it or should just be removed...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ob_get_clean() can return false

3 participants