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.
API Index — Namespaces, Packages, Reports, Indices

RedisSessionHandler
in package
implements SessionHandlerInterface

Redis-backed session handler for ZealPHP.

Reads/writes session data using the same key format as PHP's phpredis session handler (PHPREDIS_SESSION:{session_id}), so sessions created by Apache/mod_php are readable by ZealPHP and vice versa.

Coroutine safety (issue #16)

phpredis (\Redis) is not coroutine-safe: a single connection multiplexed across concurrent coroutines interleaves request/response frames on the same socket, so one coroutine can read another's reply (or garbage). Sharing one handler instance — the common onWorkerStart pattern — therefore corrupted session reads under load, which write_close() then persisted (a 24-key session collapsing to a handful of keys).

This handler keeps one connection per coroutine, stored in the coroutine's context, so concurrent requests never share a socket. The socket is closed deterministically via Coroutine::defer() when the coroutine ends (issue #438 — relying on context GC leaked one FD per request under HOOK_ALL until maxclients was exhausted; see conn()). Outside a coroutine (CLI, tests) it uses a single lazily-created fallback connection. High-throughput deployments that want to avoid per-request connection churn should front this with a connection pool; the per-coroutine model here is correct-by-default.

Every method requires a live Redis connection, so it is verified against a real server rather than unit tests — excluded from coverage measurement (no offline seam without shipping a Redis mock).

Tags
codeCoverageIgnore

Table of Contents

Interfaces

SessionHandlerInterface

Properties

$baseData  : array<string, string>
Per-coroutine read snapshot for 3-way merge on write conflict.
$fallback  : Redis|null
Single connection used outside coroutine context (CLI / tests).
$host  : string
Redis host (default '127.0.0.1').
$port  : int
Redis port (default 6379).
$prefix  : string
Key prefix used for session entries (default 'PHPREDIS_SESSION:').
$ttl  : int
Session TTL in seconds (default 1440).

Methods

__construct()  : mixed
close()  : bool
No-op: the per-coroutine connection is closed by the Coroutine::defer() registered in conn() (#438), not by the session-manager lifecycle.
destroy()  : bool
Delete the session key from Redis and return true.
gc()  : int|false
Garbage collection — Redis TTL handles expiry server-side, so this is a no-op.
getRedis()  : Redis
Expose the Redis connection for the current coroutine (or the fallback).
merge3Array()  : array<string|int, mixed>
Recursive 3-way array merge.
open()  : bool
Verify that the Redis connection is alive. Called by PHP's session manager before the first read().
parseSession()  : array<string|int, mixed>
Parse a PHP session-encoded string into a key→value array.
read()  : string
Read and return the serialised session data for $sessionId.
serializeSession()  : string
Serialise a key→value array back to the PHP session-encoded string format.
write()  : bool
Persist serialised session data for $sessionId with optimistic locking.
conn()  : Redis
Resolve the Redis connection for the CURRENT context.
connect()  : Redis
Open a new \Redis connection to $this->host:$this->port.
io()  : mixed
Run a Redis save-handler operation, guaranteeing it executes inside a coroutine.
merge3Sessions()  : string
3-way merge for serialised PHP session strings.

Properties

$baseData

Per-coroutine read snapshot for 3-way merge on write conflict.

private array<string, string> $baseData = []

$fallback

Single connection used outside coroutine context (CLI / tests).

private Redis|null $fallback = null

$prefix

Key prefix used for session entries (default 'PHPREDIS_SESSION:').

private string $prefix

Methods

__construct()

public __construct([string $host = '127.0.0.1' ][, int $port = 6379 ][, string $prefix = 'PHPREDIS_SESSION:' ][, int $ttl = 1440 ]) : mixed
Parameters
$host : string = '127.0.0.1'

Redis host.

$port : int = 6379

Redis port.

$prefix : string = 'PHPREDIS_SESSION:'

Key prefix; use the phpredis default ('PHPREDIS_SESSION:') for cross-handler compatibility.

$ttl : int = 1440

Session TTL in seconds.

close()

No-op: the per-coroutine connection is closed by the Coroutine::defer() registered in conn() (#438), not by the session-manager lifecycle.

public close() : bool

close() deliberately does NOT close the socket: PHP can call the save-handler close more than once per coroutine (every session_write_close()), and the socket is shared across all session operations in the request — closing it here would break a later read()/write() on the same coroutine. The deferred close fires exactly once, when the coroutine ends.

Return values
bool

destroy()

Delete the session key from Redis and return true.

public destroy(mixed $sessionId) : bool
Parameters
$sessionId : mixed
Return values
bool

gc()

Garbage collection — Redis TTL handles expiry server-side, so this is a no-op.

public gc(mixed $maxlifetime) : int|false

Returns 0 (zero sessions collected) to satisfy the SessionHandlerInterface contract.

Parameters
$maxlifetime : mixed
Return values
int|false

getRedis()

Expose the Redis connection for the current coroutine (or the fallback).

public getRedis() : Redis

Useful for inspecting connection state in tests or running additional Redis commands in the same per-coroutine socket.

Return values
Redis

merge3Array()

Recursive 3-way array merge.

public static merge3Array(array<string|int, mixed> $base, array<string|int, mixed> $local, array<string|int, mixed> $remote) : array<string|int, mixed>

Strategy: remote is the baseline result; for each key in local:

  • If the key is new in local (not in base), add it to the result.
  • If both local and remote are arrays, recurse.
  • If local changed from base, local wins. Keys deleted locally (present in base, absent in local) are removed from the result only when the remote value is still the base value (i.e. nobody else changed it).
Parameters
$base : array<string|int, mixed>
$local : array<string|int, mixed>
$remote : array<string|int, mixed>
Return values
array<string|int, mixed>

open()

Verify that the Redis connection is alive. Called by PHP's session manager before the first read().

public open(mixed $savePath, mixed $sessionName) : bool
Parameters
$savePath : mixed
$sessionName : mixed
Return values
bool

parseSession()

Parse a PHP session-encoded string into a key→value array.

public static parseSession(string $data) : array<string|int, mixed>

Uses the php serialisation format (key|serialized_value). Unknown or malformed entries are silently skipped. Only stdClass objects are allowed in unserialize() (matches the whitelist in src/Session/utils.php).

Parameters
$data : string
Return values
array<string|int, mixed>

read()

Read and return the serialised session data for $sessionId.

public read(mixed $sessionId) : string

Also WATCHes the key so a concurrent write is detected during the subsequent write() call; stores the baseline data in $baseData for the 3-way merge.

Parameters
$sessionId : mixed
Return values
string

serializeSession()

Serialise a key→value array back to the PHP session-encoded string format.

public static serializeSession(array<string|int, mixed> $data) : string
Parameters
$data : array<string|int, mixed>
Return values
string

write()

Persist serialised session data for $sessionId with optimistic locking.

public write(mixed $sessionId, mixed $sessionData) : bool

Uses WATCH/MULTI/EXEC and retries up to 3 times on conflict. When a concurrent writer is detected, performs a 3-way merge (base = original read() snapshot, local = intended write, remote = current Redis value) before retrying. Returns false if all 3 attempts fail.

Parameters
$sessionId : mixed
$sessionData : mixed
Return values
bool

conn()

Resolve the Redis connection for the CURRENT context.

private conn() : Redis

In a coroutine: a per-coroutine socket stored in the coroutine context (issue #16 — concurrent coroutines must never share a socket and interleave RESP frames). Outside a coroutine: the shared $fallback. The connection is established lazily here.

The per-coroutine socket is closed deterministically via Coroutine::defer() (issue #438), NOT by context GC. Under HOOK_ALL the socket is hooked I/O; once the coroutine has already exited there is no live coroutine left to drive its hooked close, so relying on context teardown leaked one FD per request (visible as CLOSE-WAIT sockets) until the worker eventually exhausted Redis/Valkey's maxclients. The defer closure runs while the coroutine is still alive (just before context teardown), so the hooked close() can yield and the FD is released. Registered exactly once, on first creation, so a single deferred close fires per coroutine.

Callers reach this only through io(), which guarantees a coroutine is live before connect()'s hooked \Redis->connect() runs.

Return values
Redis

connect()

Open a new \Redis connection to $this->host:$this->port.

private connect() : Redis
Return values
Redis

io()

Run a Redis save-handler operation, guaranteeing it executes inside a coroutine.

private io(callable(Redis): mixed $op) : mixed

Every \Redis call in this handler — the connect() plus watch/get/multi/exec/del — is hooked I/O under OpenSwoole\Runtime::HOOK_ALL, so it FATALS with "API must be called in the coroutine" when the PHP session save-handler chain fires OUTSIDE a request coroutine. That happens under App::superglobals(true) WITHOUT enableCoroutine(true): the onRequest handler isn't auto-wrapped in a coroutine, yet HOOK_ALL still hooks \Redis — e.g. an app that installs its own save handler via sessionLifecycle(false) and calls session_start() from middleware. #271 made the constructor lazy, but open()/read() are themselves "first use" and still hit the wall (#285).

When already in a coroutine, run $op directly on the per-coroutine connection. When NOT, run it inside Coroutine::run() on the shared $fallback (the App::parallel / #261 sync-mode idiom — Coroutine::run swallows throws, so capture + rethrow to keep a Redis error a catchable exception, not a worker-killing fatal). $fallback persists across these sequential transient runs, so the connection — and WATCH (read) -> MULTI/EXEC (write) optimistic locking — spans the whole request. Validated against a live Redis: a hooked socket created in one Coroutine::run() is reused, with WATCH/MULTI/EXEC intact, in later runs. (In this no-request-coroutine mode a worker handles requests sequentially, so there is no concurrent writer for the lock to guard anyway.)

Parameters
$op : callable(Redis): mixed

merge3Sessions()

3-way merge for serialised PHP session strings.

private merge3Sessions(string $base, string $local, string $remote) : string

Gives leaf-level granularity — concurrent writes to disjoint leaf paths under the same top-level key (e.g. $_SESSION['cart']['item1'] vs $_SESSION['cart']['item2']) both survive.

Parameters
$base : string
$local : string
$remote : string
Return values
string
On this page