Skip to content

Instantly share code, notes, and snippets.

@asentner
Last active February 17, 2017 12:47
Show Gist options
  • Save asentner/6d60887a00dae3e2eb0e310b0d3d1e80 to your computer and use it in GitHub Desktop.
Save asentner/6d60887a00dae3e2eb0e310b0d3d1e80 to your computer and use it in GitHub Desktop.
Session handler using a PDO connection to read/write session data to the database on Symfony and Laravel projects. Compatible with MySQL (innodb databases) and PostgreSQL 9.5+. If the database connection fails, this essentially becomes a NullSessionHandler and doesn't write a session at all.
<?php
namespace Acme\DemoBundle\Session\Storage\Handler;
/**
* Session handler using a PDO connection to read/write session data to the database on Symfony and
* Laravel projects.
*
* This script is intended to work with MySQL (innodb databases) and PostgreSQL 9.5+.
*
* 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.
*/
class DatabaseSessionHandler implements \SessionHandlerInterface
{
/**
* @var \PDO|null
*/
private $pdo;
/**
* @var string|null
*/
private $dsn = false;
/**
* @var string
*/
private $table = 'session';
/**
* @var string
*/
private $idCol = 'session_id';
/**
* @var string
*/
private $dataCol = 'session_data';
/**
* @var string
*/
private $lifetimeCol = 'session_lifetime';
/**
* @var string
*/
private $timeCol = 'session_time';
/**
* @var string
*/
private $username = '';
/**
* @var string
*/
private $password = '';
/**
* @var array
*/
private $connectionOptions = [];
/**
* @var string
*/
private $driver;
/**
* @var bool
*/
private $sessionExpired = false;
/**
* @var bool
*/
private $inTransaction = false;
/**
* @var bool
*/
private $gcCalled = false;
/**
* DatabaseSessionHandler constructor.
* @param null $dsn
* @param array $options
*/
public function __construct($dsn = null, array $options = [])
{
$this->dsn = $dsn;
$this->table = isset($options['db_table']) ? $options['db_table'] : $this->table;
$this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol;
$this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol;
$this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol;
$this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol;
$this->username = isset($options['db_username']) ? $options['db_username'] : $this->username;
$this->password = isset($options['db_password']) ? $options['db_password'] : $this->password;
$this->connectionOptions = isset($options['db_connection_options']) ?
$options['db_connection_options'] : $this->connectionOptions;
}
public function createTable()
{
$this->openConnection();
switch ($this->driver) {
case 'mysql':
$sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB";
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;
default:
throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver));
}
try {
$this->pdo->exec($sql);
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
/**
* @return bool
*/
public function isSessionExpired()
{
return $this->sessionExpired;
}
/**
* {@inheritdoc}
*/
public function close()
{
if (null !== $this->pdo) {
$this->commit();
if ($this->gcCalled) {
$this->gcCalled = false;
// delete the session records that have expired
$sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
$stmt->execute();
}
$this->pdo = null;
}
return true;
}
/**
* {@inheritdoc}
*/
public function destroy($sessionId)
{
if (null !== $this->pdo) {
// delete the record associated with this id
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->execute();
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
return true;
}
/**
* {@inheritdoc}
*/
public function gc($maxlifetime)
{
$this->gcCalled = true;
return true;
}
/**
* {@inheritdoc}
*/
public function open($savePath, $name)
{
try {
$this->pdo = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions);
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
}
finally {
return true;
}
}
/**
* {@inheritdoc}
*/
public function read($sessionId)
{
if (null !== $this->pdo) {
try {
return $this->doRead($sessionId);
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
return '';
}
/**
* {@inheritdoc}
*/
public function write($sessionId, $data)
{
$maxlifetime = (int) ini_get('session.gc_maxlifetime');
if (null !== $this->pdo) {
try {
switch($this->driver) {
case 'mysql':
$mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
"ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
break;
case 'pgsql':
$mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
"ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
break;
}
$mergeStmt = $this->pdo->prepare($mergeSql);
$mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
$mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
$mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
if (null !== $mergeStmt) {
$mergeStmt->execute();
return true;
}
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
return true;
}
private function beginTransaction()
{
if (!$this->inTransaction) {
if ('mysql' === $this->driver) {
$this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
}
$this->pdo->beginTransaction();
$this->inTransaction = true;
}
}
private function commit()
{
if ($this->inTransaction) {
try {
$this->pdo->commit();
$this->inTransaction = false;
}
catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
}
private function rollback()
{
if ($this->inTransaction) {
$this->pdo->rollBack();
$this->inTransaction = false;
}
}
/**
* @param $sessionId
* @return string
*/
private function doRead($sessionId)
{
$this->sessionExpired = false;
$this->beginTransaction();
$selectStmt = $this->pdo->prepare("SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE");
$selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
do {
$selectStmt->execute();
$sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
if ($sessionRows) {
if ($sessionRows[0][1] + $sessionRows[0][2] < time()) {
$this->sessionExpired = true;
return '';
}
return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
}
try {
$insertStmt = $this->pdo->prepare(
"INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"
);
$insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$insertStmt->bindValue(':data', '', \PDO::PARAM_LOB);
$insertStmt->bindValue(':lifetime', 0, \PDO::PARAM_INT);
$insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
$insertStmt->execute();
} catch (\PDOException $e) {
// Catch duplicate key error because other connection created the session already.
// It would only not be the case when the other connection destroyed the session.
if (0 === strpos($e->getCode(), '23')) {
$this->rollback();
$this->beginTransaction();
continue;
}
throw $e;
}
return '';
} while (true);
}
/**
* @return \PDO
*/
protected function openConnection()
{
if (null === $this->pdo) {
$this->pdo = new \PDO($this->dsn, $this->username, $this->password, $this->connectionOptions);
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
}
return $this->pdo;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment