In PHP, how do you implement and register a custom session save handler with SessionHandlerInterface, and what must it handle itself?
answer
- six methods, open to gc
- register the object before session_start()
- read() returns '' for unknown IDs
- validateId() or strict mode is inert
- no lock unless you build one
basics
~10 sImplement SessionHandlerInterface's open, close, read, write, destroy and gc, register the object with session_set_save_handler($handler, true) before session_start(), and handle locking, ID validation and timestamp refreshes yourself.
solid answer
~40 s`SessionHandlerInterface` has six methods: `open`, `close`, `read(string $id): string|false`, `write(string $id, string $data): bool`, `destroy` and `gc(int $max_lifetime): int|false`. `$data` arrives already serialized, and `read()` returns it back as a string, an empty string for an unknown ID. Register it with `session_set_save_handler($handler, true)` before `session_start()`; the `true` registers `session_write_close()` as a shutdown function. Three things the `files` handler gave you are now yours: **locking** (none unless you implement it), **ID validation** (implement `validateId()` from `SessionUpdateTimestampHandlerInterface`, or `session.use_strict_mode` accepts every ID) and **timestamp refresh** (without `updateTimestamp()` PHP calls `write()` even for unchanged data). Passing individual callbacks instead of an object has been deprecated since PHP 8.4.
code
php · 33 lines<?php
declare(strict_types=1);
final class PdoSessionHandler implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface
{
public function __construct(private readonly PDO $pdo) {}
public function open(string $path, string $name): bool { return true; }
public function close(): bool { return true; }
public function read(string $id): string|false {
$st = $this->pdo->prepare('SELECT data FROM sessions WHERE id = ?');
$st->execute([$id]);
$data = $st->fetchColumn();
return $data === false ? '' : $data; // unknown ID: empty string, not false
}
public function write(string $id, string $data): bool {
return $this->pdo->prepare('REPLACE INTO sessions (id, data, touched) VALUES (?, ?, ?)')->execute([$id, $data, time()]);
}
public function destroy(string $id): bool {
return $this->pdo->prepare('DELETE FROM sessions WHERE id = ?')->execute([$id]);
}
public function gc(int $max_lifetime): int|false {
$st = $this->pdo->prepare('DELETE FROM sessions WHERE touched < ?');
return $st->execute([time() - $max_lifetime]) ? $st->rowCount() : false;
}
public function validateId(string $id): bool {
$st = $this->pdo->prepare('SELECT 1 FROM sessions WHERE id = ?');
$st->execute([$id]);
return $st->fetchColumn() !== false;
}
public function updateTimestamp(string $id, string $data): bool {
return $this->pdo->prepare('UPDATE sessions SET touched = ? WHERE id = ?')->execute([time(), $id]);
}
}go deeper
Recall that PHP can store sessions somewhere other than files by registering a handler object with session_set_save_handler() before session_start().
Name the six interface methods, their return types, what read() returns for an unknown ID, and why write() receives an already serialized string.
Show you know what the files handler did implicitly, locking, ID validation and timestamp refresh, and how dropping each one shows up in production.
Judge whether a custom handler is worth owning at all compared with a maintained extension handler, given the locking and validation work it brings.
## What a save handler is PHP's session module separates the session API (`session_start()`, `$_SESSION`, `session_regenerate_id()`) from **storage**. The storage layer is a *save handler*: code that can open, read, write, delete and clean up session records keyed by ID. PHP ships the `files` handler as the default, and extensions can add others. When you need storage PHP does not provide, you write a class implementing `SessionHandlerInterface` and register an instance. ## The interfaces | Interface | Method | Contract | |---|---|---| | `SessionHandlerInterface` | `open(string $path, string $name): bool` | prepare storage; receives `session.save_path` and the session name | | | `close(): bool` | release resources for this request | | | `read(string $id): string\|false` | return the stored serialized string; `''` when none exists | | | `write(string $id, string $data): bool` | store the serialized string | | | `destroy(string $id): bool` | delete one record | | | `gc(int $max_lifetime): int\|false` | delete expired records; return how many | | `SessionUpdateTimestampHandlerInterface` | `validateId(string $id): bool` | does this ID exist? used by strict mode | | | `updateTimestamp(string $id, string $data): bool` | refresh expiry without rewriting | | `SessionIdInterface` | `create_sid(): string` | generate IDs yourself (rarely needed) | The return types are **tentative** (since PHP 8.1): an implementation that omits them gets a deprecation notice unless the method carries `#[\ReturnTypeWillChange]`. In new code, declare them. ## Registering it 1. Build the handler with its dependencies, for example a `PDO` connection. 2. Call `session_set_save_handler($handler, true)` **before** `session_start()`; changing the handler on an active session only produces a warning. 3. Call `session_start()` as usual. The second argument, `register_shutdown`, defaults to `true` and registers `session_write_close()` as a shutdown function, so the session is written while the handler object and its connection still exist rather than during object destruction at shutdown. The older form with six or more callables is deprecated since PHP 8.4: calling `session_set_save_handler()` with more than two arguments raises `E_DEPRECATED`. ## What read() must return `$data` passed to `write()` is already encoded by `session.serialize_handler`; store it as an opaque string and return it unchanged from `read()`. For an ID with no record, return **an empty string**. The manual page for `read()` says to return `false` when the record is not found, but the engine treats `false` as a failed read: `session_start()` aborts with "Failed to read session data" and returns `false`, and a comment in the session source calls handlers that return failure for a non-existent ID broken. Every new visitor's first request reads an unknown ID, so this matters immediately. ## What the files handler did for you Replacing the default handler drops three behaviours unless you rebuild them: - **Locking.** The `files` handler holds an exclusive `flock()` on the session file for the whole request. A custom handler has no lock by default, so concurrent requests for one session read the same data and the last `write()` wins. Implement a lock in `open`/`read` and release it in `close`, or accept and document last-write-wins. - **ID validation.** `session.use_strict_mode=1` asks the handler whether an ID exists. Without `validateId()`, PHP falls back to a validator that accepts every ID, so strict mode silently stops working. - **Cheap refreshes.** With `session.lazy_write=1`, unchanged data triggers `updateTimestamp()`. Without it, PHP calls `write()` on every request. ## Garbage collection PHP calls `gc()` on a fraction of `session_start()` calls, with probability `session.gc_probability / session.gc_divisor` (built-in defaults 1 and 100; both shipped php.ini files set the divisor to 1000). The argument is `session.gc_maxlifetime`, 1440 seconds by default. Return the number of records removed, or `false` on failure. Many teams set `gc_probability` to 0 and purge from a scheduled job instead, so no user request pays for the cleanup. ## Extending SessionHandler instead `SessionHandler` is a built-in class that exposes the current internal handler (the one `session.save_handler` names) through the same methods. Extending it lets you wrap one step, for instance encrypting in `write()` and decrypting in `read()` around `parent::` calls, while the built-in storage, including the `files` handler's locking, keeps doing the rest. ## Testing a handler Because PHP drives the handler, the quickest check is behavioural: - start a session, write a value, close it, and confirm a second `session_start()` with the same ID (set through `session_id()` before starting) reads it back; - start with an ID that was never issued under `session.use_strict_mode=1` and confirm a new ID is generated, which proves `validateId()` is wired in; - call `session_regenerate_id(true)` and confirm the old record is gone, which exercises `destroy()`.
- What should read() return for a session ID that has no stored data?An empty string. Although the manual page says `false`, the engine treats `false` as a failed read: `session_start()` warns "Failed to read session data" and returns `false`. A new visitor's first request always reads an unknown ID, so returning `false` breaks every fresh session.
- Why pass true as the second argument of session_set_save_handler()?It registers `session_write_close()` as a shutdown function, so the session is written while the handler object and its dependencies, such as a PDO connection, still exist. It is the default, but spelling it out documents the intent; without it the final write could run after those objects are torn down at shutdown.
- When would you extend SessionHandler instead of implementing the interface from scratch?When you want to keep PHP's built-in storage and change one step. `SessionHandler` exposes the internal handler that `session.save_handler` names, so a subclass can, for example, encrypt in `write()` and decrypt in `read()` around `parent::` calls while the `files` handler keeps its storage and file locking.
saying these in an interview costs you the question
- read() should return false when no record exists for the ID
- write() receives the $_SESSION array and must serialize it
- A custom handler inherits the files handler's locking automatically
- session.use_strict_mode works with any handler without extra methods
- Passing six callables to session_set_save_handler() is the current style