Six methods between PHP and storage, shown on a complete framework-free Redis handler
Once more than one php-fpm process is running, the default file-based session storage quickly becomes a problem, at the latest once you scale horizontally across multiple servers. SessionHandlerInterface offers exactly six methods to store session data in Redis, a database, or any other backend instead, without any framework at all. This article walks through the implementation in detail, including the locking strategy that protects parallel requests for the same session from data loss.
Table of Contents
- 1. SessionHandlerInterface overview and why a custom handler
- 2. open() and close(): connection setup and teardown
- 3. read(): loading data and the locking semantics behind it
- 4. write(), destroy(), and gc(): saving, deleting, and cleaning up
- 5. Preventing race conditions across parallel requests for the same session
- 6. Practical example: a complete Redis session handler
- 7. Registering with session_set_save_handler
- 8. Comparison with SessionUpdateTimestampHandlerInterface
- 9. Pitfalls in production and sensible monitoring
- 10. Summary
- 11. FAQ
1. SessionHandlerInterface overview and why a custom handler
PHP's built-in session mechanism stores session data as files in the directory defined by session.save_path by default, which is fine for a single server but fails once multiple php-fpm instances sit behind a load balancer, as soon as two consecutive requests from the same user land on different servers. SessionHandlerInterface, part of the language since PHP 5.4, defines exactly the six methods that the internal session mechanism calls, regardless of where the data actually ends up.
The key advantage over a framework-provided solution is that this mechanism works entirely without Symfony, Laravel, or any other framework, since it is part of PHP's standard library itself. You implement the six methods in your own class, register it with session_set_save_handler, and from that point on PHP transparently calls those methods instead of the built-in filesystem logic, without anything changing in the rest of the application code that accesses $_SESSION.
2. open() and close(): connection setup and teardown
open is called by PHP exactly once at the start of a session, before read runs for the first time, and receives the configured save path and session name as parameters, even though a Redis-based handler usually ignores both in practice, since the connection details are typically already available via the constructor or dependency injection. The return value must be a boolean signaling whether initialization succeeded.
close is correspondingly called at the end of session processing, usually implicitly at script end or explicitly via session_write_close, and should release any resources that open reserved. With a Redis connection already managed via dependency injection and also used for other purposes during the request, close is often a plain no-op in practice that simply returns true, since actually closing the connection happens elsewhere in the lifecycle.
3. read(): loading data and the locking semantics behind it
read receives the session ID as a parameter and must return the previously stored, serialized session string, or an empty string if no data exists yet for that ID. PHP deserializes that string internally into the $_SESSION array itself, so the handler method does not need to worry about serialization in the literal sense, it merely needs to store and return the raw, already PHP-serialized string unchanged.
PHP's built-in file handler implicitly acquires an exclusive filesystem lock on the session file during read, which is only released again on write or close. That is the reason two parallel requests for the same session are processed sequentially rather than in parallel with the file handler by default, since the second request waits at that point until the first releases its lock. A custom handler has to explicitly replicate that behavior if it wants to offer the same consistency guarantee.
4. write(), destroy(), and gc(): saving, deleting, and cleaning up
write receives the session ID together with the data string already serialized by PHP and must persist it, with the return value again being a boolean for success or failure. It matters that write is called even when the content of $_SESSION has not changed since the last read, PHP does not check that itself by default unless session.lazy_write is enabled and supported accordingly by the handler.
destroy also receives the session ID and must completely remove the associated data, for example on an explicit session_destroy call following a logout. gc, finally, receives the configured lifetime in seconds as a parameter and must remove every session older than that value, with the return value since PHP 7.1 no longer being a boolean but the number of deleted sessions as an integer, or false on failure.
5. Preventing race conditions across parallel requests for the same session
Without its own locking, a Redis handler runs into a classic lost-update problem: two parallel requests for the same session, say a regular page load and an Ajax call triggered by JavaScript, both read the same starting state, each modify a different part of $_SESSION, and whichever request writes last silently overwrites the other's changes, since write always writes the entire serialized state, not just the changed fields.
The robust solution is a pessimistic lock right inside read, implemented via the Redis SET command with the NX option, meaning set only if the key does not already exist, and EX for an automatic expiry as a safeguard against stuck locks. The handler repeatedly tries, with a short wait, to acquire the lock before actually reading the session data, and releases it again in write or close, so parallel writes to the same session are strictly serialized, quite analogous to the built-in handler's filesystem lock.
6. Practical example: a complete Redis session handler
The following example shows a complete, framework-independent implementation of SessionHandlerInterface that uses Predis as the Redis client and implements a simple spinlock pattern for the read lock. Every session key gets its own Redis TTL, which is renewed on every write, so expired sessions are removed automatically by Redis itself and gc, in this specific case, exists only as an empty formality.
What matters is consistent namespace separation via a prefix such as sess:, so session keys never accidentally collide with other data stored in the same Redis instance, and sensible error handling for the case where Redis itself is unreachable, since a session handler that throws an uncaught exception on a Redis outage takes down every single request across the entire application with it.
<?php
declare(strict_types=1);
use Predis\Client;
/**
* Framework-independent session handler that stores session data in
* Redis and serializes parallel writes to the same session via a lock.
*/
final class RedisSessionHandler implements SessionHandlerInterface
{
private const string LOCK_PREFIX = 'sess_lock:';
private const string DATA_PREFIX = 'sess:';
private const int LOCK_TTL_SECONDS = 5;
private const int MAX_LOCK_WAIT_MICROSECONDS = 100_000;
public function __construct(
private readonly Client $redis,
private readonly int $sessionLifetimeSeconds = 1440,
) {
}
/**
* Called by PHP exactly once at the start of a session. The actual
* Redis connection is already injected via the constructor.
*
* @param string $path Configured save path, unused here
* @param string $name Configured session name, unused here
* @return bool Always true, since no extra initialization is needed
*/
public function open(string $path, string $name): bool
{
return true;
}
/**
* Acquires a lock for the session ID and then reads the stored,
* already PHP-serialized session data.
*
* @param string $id The session ID
* @return string The stored data string, or an empty string
*/
public function read(string $id): string
{
$this->acquireLock($id);
return (string) $this->redis->get(self::DATA_PREFIX . $id);
}
/**
* Stores the session data already serialized by PHP with a renewed
* TTL and releases the lock afterward.
*
* @param string $id The session ID
* @param string $data The session content already serialized by PHP
* @return bool True on successful save
*/
public function write(string $id, string $data): bool
{
$this->redis->setex(self::DATA_PREFIX . $id, $this->sessionLifetimeSeconds, $data);
$this->releaseLock($id);
return true;
}
/**
* Fully removes the session data, e.g. on logout.
*
* @param string $id The session ID to delete
* @return bool True on successful deletion
*/
public function destroy(string $id): bool
{
$this->redis->del(self::DATA_PREFIX . $id);
$this->releaseLock($id);
return true;
}
/**
* No manual cleanup needed, since every key already carries its own
* Redis TTL and expires automatically.
*
* @param int $maxLifetime Configured lifetime in seconds
* @return int Number of deleted sessions, always 0 here
*/
public function gc(int $maxLifetime): int
{
return 0;
}
/**
* Releases any still-open lock and closes the session.
*
* @return bool Always true
*/
public function close(): bool
{
return true;
}
/**
* Repeatedly tries to acquire an exclusive lock for the session ID
* to serialize parallel write operations.
*
* @param string $id The session ID
* @return void
*/
private function acquireLock(string $id): void
{
$lockKey = self::LOCK_PREFIX . $id;
while (!$this->redis->set($lockKey, '1', 'NX', 'EX', self::LOCK_TTL_SECONDS)) {
usleep(random_int(1_000, self::MAX_LOCK_WAIT_MICROSECONDS));
}
}
/**
* Releases the lock for the session ID again.
*
* @param string $id The session ID
* @return void
*/
private function releaseLock(string $id): void
{
$this->redis->del(self::LOCK_PREFIX . $id);
}
}
7. Registering with session_set_save_handler
Registration itself happens via session_set_save_handler, which is passed an instance of a SessionHandlerInterface implementation, followed by register_shutdown_function with session_write_close as the argument, so the session is written cleanly even in cases where an object destructor would otherwise close it too late or in an already inconsistent state.
This registration must happen before the actual session_start call, because once PHP has started a session, the handler active at that point already applies, and a later switch would be silently ignored. In a framework-free bootstrap script, both calls therefore belong right next to each other at the very beginning, before any business logic that reads or writes $_SESSION.
<?php
declare(strict_types=1);
$redis = new Predis\Client(['host' => 'redis', 'port' => 6379]);
$handler = new RedisSessionHandler($redis, sessionLifetimeSeconds: 1440);
session_set_save_handler($handler, true);
register_shutdown_function('session_write_close');
session_start();
8. Comparison with SessionUpdateTimestampHandlerInterface
Alongside SessionHandlerInterface, PHP 7.0 introduced the optional SessionUpdateTimestampHandlerInterface, which defines two additional methods: validateId, which checks before a read whether a client-supplied session ID is valid at all, and updateTimestamp, which merely refreshes the timestamp for a session that has not changed on the client side, without going through the full write process.
The practical benefit lies mainly in updateTimestamp: when session.lazy_write is used, which has been enabled by default since PHP 7.0, PHP only calls write if the content has actually changed, and uses updateTimestamp instead for a pure expiry refresh, which for Redis is considerably cheaper than a full write with re-serialization. For the Redis handler shown here, this extension is worthwhile, since setex already refreshes the TTL and updateTimestamp would only need to run the same command without the data portion.
9. Pitfalls in production and sensible monitoring
A commonly overlooked pitfall is too short a lock TTL: if it is shorter than the actual response time of a slow request, a second request can acquire the supposedly expired lock while the first request is still writing, defeating the very serialization the lock was meant to provide. The lock TTL should therefore sit comfortably above the expected maximum request duration, combined with a sensible timeout in acquireLock that aborts with a clear error after a few seconds instead of looping forever.
For production operation, it is also worth monitoring the average wait time inside acquireLock, since a noticeable increase usually points to too many parallel requests for the same session, for example from aggressive frontend polling, which can be mitigated with proper throttling or by splitting session data across multiple, finer-grained Redis keys instead of treating the entire session as a single blob that is always rewritten completely.
| Method | Parameters | Return value | Redis implementation |
|---|---|---|---|
| open() | path, name | bool | No-op, connection already exists |
| read() | id | string | Acquire lock, then GET on sess:{id} |
| write() | id, data | bool | SETEX on sess:{id}, then release the lock |
| destroy() | id | bool | DEL on sess:{id} and its associated lock |
| gc() | maxLifetime | int | No-op, since every key carries its own TTL |
| close() | none | bool | No-op, connection is managed centrally |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
Custom Session Storage: The Essentials at a Glance
Six required methods
open, close, read, write, destroy, and gc form the complete interface between PHP and the storage backend.
write receives raw data
PHP serializes $_SESSION itself, the handler only stores and returns the already finished string.
Locking inside read
A Redis lock with NX and an automatic expiry prevents lost-update problems on parallel requests.
TTL instead of gc
With Redis, a per-key TTL takes over the job of classic garbage collection.