phpdot / session
Secure session management with pluggable handlers, flash data, CSRF tokens, and PSR-15 middleware.
Requires
- php: >=8.3
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- ext-redis: Required for RedisHandler
README
Secure session management for PHP. Pluggable storage handlers, flash data, CSRF tokens, and PSR-15 middleware.
No session_start(). No $_SESSION. No global state. Works with Swoole, RoadRunner, FPM, or any PSR-15 stack.
Install
composer require phpdot/session
Quick Start
use PHPdot\Session\Handler\FileHandler; use PHPdot\Session\Middleware\SessionMiddleware; use PHPdot\Session\SessionConfig; use PHPdot\Session\SessionManager; $config = new SessionConfig(name: 'sid', lifetime: 3600); $handler = new FileHandler('/tmp/sessions'); $manager = new SessionManager($handler, $config); // Register as global middleware $router->middleware(SessionMiddleware::class);
Three objects. One middleware. Sessions work.
Architecture
Request Lifecycle
MIDDLEWARE
┌─────────────────────────────────────────────────────────┐
│ │
│ Request arrives │
│ │ │
│ ▼ │
│ Read cookie ─── "sid=a1b2c3..." │
│ │ │
│ ▼ │
│ SessionManager::start($cookieId) │
│ │ │
│ ├── Cookie valid + exists in storage? │
│ │ YES → resume (deserialize, rotate flash) │
│ │ NO → create new session │
│ │ │
│ ▼ │
│ Attach Session to request attribute │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ Application handles request │ │
│ │ │ │
│ │ $session->set('user_id', 42) │ │
│ │ $session->flash('msg', 'OK') │ │
│ │ $session->regenerate() │ │
│ │ │ │
│ │ return Response │ │
│ └─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ SessionManager::save($session) │
│ │ - update lastActivity │
│ │ - age flash data │
│ │ - serialize → write to handler │
│ │ │
│ ▼ │
│ Set-Cookie header added to response │
│ │ │
│ ▼ │
│ Response sent │
│ │
└─────────────────────────────────────────────────────────┘
Package Structure
src/
├── Contract/
│ ├── SessionInterface.php Public API
│ ├── SessionHandlerInterface.php Storage backend contract
│ └── SerializerInterface.php Encode/decode contract
│
├── Handler/
│ ├── FileHandler.php File-based with flock()
│ ├── RedisHandler.php Redis with TTL expiration
│ ├── ArrayHandler.php In-memory (testing)
│ └── NullHandler.php No-op (stateless APIs)
│
├── Serializer/
│ ├── JsonSerializer.php Safe default
│ └── PhpSerializer.php PHP serialize (no object injection)
│
├── Middleware/
│ └── SessionMiddleware.php PSR-15 lifecycle manager
│
├── Exception/
│ ├── SessionException.php Base exception
│ ├── SessionExpiredException.php Idle timeout
│ ├── SessionReadException.php Storage read failure
│ └── SessionWriteException.php Storage write failure
│
├── Session.php Mutable session instance
├── SessionManager.php Lifecycle orchestrator
├── SessionConfig.php Immutable configuration
└── SessionId.php Cryptographic ID value object
Session API
Data Access
$session->set('user_id', 42); $session->get('user_id'); // 42 $session->get('missing', 'default'); // 'default' $session->has('user_id'); // true $session->remove('user_id'); $session->all(); // all data as array $session->clear(); // remove everything
Flash Data
Flash data lives for exactly one more request, then disappears automatically.
// Request 1: set flash $session->flash('success', 'Profile updated!'); // Request 2: read flash $session->getFlash('success'); // 'Profile updated!' $session->hasFlash('success'); // true // Request 3: gone $session->getFlash('success'); // null
Keep flash data alive for another request:
$session->reflash(); // keep ALL flash data $session->keep(['success']); // keep specific keys only
CSRF Tokens
$token = $session->token(); // get or generate $token = $session->regenerateToken(); // force new token // In your form <input type="hidden" name="_token" value="<?= $session->token() ?>"> // In your middleware if (!hash_equals($session->token(), $request->input('_token'))) { return $response->forbidden(); }
Session Lifecycle
$session->id(); // 64-char hex string $session->isStarted(); // true after data loaded // Regenerate ID (after login — prevents session fixation) $session->regenerate(destroy: true); // Destroy session (logout — clears data + regenerates ID) $session->invalidate(); // Metadata $session->createdAt(); // unix timestamp $session->lastActivity(); // unix timestamp
Handlers
FileHandler
File-based storage with advisory locking. No extensions required.
use PHPdot\Session\Handler\FileHandler; $handler = new FileHandler('/var/sessions');
- Shared locks (
LOCK_SH) for reads, exclusive locks (LOCK_EX) for writes - Creates the directory automatically if it doesn't exist
- GC scans the directory and removes files older than the lifetime
- Files stored as
sess_{id}
RedisHandler
Redis storage with automatic TTL expiration. Requires ext-redis.
use PHPdot\Session\Handler\RedisHandler; $handler = new RedisHandler($redis, prefix: 'session:');
- Uses
SETEXfor writes — Redis handles expiration automatically gc()is a no-op — TTL does the work- Keys stored as
{prefix}{id} - Wraps
\RedisExceptioninto typedSessionReadException/SessionWriteException
ArrayHandler
In-memory storage for testing. Data does not persist between requests.
use PHPdot\Session\Handler\ArrayHandler; $handler = new ArrayHandler();
NullHandler
No-op handler for stateless APIs. All reads return empty, all writes are discarded.
use PHPdot\Session\Handler\NullHandler; $handler = new NullHandler();
Custom Handler
Implement SessionHandlerInterface:
use PHPdot\Session\Contract\SessionHandlerInterface; final class MongoHandler implements SessionHandlerInterface { public function __construct( private readonly \MongoDB\Database $db, ) {} public function read(string $id): string { $doc = $this->db->selectCollection('sessions')->findOne(['_id' => $id]); return $doc['data'] ?? ''; } public function write(string $id, string $data, int $lifetime): void { $this->db->selectCollection('sessions')->updateOne( ['_id' => $id], ['$set' => ['data' => $data, 'expires_at' => time() + $lifetime]], ['upsert' => true], ); } public function destroy(string $id): void { $this->db->selectCollection('sessions')->deleteOne(['_id' => $id]); } public function exists(string $id): bool { return $this->db->selectCollection('sessions')->countDocuments(['_id' => $id]) > 0; } public function gc(int $lifetime): int { $result = $this->db->selectCollection('sessions')->deleteMany([ 'expires_at' => ['$lt' => time()], ]); return $result->getDeletedCount(); } }
Serializers
JsonSerializer (default)
Safe. No object injection risk. Handles strings, numbers, booleans, nulls, and arrays.
use PHPdot\Session\Serializer\JsonSerializer; $manager = new SessionManager($handler, $config, new JsonSerializer());
PhpSerializer
Uses PHP's native serialize()/unserialize() with allowed_classes: false to prevent object injection attacks.
use PHPdot\Session\Serializer\PhpSerializer; $manager = new SessionManager($handler, $config, new PhpSerializer());
Custom Serializer
use PHPdot\Session\Contract\SerializerInterface; final class MsgpackSerializer implements SerializerInterface { public function encode(array $data): string { return msgpack_pack($data); } public function decode(string $data): array { return $data === '' ? [] : msgpack_unpack($data); } }
Configuration
use PHPdot\Session\SessionConfig; $config = new SessionConfig( name: 'phpdot_session', // cookie name lifetime: 7200, // seconds (0 = browser session) path: '/', // cookie path domain: '', // cookie domain ('' = current host) secure: true, // Secure flag httpOnly: true, // HttpOnly flag sameSite: 'Lax', // Strict, Lax, None partitioned: false, // CHIPS (Partitioned cookies) gcProbability: 2, // % chance GC runs per request (0 = disabled) savePath: '/tmp/sessions', // FileHandler directory );
All properties are readonly. Create a new SessionConfig for different settings.
Middleware
PSR-15 Integration
The middleware handles the full lifecycle: start, attach, save, cookie.
use PHPdot\Session\Middleware\SessionMiddleware; $middleware = new SessionMiddleware($manager); // Access the session in your handler $session = $request->getAttribute(SessionMiddleware::ATTRIBUTE); // or $session = $request->getAttribute('session');
What the Middleware Does
- Reads the session cookie from the request
- Calls
$manager->start($cookieId)— resumes or creates - Attaches the
Sessionto the request as attribute'session' - Calls
$handler->handle($request)— your application runs - Saves the session via
$manager->save($session)(even if the handler throws) - Adds the
Set-Cookieheader to the response - Destroys the old session if
regenerate(destroy: true)orinvalidate()was called
Framework Integration (Swoole + PHP-DI)
With a DI container, inject SessionInterface directly into controllers:
use PHPdot\Session\Contract\SessionInterface; final class DashboardController implements ControllerInterface { public function __construct( private readonly SessionInterface $session, private readonly ResponseFactory $response, ) {} public function index(): ResponseInterface { $name = $this->session->get('name', 'Guest'); return $this->response->html("Hello, {$name}"); } public function login(): ResponseInterface { $this->session->set('user_id', 42); $this->session->regenerate(destroy: true); $this->session->flash('welcome', 'Logged in!'); return $this->response->redirect('/dashboard'); } public function logout(): ResponseInterface { $this->session->invalidate(); return $this->response->redirect('/'); } }
DI Wiring with Connection Pools
When using connection pools (e.g., Redis pool in Swoole), the handler receives a scoped connection via DI. The developer never interacts with the pool directly:
// Config and serializer are stateless — singleton SessionConfig::class => singleton(fn () => new SessionConfig( name: $config->string('session.name'), lifetime: $config->int('session.lifetime'), )), SerializerInterface::class => singleton(fn () => new JsonSerializer()), // Redis connection: scoped (one per coroutine, auto-returned via defer) \Redis::class => scoped(function (ContainerInterface $c) { $pool = $c->get(Pool::class); $redis = $pool->borrow(); Coroutine::defer(fn () => $pool->release($redis)); return $redis; }), // Handler: scoped (holds scoped \Redis) SessionHandlerInterface::class => scoped( fn (ContainerInterface $c) => new RedisHandler($c->get(\Redis::class)), ), // Manager: scoped (holds scoped handler) SessionManager::class => scoped( fn (ContainerInterface $c) => new SessionManager( $c->get(SessionHandlerInterface::class), $c->get(SessionConfig::class), $c->get(SerializerInterface::class), ), ),
Singleton (one per worker) Scoped (one per coroutine)
────────────────────────── ────────────────────────────
SessionConfig \Redis ◄── pool borrow + defer
JsonSerializer RedisHandler (holds \Redis)
Pool SessionManager (holds handler)
Session (data, flash, id)
Flash Data Internals
Flash data is stored alongside regular session data using reserved keys:
Request N: $session->flash('msg', 'hello')
data = { msg: 'hello', _flash_new: ['msg'], _flash_old: [] }
save: _flash_old keys deleted (none), data persisted
Request N+1: load
_flash_old = previous _flash_new = ['msg']
_flash_new = []
getFlash('msg') → 'hello' (still in data)
save: ageFlash() deletes 'msg' from data
Request N+2: load
_flash_old = []
getFlash('msg') → null
Session ID
- 32 bytes of randomness (256 bits) via
random_bytes() - Encoded as 64 lowercase hex characters
- Exceeds OWASP recommendation of 128 bits minimum
- Constant-time comparison via
hash_equals()to prevent timing attacks - Strict validation: only
/^[a-f0-9]{64}$/accepted
Garbage Collection
| Handler | Strategy |
|---|---|
| FileHandler | Scans directory, deletes files with mtime older than lifetime |
| RedisHandler | No-op — Redis TTL handles expiration automatically |
| ArrayHandler | Iterates storage, removes expired entries |
| NullHandler | No-op |
GC runs probabilistically at the start of each $manager->start() call:
gcProbability: 2→ 2% chance per request (default)gcProbability: 0→ disabled (run GC from a scheduled task instead)gcProbability: 100→ every request (testing only)
Exceptions
SessionException (extends RuntimeException)
├── SessionExpiredException idle timeout exceeded
├── SessionReadException storage read failure
└── SessionWriteException storage write failure
All leaf exceptions carry the sessionId that failed:
try { $session = $manager->start($cookieId); } catch (SessionExpiredException $e) { $e->getSessionId(); // the expired session ID }
Security
| Threat | Mitigation |
|---|---|
| Session fixation | regenerate(destroy: true) on login |
| Session hijacking | Secure, HttpOnly, SameSite cookie defaults |
| Brute force | 256-bit random IDs (64 hex chars) |
| Timing attacks | hash_equals() in SessionId::equals() |
| Object injection | JsonSerializer default; PhpSerializer uses allowed_classes: false |
| Data leakage | invalidate() clears data + destroys old session + regenerates ID |
| Idle timeout | lastActivity checked on resume, configurable via lifetime |
Development
composer test # Run tests (169 tests) composer analyse # PHPStan level 10 composer cs-fix # Fix code style composer cs-check # Check code style (dry run) composer check # Run all three
License
MIT