Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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.)

The model in one line. Sessions work like classic PHP — call 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.
How it works under the hood
// 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 PHPZealPHP replacementNotes
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.

GET 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']];
});
LIVE OUTPUT Click Run →
GET 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(),
    ];
});
LIVE OUTPUT Click Run →
Sessions are per-coroutine in coroutine mode. Each request gets its own isolated 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 asRead back asWhy
Scalar (string, int, float, bool, null)Same scalarNo instantiation needed; trivially safe.
Array (assoc or list)Same arraySame — recursive scalars only by default.
stdClassLive stdClassZero 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_ClassProperty 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.
In practice: storing an OAuth token from json_decode
$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():

HandlerWhen usedConcurrency 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.
Selecting a session handler
// 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.