SessionHandlerInterface: Building a Custom Session Store Without a Framework
AI generated
8.4
PHP · Sessions
SessionHandlerInterface: Building a Custom Session Store
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.

12 min read SessionHandlerInterface Redis custom handler

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.

11. FAQ: Custom Session Storage: The Essentials at a Glance

1Does a custom session handler have to implement SessionHandlerInterface?
Technically a class with the same six method names is enough, but implementing the interface is the documented, type-safe variant that every PHP developer recognizes immediately, and it should always be preferred.
2Why does PHP call write even when $_SESSION has not changed?
Because PHP does not perform change detection by default, unless session.lazy_write is enabled and the handler additionally implements SessionUpdateTimestampHandlerInterface, which provides the cheaper updateTimestamp method for exactly that case.
3What happens if Redis becomes briefly unreachable during a request?
Without explicit error handling, the Redis client throws an exception that aborts the entire request. A production-ready handler should catch such cases and, depending on the use case, either continue with an empty session or fail the request in a controlled way.
4How do you prevent a stuck lock from permanently blocking a session?
Through an automatic expiry set directly when the lock is created, using the EX option of SET, so a lock expires at the latest after the configured time even without an explicit release.
5Is the spinlock pattern shown here suitable for very high load?
For most applications yes, but under extremely high parallel load on the same session, a more sophisticated lock implementation, for example with exponential backoff or a queue instead of repeated polling, pays off.
6Do you even need a custom gc implementation with Redis?
No, as long as every key already gets its own expiry via setex, Redis handles automatic removal itself, and gc can remain an empty no-op method.
7Can the same handler be used for multiple applications on the same Redis instance?
Yes, as long as an application-specific prefix is consistently used for the Redis keys, so session data from different applications cannot overwrite each other.
8How do you test a custom session handler without a real Redis server?
Easiest with an in-memory implementation of the same interface in tests, or with an embedded Redis-compatible server such as a Docker test container that delivers realistic behavior including TTL.
9What is the difference between destroy and simply unsetting $_SESSION?
unset merely removes individual keys from PHP's internal $_SESSION array within the current request. destroy deletes the persistently stored data in the handler backend and is called by session_destroy to end the session entirely.
10Is SessionUpdateTimestampHandlerInterface worth adding to every custom handler?
Only if the pure expiry refresh is noticeably cheaper than a full write, which barely matters for Redis with setex, but can be quite relevant for more expensive stores like a relational database with larger records.