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.
- session_id($id);
session_start(); echosession_id(); // empty! - session_id($id); echo "Session started.\n";
session_start(); echo
session_id(); // FINE!! - 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
ᐧ
ᐧ
My apologies. I made a mistake in the minimal examples to reproduce:
-
session_id($id);
session_start(); echosession_id(); // empty! -
session_id($id); echo "b";
session_start(); echosession_id(); // FINE!! -
echo "a"; session_id($id); echo "b";
session_start(); echosession_id();
// empty again!
Sincerely,
Greg Magarshak
http://qbix.com/invest
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.
- session_id($id);
session_start(); echosession_id(); // empty!- session_id($id); echo "Session started.\n";
session_start(); echo
session_id(); // FINE!!- 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 bookkeepingOutput 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(); // emptyWhy:
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(); // OKWhy:
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(); // emptyWhy 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 possibleThis 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 resetOutput 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 Magarshakhttps://76183cf5.streak-link.com/CsqFLR9dXbkQa2qdJQNqfbB-/http%3A%2F%2Fqbix.com%2Finvest
ᐧ
ᐧ
My apologies. I made a mistake in the minimal examples to reproduce:
session_id($id);
session_start(); echosession_id(); // empty!session_id($id); echo "b";
session_start(); echosession_id(); // FINE!!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
- session_id('session17');
session_start(); echosession_id();"
session17
- session_id('session17'); echo 'b';
session_start(); echosession_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
- 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?