phpdot/session

Secure session management with pluggable handlers, flash data, CSRF tokens, and PSR-15 middleware.

Maintainers

Package info

github.com/phpdot/session

pkg:composer/phpdot/session

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-04-12 12:14 UTC

This package is auto-updated.

Last update: 2026-04-12 12:15:32 UTC


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 SETEX for writes — Redis handles expiration automatically
  • gc() is a no-op — TTL does the work
  • Keys stored as {prefix}{id}
  • Wraps \RedisException into typed SessionReadException / 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

  1. Reads the session cookie from the request
  2. Calls $manager->start($cookieId) — resumes or creates
  3. Attaches the Session to the request as attribute 'session'
  4. Calls $handler->handle($request) — your application runs
  5. Saves the session via $manager->save($session) (even if the handler throws)
  6. Adds the Set-Cookie header to the response
  7. Destroys the old session if regenerate(destroy: true) or invalidate() 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