Save PHP Sessions to MySQL Database (Custom SessionHandlerInterface)
When running PHP or WordPress workloads on load-balanced servers or managed hosting platforms like WP Engine, default filesystem-based PHP sessions (/var/lib/php/sessions) often fail because consecutive HTTP requests can be routed to different application nodes or stripped by edge page caching.
While in-memory stores like Redis are ideal for high-throughput clusters, persisting PHP sessions in MySQL using PHP’s built-in SessionHandlerInterface provides a zero-dependency solution when a dedicated Redis instance is unavailable.
Disclaimer: Evaluate your concurrency requirements before storing high-volume session data in a relational database. You can adapt the PDO handler below to your environment and review the official PHP references at the end of this guide.
Create the MySQL Sessions Table (utf8mb4 + Expiration Index)
Create a dedicated sessions table using InnoDB and utf8mb4. Adding a secondary index on the access timestamp column (idx_access) is critical so garbage collection (DELETE FROM sessions WHERE access < ?) uses an index range scan instead of locking the entire table:
CREATE DATABASE IF NOT EXISTS `wp_sessions`
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `wp_sessions`;
CREATE TABLE `sessions` (
`id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,
`access` int unsigned NOT NULL,
`data` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_access` (`access`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Implement SessionHandlerInterface with PDO
A complete custom session handler in PHP implements six lifecycle methods defined by SessionHandlerInterface:
open: Initializes the PDO database connection.read: Fetches the serialized session payload by session ID.write: Performs an atomic upsert (INSERT ... ON DUPLICATE KEY UPDATEorREPLACE INTO) with the current Unix timestamp.destroy: Deletes a specific session row whensession_destroy()is invoked.close: Releases the PDO database connection handle.gc: Purges expired session rows older than$lifetimeseconds.
Save the following class as sessionhandler.class.php:
<?php
class WpSessionHandler implements SessionHandlerInterface
{
private ?PDO $db = null;
public function open(string $savePath, string $sessionName): bool
{
$host = getenv('SESSION_DB_HOST') ?: 'localhost';
$dbname = getenv('SESSION_DB_NAME') ?: 'wp_sessions';
$user = getenv('SESSION_DB_USER') ?: 'user';
$pass = getenv('SESSION_DB_PASS') ?: 'password';
try {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $host, $dbname);
$this->db = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return true;
} catch (PDOException $e) {
error_log('Session DB connection failed: ' . $e->getMessage());
return false;
}
}
public function read(string $id): string|false
{
if (!$this->db) {
return '';
}
$query = $this->db->prepare('SELECT data FROM sessions WHERE id = ? LIMIT 1;');
$query->execute([$id]);
$row = $query->fetch();
return $row === false ? '' : (string) $row->data;
}
public function write(string $id, string $data): bool
{
if (!$this->db) {
return false;
}
$access = time();
$sql = 'INSERT INTO sessions (id, access, data) VALUES (?, ?, ?) '
. 'ON DUPLICATE KEY UPDATE access = VALUES(access), data = VALUES(data);';
$query = $this->db->prepare($sql);
return $query->execute([$id, $access, $data]);
}
public function destroy(string $id): bool
{
if (!$this->db) {
return false;
}
$query = $this->db->prepare('DELETE FROM sessions WHERE id = ?;');
return $query->execute([$id]);
}
public function close(): bool
{
$this->db = null;
return true;
}
public function gc(int $max_lifetime): int|false
{
if (!$this->db) {
return false;
}
$expiration = time() - $max_lifetime;
$query = $this->db->prepare('DELETE FROM sessions WHERE access < ?;');
$query->execute([$expiration]);
return $query->rowCount();
}
}
Register the Handler & Bypass Reverse-Proxy Caching
Initialize the session handler inside your WordPress plugin or application bootstrap before any output is sent:
require_once __DIR__ . '/sessionhandler.class.php';
$handler = new WpSessionHandler();
session_set_save_handler($handler, true);
// Use a custom session cookie name and configure secure cookie flags
session_name('my_session_name');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
If you are hosting on WP Engine or behind Cloudflare / Varnish, make sure the edge cache is configured to bypass or vary on your custom cookie name (my_session_name) for routes that depend on active user sessions.
References
Related Articles
PHP sessions support
How to check if host support PHP sessions
Update WordPress URL in MySQL & WP-CLI Without Breaking Serialized Data
How to safely update your WordPress domain URL in MySQL using SQL transactions or WP-CLI search-replace without corrupting PHP serialized options.
PHP script timed out before returning headers
Directadmin throws an error: suPHP error script timed out before returning headers