Bug inside PHP CLI sessions

php.internals

Gregory Magarshak

258 days ago
I am using my PHP framework inside my PHP scripts, which has code designed to work across multiple environments, including web requests and CLI scripts, and uses handlers which extends SessionHandler. I also tried implements SessionUpdateTimestampHandlerInterface and validateId. Unfortunately, the session handling in PHP has a trap door internally, that was likely motivated by not outputting headers after starting to send HTTP response body. It's mystical. 1. session_id($id); session_start(); echo session_id(); // empty! 2. session_id($id); echo "Session started.\n"; session_start(); echo session_id(); // FINE!! 3. session_id($id); echo "Session started.\n"; session_start(); echo session_id(); // empty again! I can't believe it is there in 2025. *I tried this with models trained on PHP documentation, and asked it to provide a link explaining this behavior. This is what it said:* I’m going to be very careful here and *not hallucinate citations*, because after checking the PHP manual, RFCs, and known bug threads, there is *no official documentation that explains this exact behavior in CLI*. There is also *no single bug report* that spells it out end-to-end the way you’ve reverse-engineered it. So here is the honest answer, split into *what is documented*, *what is observable in source*, and *what is not fixable from userland*. ------------------------------ Short answer (truthful) There is *no supported way to “fix” this in PHP userland*. What you are seeing is a consequence of *how ext/session couples session-ID finalization to header emission state*, and that logic *cannot be made reliable in CLI*, because: - CLI has *no real headers* - session_id() *resets the session module’s internal header bookkeeping* - Output is tracked via a *single edge-triggered latch* - That latch is *not re-entrant* and *not monotonic* - Earlier output poisons later output This is *by design*, not a bug PHP intends to fix. ------------------------------ Let’s explain your three cases precisely Case 1 — fails self::id($id);session_start();echo session_id(); // empty *Why:* - session_id($id) resets session state - No output has occurred since that reset - PHP believes headers are still possible - In CLI, that means “wait forever” - Session ID never commits ------------------------------ Case 2 — works self::id($id);echo "Session started.\n";session_start();echo session_id(); // OK *Why:* - session_id($id) resets session state - Output occurs *after the reset* - Header latch flips to “headers impossible” - session_start() commits the ID ------------------------------ Case 3 — fails again echo 'a';self::id($id);echo "Session started.\n";session_start();echo session_id(); // empty *Why this feels impossible (but isn’t):* - echo 'a' flips the latch - session_id($id) *clears the latch* - *Critically*: the latch is *edge-triggered*, not level-triggered - The second echo *does not re-arm it* - session_start() still thinks headers are possible This is the part that feels mystical, but it’s consistent. ------------------------------ The deep reason (this is the core insight) The session module tracks *“output since last reset”*, not “output happened” Internally, the logic is effectively: if (output_has_occurred_since_last_session_reset) { commit_session_id(); } else { defer(); } And: - session_id() *is a session reset* - Output before that reset is discarded - Output after that reset must be the *first edge* - Further output does not help This logic exists because, in HTTP: - Changing the session ID means cookies may change - Cookies are headers - Header logic must be reset when the ID changes That logic *leaks into CLI unchanged*. Sincerely, Greg Magarshak <https://76183cf5.streak-link.com/CsqFLR9dXbkQa2qdJQNqfbB-/http%3A%2F%2Fqbix.com%2Finvest> ᐧ ᐧ

Gregory Magarshak

258 days ago
My apologies. I made a mistake in the minimal examples to reproduce: 1. session_id($id); session_start(); echo session_id(); // empty! 2. session_id($id); echo "b"; session_start(); echo session_id(); // FINE!! 3. echo "a"; session_id($id); echo "b"; session_start(); echo session_id(); // empty again! Sincerely, Greg Magarshak <http://qbix.com/invest> On Wed, Dec 17, 2025 at 12:06 PM Gregory Magarshak <greg@qbix.com> wrote:

Weedpacket

258 days ago
On 2025-12-18 06:12, Gregory Magarshak wrote:
> My apologies. I made a mistake in the minimal examples to reproduce: > > 1. session_id($id); session_start(); echo session_id(); // empty! > > 2. session_id($id); echo "b"; session_start(); echo session_id(); // FINE!! > > 3. echo "a"; session_id($id); echo "b"; session_start(); echo > session_id(); // empty again! > > Sincerely, > Greg Magarshak > <http://qbix.com/invest> > >
I tried your minimal reproductions in the CLI and got undefined variable warnings from the $id value. Putting in a literal session id value I get 1. session_id('session17'); session_start(); echo session_id();"
> session17
2. session_id('session17'); echo 'b'; session_start(); echo session_id();
> bSession cannot be started after headers have already been sent (sent
from Command line code on line 1) in Command line code on line 1 3. echo 'a'; session_id('session17'); echo 'b'; session_start(); echo session_id();
> aSession ID cannot be changed after headers have already been sent > bSession cannot be started after headers have already been sent
I get the same results using `php -r`, `php -a`, and putting those lines in files and running them. What else is your framework doing that is being left out of this setup?