web php

Custom Session Handlers

PHP sessions need storage. The default storage is often files on the local server, but larger applications may need session data in Redis, Memcached, a database, or another shared system.

A custom session handler tells PHP how to open, read, write, delete, and clean up session data. You are changing the storage layer while the application code still uses $_SESSION.

Why custom handlers exist

Local file sessions are simple and often fine for small sites. They become harder when:

  • the application runs on multiple web servers
  • containers are replaced frequently
  • session data must be shared across instances
  • storage needs a central TTL or monitoring
  • the team wants session storage in an existing cache or database

In those cases, a framework may configure Redis sessions for you. Even if you do not write the handler yourself, you should understand what it is responsible for.

The handler interface

SessionHandlerInterface contains methods PHP calls during the session lifecycle.

open() prepares storage. read() returns the serialized session payload for an ID. write() saves the payload. destroy() removes one session. gc() removes expired sessions. close() finishes the storage operation.

This small in-memory handler is not for production, but it shows the shape:

PHP example
<?php

declare(strict_types=1);

final class ArraySessionHandler implements SessionHandlerInterface
{
    /** @var array<string, string> */
    private array $store = [];

    public function open(string $path, string $name): bool
    {
        return true;
    }

    public function close(): bool
    {
        return true;
    }

    public function read(string $id): string|false
    {
        return $this->store[$id] ?? '';
    }

    public function write(string $id, string $data): bool
    {
        $this->store[$id] = $data;
        return true;
    }

    public function destroy(string $id): bool
    {
        unset($this->store[$id]);
        return true;
    }

    public function gc(int $max_lifetime): int|false
    {
        return 0;
    }
}

$handler = new ArraySessionHandler();
$handler->write('abc123', 'user_id|i:42;');

echo $handler->read('abc123') . PHP_EOL;

// Prints:
// user_id|i:42;

The string user_id|i:42; is PHP's session payload format. Your handler usually should not parse it. It should store and return it exactly.

Registering a handler

A handler must be registered before session_start().

PHP example
<?php

declare(strict_types=1);

$handlerClass = 'RedisSessionHandler';

echo 'Register ' . $handlerClass . ' before session_start().' . PHP_EOL;

// Prints:
// Register RedisSessionHandler before session_start().

In real code, that placeholder line would be session_set_save_handler($handler, true);, followed by session_start().

TTL and cleanup

The handler needs a cleanup strategy. File sessions use garbage collection. Redis-style storage commonly uses a TTL for each session key. Database storage may use a last_activity column and a scheduled cleanup job.

The server-side TTL should line up with the application's expected session lifetime. If storage expires data too early, users are logged out unexpectedly. If storage keeps data for too long, old sessions stay valid longer than the team intended.

PHP example
<?php

declare(strict_types=1);

$sessionLifetime = 60 * 60 * 2;
$key = 'session:abc123';

echo 'Write ' . $key . ' with TTL ' . $sessionLifetime . ' seconds' . PHP_EOL;

// Prints:
// Write session:abc123 with TTL 7200 seconds

Locking and concurrent requests

Session storage must handle concurrent requests safely. File sessions normally lock. Some custom handlers do not lock unless the implementation adds it.

Without locking, two requests from the same user can read the same old value and then overwrite each other. With locking, one request may wait while another holds the session open. The right behaviour depends on the storage backend and framework, but ignoring it creates subtle production bugs.

Failure behaviour

Session storage is on the critical path for many requests. If Redis, the database, or the network is down, the application must fail in a controlled way.

For authenticated pages, failing closed is usually better than silently treating the user as logged out and corrupting state. For public pages that do not require a session, the application may avoid starting a session at all.

What to check in a project

Find where the session handler is configured. It should happen before session_start() or through framework bootstrap.

Check what storage is used in each environment. A single-server local setup may use files while production uses Redis.

Check expiry. The storage TTL, PHP session lifetime, and application logout rules should not contradict each other.

Check locking or race handling. This is especially important for carts, flash messages, and login state.

Check observability. Production teams need logs or metrics when session storage fails.

What you should be able to do

After this lesson, you should be able to explain why custom session handlers exist, describe the main interface methods, recognise that handlers store PHP's serialized session payload, and identify the TTL, locking, and failure questions that matter in production.

Practice

Task: Build A Tiny Session Store

Build a small in-memory session store that behaves like the storage layer behind a custom session handler.

Requirements

  • Use declare(strict_types=1);.
  • Create a class that can write, read, destroy, and garbage-collect session payloads.
  • Store payloads as strings without parsing them.
  • Track an expiry timestamp for each stored session.
  • Include a normal case where a session is written and read.
  • Include an edge case where an expired session is cleaned up.
  • Print the output that proves both behaviours.

Check Your Work

Run the script and confirm that stored payloads are returned exactly and expired sessions are removed.

Show solution

This solution models the storage responsibilities separately from PHP's actual session extension. The payload is stored as a string because a real handler receives already-serialized session data.

PHP example
<?php

declare(strict_types=1);

final class TinySessionStore
{
    /** @var array<string, array{payload: string, expires_at: int}> */
    private array $sessions = [];

    public function write(string $id, string $payload, int $ttlSeconds, int $now): void
    {
        $this->sessions[$id] = [
            'payload' => $payload,
            'expires_at' => $now + $ttlSeconds,
        ];
    }

    public function read(string $id, int $now): string
    {
        $session = $this->sessions[$id] ?? null;

        if ($session === null || $session['expires_at'] <= $now) {
            return '';
        }

        return $session['payload'];
    }

    public function destroy(string $id): void
    {
        unset($this->sessions[$id]);
    }

    public function collectGarbage(int $now): int
    {
        $removed = 0;

        foreach ($this->sessions as $id => $session) {
            if ($session['expires_at'] <= $now) {
                unset($this->sessions[$id]);
                $removed++;
            }
        }

        return $removed;
    }
}

$store = new TinySessionStore();
$now = 1_700_000_000;

$store->write('abc123', 'user_id|i:42;', 3600, $now);
echo 'Read active session: ' . $store->read('abc123', $now + 60) . PHP_EOL;

$store->write('old456', 'flash|a:0:{}', 10, $now);
$removed = $store->collectGarbage($now + 20);

echo 'Expired sessions removed: ' . $removed . PHP_EOL;
echo 'Read expired session: ' . ($store->read('old456', $now + 20) ?: 'empty') . PHP_EOL;

// Prints:
// Read active session: user_id|i:42;
// Expired sessions removed: 1
// Read expired session: empty

This is not a complete SessionHandlerInterface implementation, but it demonstrates the storage rules a handler must respect: preserve payloads, apply expiry, and remove destroyed or expired sessions.

Why This Works

The active read proves the payload is returned unchanged. The garbage-collection case proves expired data is not kept forever and no longer reads as an active session.