* Session handler using a PDO connection to read and write data.
*
* It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements
* different locking strategies to handle concurrent access to the same session.
* Locking is necessary to prevent loss of data due to race conditions and to keep
* the session data consistent between read() and write(). With locking, requests
* for the same session will wait until the other one finished writing. For this
* reason it's best practice to close a session as early as possible to improve
* concurrency. PHPs internal files session handler also implements locking.
*
* Attention: Since SQLite does not support row level locks but locks the whole database,
* it means only one session can be accessed at a time. Even different sessions would wait
* for another to finish. So saving session in SQLite should only be considered for
* development or prototypes.
*
* Session data is a binary string that can contain non-printable characters like the null byte.
* For this reason it must be saved in a binary column in the database like BLOB in MySQL.
* Saving it in a character column could corrupt the data. You can use createTable()
* to initialize a correctly defined table.
*
* @see https://php.net/sessionhandlerinterface
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Michael Williams <michael.williams@funsational.com>
* @author Tobias Schultze <http://tobion.de>
*/
class PdoSessionHandler extends AbstractSessionHandler
{
/**
* No locking is done. This means sessions are prone to loss of data due to
* race conditions of concurrent requests to the same session. The last session
* write will win in this case. It might be useful when you implement your own
* logic to deal with this like an optimistic approach.
*/
public const LOCK_NONE = 0;
/**
* Creates an application-level lock on a session. The disadvantage is that the
* lock is not enforced by the database and thus other, unaware parts of the
* application could still concurrently modify the session. The advantage is it
* does not require a transaction.
* This mode is not available for SQLite and not yet implemented for oci and sqlsrv.
*/
public const LOCK_ADVISORY = 1;
/**
* Issues a real row lock. Since it uses a transaction between opening and
* closing a session, you have to be careful when you use same database connection
* that you also use for your application logic. This mode is the default because
* it's the only reliable solution across DBMSs.
*/
public const LOCK_TRANSACTIONAL = 2;
private const MAX_LIFETIME = 315576000;
/**
* @var \PDO|null PDO instance or null when not connected yet
*/
private $pdo;
/**
* DSN string or null for session.save_path or false when lazy connection disabled.
*
* @var string|false|null
*/
private $dsn = false;
/**
* @var string|null
*/
private $driver;
/**
* @var string
*/
private $table = 'sessions';
/**
* @var string
*/
private $idCol = 'sess_id';
/**
* @var string
*/
private $dataCol = 'sess_data';
/**
* @var string
*/
private $lifetimeCol = 'sess_lifetime';
/**
* @var string
*/
private $timeCol = 'sess_time';
/**
* Username when lazy-connect.
*
* @var string
*/
private $username = '';
/**
* Password when lazy-connect.
*
* @var string
*/
private $password = '';
/**
* Connection options when lazy-connect.
*
* @var array
*/
private $connectionOptions = [];
/**
* The strategy for locking, see constants.
*
* @var int
*/
private $lockMode = self::LOCK_TRANSACTIONAL;
/**
* It's an array to support multiple reads before closing which is manual, non-standard usage.
*
* @var \PDOStatement[] An array of statements to release advisory locks
*/
private $unlockStatements = [];
/**
* True when the current session exists but expired according to session.gc_maxlifetime.
*
* @var bool
*/
private $sessionExpired = false;
/**
* Whether a transaction is active.
*
* @var bool
*/
private $inTransaction = false;
/**
* Whether gc() has been called.
*
* @var bool
*/
private $gcCalled = false;
/**
* You can either pass an existing database connection as PDO instance or
* pass a DSN string that will be used to lazy-connect to the database
* when the session is actually used. Furthermore it's possible to pass null
* which will then use the session.save_path ini setting as PDO DSN parameter.
*
* List of available options:
* * db_table: The name of the table [default: sessions]
* * db_id_col: The column where to store the session id [default: sess_id]
* * db_data_col: The column where to store the session data [default: sess_data]
* * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime]
* * db_time_col: The column where to store the timestamp [default: sess_time]
* * db_username: The username when lazy-connect [default: '']
* * db_password: The password when lazy-connect [default: '']
* * db_connection_options: An array of driver-specific connection options [default: []]
* * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL]
*
* @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or URL string or null
*
* @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
*/
public function __construct($pdoOrDsn = null, array $options = [])
{
if ($pdoOrDsn instanceof \PDO) {
if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
throw new \InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)).', __CLASS__));
* Creates the table to store sessions which can be called once for setup.
*
* Session ID is saved in a column of maximum length 128 because that is enough even
* for a 512 bit configured session.hash_function like Whirlpool. Session data is
* saved in a BLOB. One could also use a shorter inlined varbinary column
* if one was sure the data fits into it.
*
* @throws \PDOException When the table already exists
* @throws \DomainException When an unsupported PDO driver is used
*/
public function createTable()
{
// connect if we are not yet
$this->getConnection();
switch ($this->driver) {
case 'mysql':
// We use varbinary for the ID column because it prevents unwanted conversions:
// - character set conversions between server and client
// - trailing space removal
// - case-insensitivity
// - language processing like é == e
$sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB";
break;
case 'sqlite':
$sql = "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)";
break;
case 'pgsql':
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)";
break;
case 'oci':
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR2(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)";
break;
case 'sqlsrv':
$sql = "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)";
break;
default:
throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver));
}
try {
$this->pdo->exec($sql);
$this->pdo->exec("CREATE INDEX EXPIRY ON $this->table ($this->lifetimeCol)");
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
/**
* Returns true when the current session exists but expired according to session.gc_maxlifetime.
*
* Can be used to distinguish between a new session and one that expired due to inactivity.
*
* @return bool
*/
public function isSessionExpired()
{
return $this->sessionExpired;
}
/**
* @return bool
*/
#[\ReturnTypeWillChange]
public function open($savePath, $sessionName)
{
$this->sessionExpired = false;
if (null === $this->pdo) {
$this->connect($this->dsn ?: $savePath);
}
return parent::open($savePath, $sessionName);
}
/**
* @return string
*/
#[\ReturnTypeWillChange]
public function read($sessionId)
{
try {
return parent::read($sessionId);
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
/**
* @return int|false
*/
#[\ReturnTypeWillChange]
public function gc($maxlifetime)
{
// We delay gc() to close() so that it is executed outside the transactional and blocking read-write process.
// This way, pruning expired sessions does not block them from being started while the current session is used.
$this->gcCalled = true;
return 0;
}
/**
* {@inheritdoc}
*/
protected function doDestroy(string $sessionId)
{
// delete the record associated with this id
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
// Doctrine DBAL supports passing its internal pdo_* driver names directly too (allowing both dashes and underscores). This allows supporting the same here.
if (str_starts_with($driver, 'pdo_') || str_starts_with($driver, 'pdo-')) {
$dbName = substr($params['path'], 1); // Remove the leading slash
$dsn .= 'dbname='.$dbName.';';
}
return $dsn;
}
}
// If "unix_socket" is not in the query, we continue with the same process as pgsql
// no break
case 'pgsql':
$dsn ?? $dsn = 'pgsql:';
if (isset($params['host']) && '' !== $params['host']) {
$dsn .= 'host='.$params['host'].';';
}
if (isset($params['port']) && '' !== $params['port']) {
$dsn .= 'port='.$params['port'].';';
}
if (isset($params['path'])) {
$dbName = substr($params['path'], 1); // Remove the leading slash
$dsn .= 'dbname='.$dbName.';';
}
return $dsn;
case 'sqlite':
return 'sqlite:'.substr($params['path'], 1);
case 'sqlsrv':
$dsn = 'sqlsrv:server=';
if (isset($params['host'])) {
$dsn .= $params['host'];
}
if (isset($params['port']) && '' !== $params['port']) {
$dsn .= ','.$params['port'];
}
if (isset($params['path'])) {
$dbName = substr($params['path'], 1); // Remove the leading slash
$dsn .= ';Database='.$dbName;
}
return $dsn;
default:
throw new \InvalidArgumentException(sprintf('The scheme "%s" is not supported by the PdoSessionHandler URL configuration. Pass a PDO DSN directly.', $params['scheme']));
}
}
/**
* Helper method to begin a transaction.
*
* Since SQLite does not support row level locks, we have to acquire a reserved lock
* on the database immediately. Because of https://bugs.php.net/42766 we have to create
* such a transaction manually which also means we cannot use PDO::commit or
* PDO::rollback or PDO::inTransaction for SQLite.
*
* Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
* due to https://percona.com/blog/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .