Sessions
Build a feature that survives a reload. Build it once; it works in two tabs.
You will learn
- How a server remembers something about a returning visitor — and what cookies have to do with it
- Write to
$_SESSION, read it back next request - Where ZealPHP actually keeps the data on disk (you can
lsit) $_SESSIONvs$g->session— same data, two access points, one reason
What you're building
A tiny session-backed feature: a counter that survives a reload and follows you across tabs. Hit the button, see it go up. Open another tab, see the same number. Close the browser and come back — still the same number. All with no database, no Redis on a single node, no JavaScript state — just a session.
Open this counter in a popup → · open it in multiple windows and watch them all update from any click.
htmx posts +1 from the clicking tab. The server increments the session counter and broadcasts the new HTML over a WebSocket scoped to your PHPSESSID (Lesson 19, Real-Time Sync teaches the broadcast pattern). Every other tab in this browser receives the broadcast and swaps in the same button. Reload, close the browser, come back — the number persists because the session file does.
Step 1 — HTTP doesn't remember anything
sequenceDiagram
participant B as Browser
participant SW as ZealPHP
participant FS as Session file on disk
Note over B: First visit, no cookie
B->>SW: GET /page
SW->>SW: session_start(), mint new id
SW->>FS: create sess_abc123
SW-->>B: response + Set-Cookie PHPSESSID
Note over B: Second visit, cookie present
B->>SW: GET /page + Cookie PHPSESSID
SW->>FS: read sess_abc123
FS-->>SW: lesson_counter = 5
SW->>SW: handler increments to 6
SW->>FS: write sess_abc123
SW-->>B: response (counter shows 6)
Each HTTP request stands alone. The server doesn’t know that you are the same visitor who reloaded the page two seconds ago — it just sees a new request. So how does the counter above stay at the same value across reloads?
Two pieces. First, your browser sends a cookie on every request:
PHPSESSID. It’s a random string the server picked the first time you visited
and asked the browser to remember. Open DevTools → Application → Cookies and you’ll see
yours. Second, the server keeps a file indexed by that random string with
whatever data it wants to remember about you.
Cookie + file. That’s the entire mechanism.
Step 2 — Write to the session
The route that powers the +1 button above is four lines:
// route/learn.php (excerpt)
$app->route('/api/learn/demo/session-bump', ['methods' => ['POST']], function () {
$g = G::instance();
$g->session['lesson_counter'] = ($g->session['lesson_counter'] ?? 0) + 1;
return (string) $g->session['lesson_counter']; // returned to htmx as text
});
Read the current value (default 0), add 1, store it back. The framework saves the session
automatically at the end of the request. The next request from the same browser — same
PHPSESSID cookie — gets the new value.
Step 3 — Where the data actually lives
ZealPHP stores session files on disk by default. You can list them:
$ ls -la /var/lib/php/sessions/ | head -5
sess_ab12cd34ef56gh78ij9kl0mnopqrst
sess_qrst7890uvwxyz1234567890abcdef
...
$ cat /var/lib/php/sessions/sess_ab12cd34...
lesson_counter|i:5;cart|a:2:{...}
Each file is named sess_<session_id>. The contents are PHP’s native
serialization format. ZealPHP’s session_start() override reads that file
into $_SESSION for you at the start of a request, and writes it back at the end.
Same convention vanilla PHP uses since the 90s.
For production with multiple servers behind a load balancer, swap the backend in code
(not in php.ini — ZealPHP overrides all session_*() calls and does not consult
session.save_handler in php.ini). Register a handler
before App::run():
// Cross-node: Redis WATCH/MULTI optimistic locking + 3-way merge on conflict
App::sessionHandler('redis'); // or: session_set_save_handler(new RedisSessionHandler(), true);
// Backend-agnostic (Table / Redis / Tiered — follows Store::defaultBackend())
StoreSessionHandler::register(ttl: 1440); // TTL in seconds
// Single-server, concurrent-safe, no Redis: opt into the Table-backed handler
App::sessionHandler('table');
// Default when you call NOTHING: the framework inline FILE path (every mode) —
// safe for sequential workers, but under coroutine concurrency pick 'table'/'redis'.
For single-server apps, App::sessionHandler('table') gives you a coroutine-safe
store with no Redis dependency — fast, survives restart, and the files are debuggable
with cat. Swap to RedisSessionHandler when you add a second server.
(Leaving it unset uses the plain file path, which is last-writer-wins under concurrency.)
Step 4 — Why $_SESSION AND $g->session?
You’ll see both used. They point at the same data; they exist for two different reasons.
| Access | Available in | What it is |
|---|---|---|
$_SESSION['x'] |
Anywhere PHP runs — including unmodified WordPress/Drupal | The classic PHP superglobal. ZealPHP’s session_start() populates it. |
$g->session['x'] |
ZealPHP-aware code (route handlers, API closures, services) | A reference into the same data via RequestContext — coroutine-safe by construction. |
Why this works — and what would break without it
Sessions are the one classic PHP superglobal that survives unchanged in coroutine
mode. That’s not an accident — the framework intercepts every session_*() call
(session_start, session_id, session_destroy,
session_write_close, session_regenerate_id) via the
ext-zealphp extension at startup. Those overrides live in src/Session/utils.php
and route every call to the current coroutine’s RequestContext instead of a
process-wide $_SESSION global. The Mental Model lesson covers
why that matters for
$_GET / $_POST too.
Without that interception, here’s the bug you’d see in production:
- Request A calls
session_start()for user 1, writes$_SESSION['user_id'] = 1, then yields on a DB query. - Request B arrives at the same worker, calls
session_start()for user 2. Without the override this would clobber the shared$_SESSIONarray with user 2’s data. - Request A resumes, reads
$_SESSION['user_id'], gets2, and returns user 2’s shopping cart to user 1.
That’s the exact race the framework prevents. Sessions are safe in both modes because the
framework went out of its way to keep session_start() and $_SESSION
semantics working. For everything else request-scoped — query strings, form bodies,
cookies, file uploads — use $g->X in coroutine mode. Reading
$_GET directly in coroutine mode returns null (not a leaked value),
which makes the mistake loud instead of silent.
The reason both $_SESSION and $g->session exist: in
coroutine mode (the default for new ZealPHP apps), every request runs in its
own coroutine with its own RequestContext. $g->session is the
direct, unmediated access path; $_SESSION is the back-compat path that legacy
code expects. Either works. Use whichever reads better in context.
The full mental-model is in Foundations → The Mental Model (the "What’s shared, what isn’t" section). You don’t need to think about it day-to-day — both access paths are wired so you can’t accidentally cross sessions between requests.
Which session manager runs depends on the lifecycle mode set by
App::mode():
in coroutine mode (App::mode(App::MODE_COROUTINE)) each request gets a
CoSessionManager with its own per-coroutine
RequestContext; in legacy-cgi and mixed modes
(App::mode(App::MODE_MIXED)) the shared-process
SessionManager runs instead — except
App::MODE_COROUTINE_LEGACY mode, which keeps real superglobals but
keeps CoSessionManager because coroutines remain on. Either way
$g->session is the safe access path.
First-visit cookie: redirects work after session_start() too
In a long-running OpenSwoole process, "set cookie on first
session_start()" is more subtle than it looks. Until v0.2.24,
this pattern would silently break for first-time visitors:
$app->route('/oauth/start', function($request, $response, $g) {
session_start();
$g->session['oauth_state'] = bin2hex(random_bytes(16));
$g->session['code_verifier'] = bin2hex(random_bytes(32));
// Redirect to the OAuth provider. The Set-Cookie MUST go out with
// this 302 — otherwise the callback request arrives with no PHPSESSID,
// the framework starts a fresh session, and oauth_state is lost.
return $response->redirect('https://provider.example/oauth/authorize?...', 302);
});
Symptom: the redirect went out, but with no
Set-Cookie header. On the callback hit, the client had no
PHPSESSID, the framework minted a new session, and the
oauth_state stashed in the original request was gone —
OAuth would always fail the state check.
Fix (v0.2.24, PR #12):
session_start() now auto-emits Set-Cookie when
the request had no incoming PHPSESSID. Idempotent (only fires
once per request), respects session.use_cookies = 0, and
skips when the response is already flushed. Nothing changes on the user
side — handlers that do session_start() + write +
redirect() just work now, exactly the way they would under
mod_php.
Two visitor paths covered:
- First-time visitor (no incoming cookie) — the
new auto-emit fires inside
session_start(), redirect carries the cookie, callback finds the session. - Returning visitor (already has a cookie) —
CoSessionManagerhandles the refresh as before,session_start()sees the cookie already present and skips the emit. Still exactly oneSet-Cookieon the wire (was two before in some edge cases).
What you built
A counter that survives a reload, follows you across tabs, and uses no database — just
$g->session and a file on disk. Sessions are the primitive for
anything per-user that doesn’t need to outlive the cookie’s lifetime: cart
contents, dark-mode preferences, flash messages, "you were viewing this page" breadcrumbs.
The next lesson uses sessions to remember who a user is — user accounts, register/login, password hashing — all built on the same primitive.
You set $_SESSION['cart'] = [...] in one request handler. The same user sends a second request. How does ZealPHP know to load the same cart back?
Key Takeaways
- Sessions = a cookie (
PHPSESSID) + a server-side store keyed by it. Cookie identifies, store remembers. - Default store is a file under
/var/lib/php/sessions/.cat-able, debuggable, survives restart. - Set
$g->session['key']in any handler; read in any handler from the same browser. ZealPHP saves at end of request. $_SESSIONand$g->sessionreference the same data — pick by readability. Both are coroutine-safe.- Per-user state goes in a session. Per-app state goes in
Store(Foundations → Sharing State).