adt / doctrine-session-handler
Doctrine session handler
Package info
github.com/AppsDevTeam/doctrine-session-handler
pkg:composer/adt/doctrine-session-handler
Requires
- php: >=8.1
- doctrine/dbal: ^3.6 || ^4.0
- doctrine/orm: ^2.14 || ^3.0
- symfony/console: ^6.0 || ^7.0 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^11.5 | ^12.0 | ^13.0
- symfony/cache: ^6.0 | ^7.0 | ^8.0
Suggests
- adt/doctrine-components: ^3.7.1 - adds the index on expiresAt declared on SessionTrait to the entity
Provides
None
Conflicts
None
Replaces
None
README
composer require adt/doctrine-session-handler
<?php namespace App\Entity; use ADT\DoctrineSessionHandler\SessionInterface; use ADT\DoctrineSessionHandler\SessionTrait; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity */ #[ORM\Entity] class SessionStorage extends BaseEntity implements SessionInterface { use SessionTrait; /** * @var integer * * @ORM\Id * @ORM\Column(type="integer", nullable=false) * @ORM\GeneratedValue */ #[ORM\Id] #[ORM\Column(nullable: false)] #[ORM\GeneratedValue] public int $id; }
services: sessionHandler: ADT\DoctrineSessionHandler\Handler(\App\Entity\SessionStorage) session: autoStart: smart handler: @sessionHandler
Unchanged sessions are not written
Handler implements SessionUpdateTimestampHandlerInterface. With session.lazy_write
(PHP default) PHP calls updateTimestamp() instead of write() when a request did not change
the session, and the handler then only moves expiresAt - and only once it would move by at
least refreshInterval seconds (default 60). Most requests (AJAX, polling) therefore write
nothing; a write on every request is expensive, on a Galera cluster each one is replicated to
all nodes. A session that never stored anything gets no row at all.
The row is loaded once per request and shared by validateId() (strict mode), read() and
write(). An expired session is rejected by validateId(), so PHP gives the client a new ID.
services: sessionHandler: ADT\DoctrineSessionHandler\Handler(\App\Entity\SessionStorage, refreshInterval: 60)
Index on expiresAt (required)
Cleaning up expired sessions filters by expiresAt, and without an index it degrades
into a full table scan over the whole (typically large) session table.
SessionTrait declares the index, but Doctrine reads #[Index] only from the entity
class and ignores it on traits. It reaches the entity through TraitIndexListener from
adt/doctrine-components (3.7.1+),
registered via its extension:
extensions: doctrineComponents: ADT\DoctrineComponents\DI\DoctrineComponentsExtension
adt/fancyadmin registers the listener itself when the extension is missing.
Without the listener, declare the index on your entity:
#[ORM\Index(fields: ['expiresAt'])] #[ORM\Entity] class SessionStorage extends BaseEntity implements SessionInterface
An index the entity declares on the same column takes precedence over the trait's one, whatever its name.
Cleaning up expired sessions
PHP's inline garbage collection (session.gc_probability) deletes all expired
sessions in a single statement in the middle of a request. On a large session table
that holds locks over a wide range and on a Galera cluster it overflows the
write-set limit:
SQLSTATE[HY000]: General error: 1180 Got error 5 - 'size_exceeded_error' during COMMIT
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded
The cleanup then keeps failing, expired sessions are never removed, the table grows without bound and session I/O saturates PHP-FPM.
Therefore: turn off inline GC and clean up from cron in batches.
services: sessionCleaner: ADT\DoctrineSessionHandler\SessionCleaner(\App\Entity\SessionStorage) - ADT\DoctrineSessionHandler\CleanupCommand
; php.ini / pool config session.gc_probability = 0
# cron, e.g. every 10 minutes php bin/console session:cleanup # options php bin/console session:cleanup --batch-size=5000 --sleep=0.2 -v php bin/console session:cleanup --max-batches=10 # bounded run
Handler::gc() (inline GC) is batched too and by default deletes at most one
batch, so even when it does run it cannot overflow the write-set. You can tune it
via the constructor:
services: sessionHandler: ADT\DoctrineSessionHandler\Handler(\App\Entity\SessionStorage, gcBatchSize: 1000, gcMaxBatches: 1)
Keep the session table small
Sessions are created for every visitor without a cookie (including bots), so also
consider a short session.expiration and avoid touching the session on requests
that do not need it — anything writing to the session (flash messages,
storeRequest(), CSRF tokens) creates a row.
Tests
composer install
composer test