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.
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):
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]);
});
}
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.
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).
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:
old, so the short-circuit falls through to it.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:
boot() (providers boot before middleware), so the driver exists by the time StartSession resolves config('session.driver').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.Session::extend closure, but session cookies and domains must line up; this is a footgun, not a feature.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).
\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().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."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.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.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.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.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.\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).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.How can I help you explore Laravel packages today?