Carlos Sura

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:

  1. open: Initializes the PDO database connection.
  2. read: Fetches the serialized session payload by session ID.
  3. write: Performs an atomic upsert (INSERT ... ON DUPLICATE KEY UPDATE or REPLACE INTO) with the current Unix timestamp.
  4. destroy: Deletes a specific session row when session_destroy() is invoked.
  5. close: Releases the PDO database connection handle.
  6. gc: Purges expired session rows older than $lifetime seconds.

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