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

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 ls it)
  • $_SESSION vs $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.

Live demo: a session-backed counter (cross-tab synced)
starting…

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.

AccessAvailable inWhat 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 $_SESSION array with user 2’s data.
  • Request A resumes, reads $_SESSION['user_id'], gets 2, 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:

OAuth handoff — broken in &lt;= v0.2.23 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) — CoSessionManager handles the refresh as before, session_start() sees the cookie already present and skips the emit. Still exactly one Set-Cookie on 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.
  • $_SESSION and $g->session reference 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).