Sessions
ZealPHP overrides every session_*() function via ext-zealphp at startup — those calls route through the coroutine-local RequestContext::instance()->session. Direct writes to the $_SESSION superglobal are NOT intercepted — PHP has no hook for variable assignment, so $_SESSION['k'] = $v always lands in the process-wide PHP array. In plain coroutine mode (superglobals(false)) that array leaks across concurrent requests. In App::mode(App::MODE_COROUTINE_LEGACY), $_SESSION is reference-bound to $g->session per coroutine (writes are captured and safe). In plain superglobals(true) mode it's bridged through $GLOBALS per the $g vs $_* parity rule. Always read and write through $g->session — it's the only form that's per-coroutine safe in all modes. (G remains as a class_alias for RequestContext since v0.2.6 — both names resolve to the same class.)
session_start() and read/write $g->session (or $_SESSION in superglobals modes). The framework picks the session manager from the active mode (CoSessionManager in coroutine / coroutine-legacy, SessionManager in mixed / legacy-cgi), isolates session state per request, and routes storage through a pluggable handler (Table / Redis / Store / File) that makes concurrent writes to the same session id safe. Handlers are registered in code before App::run() — no php.ini session.save_handler needed.
// At App::__construct() time — runs once per server lifecycle:
// ext-zealphp intercepts session functions, routing to per-request state:
zealphp_override('session_start', \Closure::fromCallable('ZealPHP\Session\zeal_session_start'));
zealphp_override('session_id', \Closure::fromCallable('ZealPHP\Session\zeal_session_id'));
zealphp_override('session_write_close', \Closure::fromCallable('ZealPHP\Session\zeal_session_write_close'));
// ... + 15 more functions (18 session_*() overrides in total)
// Use $g->session for per-coroutine safety in both modes:
$g = \ZealPHP\RequestContext::instance();
session_start(); // zeal_session_start — loads disk → $g->session
$g->session['user'] = ['id' => 42, 'name' => 'alice']; // safe in both modes
session_write_close(); // zeal_session_write_close — serializes $g->session
// AVOID direct $_SESSION writes — they hit the process-wide superglobal and
// leak across concurrent requests in coroutine mode. The framework cannot
// intercept the assignment, only the session_*() function calls around it.
Overridden functions
| Native PHP | ZealPHP replacement | Notes |
|---|---|---|
session_start() | zeal_session_start() | Reads session file into G::session |
session_id() | zeal_session_id() | Gets/sets session ID from cookie or G::cookie |
session_write_close() | zeal_session_write_close() | Serializes G::session to file |
session_destroy() | zeal_session_destroy() | Deletes session file |
session_regenerate_id() | zeal_session_regenerate_id() | Renames session file with new ID |
session_unset() | zeal_session_unset() | Clears all session data |
The “file” in these notes is the default backend. Storage actually goes through the active session handler — Table (coroutine default), Redis, Store, or File — so zeal_session_write_close() persists to whatever backend you registered, not necessarily a disk file.
Write session data
$app->route('/demo/session/write', function() {
$g = G::instance();
// session_start() is called automatically by CoSessionManager per request
$g->session['user'] = ['id' => 1, 'name' => 'alice'];
$g->session['login_at']= time();
return ['written' => $g->session['user']];
});
Read session data back
$app->route('/demo/session/read', function() {
$g = G::instance();
return [
'session_keys' => array_keys($g->session),
'has_user' => isset($g->session['user']),
'session_id' => session_id(),
];
});
RequestContext::instance()->session via Coroutine::getContext() —
in-request reads and writes are isolated. Concurrent writes to the same session id
(two requests for the same user arriving simultaneously) are made safe by the session handler's
optimistic-merge layer: TableSessionHandler uses CAS + a recursive 3-way merge;
RedisSessionHandler uses WATCH/MULTI with merge-on-conflict. Per-coroutine context isolation
alone does not resolve two coroutines flushing the same session row.
See the $g vs $_* parity rule for the cross-mode story (when $_SESSION is safe vs. when only $g->session works).
Storing objects in sessions — the stdClass whitelist
PHP's unserialize() can be turned into a remote-code-execution vector when fed
attacker-controlled data — any class on the autoload graph with __wakeup() /
__destruct() magic methods becomes a "gadget". Sessions are user-controlled
storage (tampered cookie, compromised Redis), so since v0.2.25 ZealPHP's session decode
refuses to instantiate arbitrary classes on read.
v0.2.26 (issue #15) narrowed the policy to an explicit whitelist:
| Stored as | Read back as | Why |
|---|---|---|
| Scalar (string, int, float, bool, null) | Same scalar | No instantiation needed; trivially safe. |
| Array (assoc or list) | Same array | Same — recursive scalars only by default. |
stdClass | Live stdClass | Zero magic methods (__wakeup, __destruct, __get, etc.) — no gadget chain. json_decode() output rides this path: OAuth token responses, API profile data, anything from json_decode($x) without the assoc flag. |
Any other class (DateTime, your User, …) | __PHP_Incomplete_Class | Property access prints a warning and yields nulls. The class is refused at unserialize time. Add it to the whitelist only after a security review of its magic methods. |
$g = \ZealPHP\RequestContext::instance();
session_start();
$tokenResponse = json_decode($curl_body); // returns stdClass
$g->session['oauth_token'] = $tokenResponse;
session_write_close();
// On the next request:
session_start();
echo $g->session['oauth_token']->access_token; // ✓ works — stdClass round-trips
echo $g->session['oauth_token']->expires_in;
Need another class on the whitelist (rare)? Audit its __wakeup / __unserialize / __destruct first — those are the gadget surfaces — then patch the four unserialize() calls in src/Session/utils.php. The function-level docblock at php_session_decode_to_array() documents the constraint.
Session storage backends
ZealPHP picks the storage handler automatically based on mode, or you can choose one explicitly
before App::run() via App::sessionHandler():
| Handler | When used | Concurrency safety |
|---|---|---|
TableSessionHandler |
Opt in with App::sessionHandler('table') — concurrent-safe, no Redis. Not the unconfigured default (see the note under the table). |
Optimistic versioning: CAS version check + recursive 3-way merge on conflict. Up to 3 retries. No Redis required. |
RedisSessionHandler |
App::sessionHandler('redis') |
WATCH/MULTI optimistic locking with 3-way merge retry on conflict. Cross-node safe. |
StoreSessionHandler |
StoreSessionHandler::register(int $ttl) or App::sessionHandler(new StoreSessionHandler(...)) (coroutine-family modes only) |
Rides whichever backend Store::defaultBackend() is configured with (Table / Redis / Tiered). TTL-mode rows expire server-side on Redis. |
FileSessionHandler |
The unconfigured default in every mode (the inline file path used when App::sessionHandler() is null); or set explicitly with App::sessionHandler('file'). |
Last-writer-wins plain file_put_contents in the handler class. The coroutine session write path (zeal_session_write_close) does a read-merge-write under LOCK_EX for file-backed sessions. Safe for sequential workers; not recommended under coroutine concurrency — opt into 'table'/'redis' there. |
// App::sessionHandler() is honored by BOTH session managers — CoSessionManager
// (coroutine / coroutine-legacy) and SessionManager (sync mixed / legacy-cgi).
// Default (no call): the framework inline FILE path in every mode — NOT
// TableSessionHandler. Under coroutine concurrency, opt into a concurrent-safe
// backend with App::sessionHandler('table') (or 'redis' for cross-node).
// Force Redis (cross-node, WATCH/MULTI concurrency safety):
App::sessionHandler('redis'); // requires Store::defaultBackend(Store::BACKEND_REDIS) or ext-redis
// Enable the Store-backed handler (backend follows Store::defaultBackend()):
StoreSessionHandler::register(3600);
// — or — App::sessionHandler(new StoreSessionHandler(...))
// Force file-backed sessions (simple, sequential workers only):
App::sessionHandler('file');
// Or pass a \SessionHandlerInterface directly:
App::sessionHandler(new MyCustomHandler());
App::init('0.0.0.0', 8080);
// ... routes ...
App::run();
What else gets reset per request
In coroutine mode, the entire RequestContext instance is per-coroutine — when the coroutine ends, every field on it is freed. That includes session data, response headers/cookies pending emission (on the Response wrapper), and the handler stacks pushed by set_error_handler() / set_exception_handler() / register_shutdown_function(). Legacy code that calls those per-request without restoring them can't accumulate handlers across requests in this mode.
In sync superglobals modes (mixed / legacy-cgi — one request at a time per worker), RequestContext is a process-wide singleton, so handler stacks could grow unbounded across requests — fixed in v0.2.10 by an explicit reset in SessionManager at request entry. coroutine-legacy is the exception: it runs superglobals(true) but still keeps a per-coroutine RequestContext via CoSessionManager, so it frees per-request state exactly like coroutine mode. See What survives a request for the full lifecycle matrix.