Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Session Storage Handler Chain Laravel Package

hautelook/session-storage-handler-chain

A PHP package that chains multiple session storage handlers, delegating session reads, writes, and deletes across them for fallback, migration, or layered persistence in applications that need flexible session storage.

View on GitHub
Deep Wiki
Context7

Getting Started

First, calibrate expectations: this is a Symfony 2 bundle, not a Laravel package. Symfony 2 reached end of life in 2018, the repo has zero dependents, and the bundle's composer.json will pin symfony/framework-bundle ^2.x, which cannot coexist with the Symfony 5/6/7 components Laravel 8+ ships. Do not composer require it into a Laravel app, and do not paper over the conflict with --ignore-platform-reqs.

What you actually want from the package is the chain object: an ordered list of \SessionHandlerInterface members where

  • read($id) walks the list in order and returns the first non-empty payload (short-circuits — this is the "efficient" part),
  • write($id, $data) fans out to every member,
  • destroy($id) and gc($lifetime) fan out to every member,
  • open/close fan out to every member.

That class is small and dependency-free (typically Hautelook\SessionStorageHandlerChain\HandlerChain, a few dozen lines around an array of handlers). Check vendor/hautelook/session-storage-handler-chain/ first; if only the bundle is installable, vendor the class into app/Support/ under its MIT license with an attribution comment. The integration work is identical either way.

Minimal wiring (3 steps):

  1. Register a custom driver. The closure argument is the SessionManager, not the container — use the app() helper inside.
// app/Providers/SessionServiceProvider.php  (register in bootstrap/providers.php on L11, config/app.php on ≤L10)
use Hautelook\SessionStorageHandlerChain\HandlerChain;
use Illuminate\Session\CacheBasedSessionHandler;
use Illuminate\Session\DatabaseSessionHandler;
use Illuminate\Support\Facades\Session;

public function boot(): void
{
    Session::extend('chain', function ($manager) {
        $lifetime = (int) config('session.lifetime');

        $fast = new CacheBasedSessionHandler(
            app('cache')->store(config('session.chain.fast_store', 'redis')),
            $lifetime
        );

        $durable = new DatabaseSessionHandler(
            app('db')->connection(config('session.connection')),
            config('session.table', 'sessions'),
            $lifetime,
            app()
        );

        return new HandlerChain([$fast, $durable]);
    });
}
  1. Point the app at it: SESSION_DRIVER=chain in .env, plus a 'chain' => ['fast_store' => 'redis'] block in config/session.php. Run php artisan session:table && php artisan migrate for the database member.

  2. Verify end-to-end, not just that the container resolves: log in, confirm redis-cli --scan --pattern '*session*' shows the key and select * from sessions shows the row, then DEL the Redis key and confirm you stay logged in (read falls through to the DB).

First use case: a multi-server app already on SESSION_DRIVER=database where every page view hits MySQL for a session read. Put Redis in front as a read-through cache; the DB member preserves durability and gives you an audit/restore path. That is the only scenario where the chain reliably earns its keep on day one (see the migration pattern below for the other).

Implementation Patterns

A. Read-through cache in front of the database. Handler order = read priority. Fast store first, durable second. Accept the write amplification (see Gotchas) and measure it before shipping.

B. Zero-downtime session-store migration. This is the pattern that actually justifies the dependency. To move file → database (or redis → DynamoDB) without logging everyone out, deploy with [new, old] in the chain:

  • Reads: sessions created before the deploy exist only in old, so the short-circuit falls through to it.
  • Writes: now land in both, so any session that gets touched is migrated.
  • Cleanup: keep old as a member for at least session.lifetime (plus GC slack) after the flip, then drop it. Dropping early silently loses sessions that were never re-written.

Then flip the array to [new] and remove the old handler's config. Test with an existing cookie, not a fresh login.

C. Failover-only secondary store. Cheap version: keep the durable store last and accept that a broken member will throw inside StartSession (see Gotchas). If you need real resilience, wrap each member in a decorator that swallows exceptions and returns ''/true, and only then put it in the chain.

D. Roll-your-own members. Use Laravel's own CacheBasedSessionHandler, DatabaseSessionHandler, and FileSessionHandler as chain members instead of Symfony's MemcachedSessionHandler/PdoSessionHandler. Lifetime semantics stay consistent with config('session.lifetime'), and you avoid pulling HTTP-foundation handler deps into a Laravel app. For tests, a 20-line ArraySessionHandler implementing SessionHandlerInterface is the cleanest fake.

Integration tips:

  • Registration must happen in boot() (providers boot before middleware), so the driver exists by the time StartSession resolves config('session.driver').
  • Keep serialization in Laravel's hands. Handlers receive opaque blobs (serialize($attributes)); never JSON-encode or inspect the payload in a custom handler.
  • Illuminate\Session\Store does not require SessionUpdateTimestampHandlerInterface. If you chain a StrictSessionHandler or anything implementing it, wrap each member — the chain itself won't satisfy the interface.
  • Per-tenant / per-domain stores are possible by reading the request host inside the Session::extend closure, but session cookies and domains must line up; this is a footgun, not a feature.
  • Add a timing + logging decorator per member (before it goes into the chain). The chain hides which store answered, which makes "why is this page 400 ms slower" impossible to debug otherwise.
  • Schedule GC explicitly: Laravel's session.lottery fires probabilistically and DatabaseSessionHandler::gc() uses its constructor lifetime, not the gc($lifetime) argument. Rely on cache TTL for the fast member, and add a scheduled command that calls gc() on the durable member so sessions doesn't grow unbounded.

Test matrix worth writing once: miss-on-first → hit-on-second; write hits both; destroy empties both; gc called on both; member throwing during read; member throwing during write (does durability silently disappear?); regenerate (destroy old + write new).

Gotchas and Tips

  • It is unmaintained. Zero dependents, zero stars, Symfony 2-era release history. Treat the repo as a reference implementation of a pattern you own, not a dependency you inherit. There is no one to file a bug against and no CVE feed.
  • PHP 8.1 return types. Symfony 2-era handlers declare \SessionHandlerInterface methods without return types. On PHP 8.1+ that emits deprecation notices for read/gc/write/open. Add #[\ReturnTypeWillChange] or type them when vendoring; read(): string|false and gc(): int|false are the important ones.
  • Write ordering vs. read ordering is one decision, not two. Order controls read priority and write order. Fast-first gives you fast reads but writes to the volatile store before the durable one — a crash in between leaves the cache ahead of the DB. If you need different orders, subclass and override write().
  • TTL skew is the big consistency bug. Cache member TTL is lifetime (e.g. 120 min); DB rows can live longer. If the cache holds newer data than the DB, then when the cache entry expires the read correctly falls back — to a stale row. Symptom: a user's session appears to "roll back" (lost CSRF token, resurrected attributes, or a logged-out user who becomes logged-in again). Mitigations: write durable-first, set cache TTL ≥ durable retention, version the DB row and ignore stale versions, or — honestly — skip dual-write and use Redis with AOF as the single store.
  • read() empty-string handling. The chain's "first non-empty wins" logic treats '' as a miss. Laravel never stores an empty string (an empty session serializes to a:0:{}), so this is usually harmless — but it's the first thing to check if a session "vanishes."
  • Failure semantics are not what you hope. The chain generally does not catch member exceptions. One dead Redis node → 500s on every request inside StartSession. Worse, a swallowed write failure to the durable member silently destroys your durability guarantee; log at error and alert if you go down this path.
  • destroy() must fan out. If it only touches the first member, logout leaves a valid session in the second store — that's a session-fixation / "logged back in" bug. Assert both stores are empty in a test.
  • Write amplification. Laravel writes the session on essentially every request (Store::save()), so an N-member chain multiplies write traffic and latency by N. regenerate() costs 2×N round trips on login. Measure p99 before and after.
  • No locking, ever. No Laravel handler locks, and the chain does not add locking. Concurrent requests remain last-write-wins, and two stores can diverge under load.
  • Key namespace collisions. Redis and a shared sessions table are global. Prefix session IDs per app/environment inside a member decorator, or you will eventually debug a "wrong user's session" incident.
  • The Symfony config key (hautelook_session_storage_handler_chain) has no Laravel analogue. There is no auto-registering service provider — no compiler pass swaps framework.session.handler_id for you. All wiring is manual, which is fine, but don't go looking for package magic that isn't there.
  • Debugging playbook: php artisan tinker → get_class(app('session')->getHandler()); app('session')->put('k','v'); app('session')->save(); then inspect both stores directly; redis-cli MONITOR plus the MySQL general log confirms actual read/write order; dd()-style logging in a decorator beats guessing.
  • Extension points, in order of usefulness: decorator around \SessionHandlerInterface for timing/logging/failover/prefixing; subclass of the chain to control write order; encryption at the handler layer if session payloads are sensitive and one store is less trusted (Laravel's EncryptCookies does not cover server-side payloads).
  • Before you build any of this: confirm the chain actually buys something over SESSION_DRIVER=redis with persistence enabled. For most apps it doesn't — the chain is worth its complexity almost exclusively for zero-downtime store migrations.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment