Search by

adt / doctrine-session-handler

appsdevteam

Doctrine session handler

Package info

github.com/AppsDevTeam/doctrine-session-handler

pkg:composer/adt/doctrine-session-handler

Statistics

Installs: 28 682

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.8 2026-11-08 15:05 UTC

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