[PHP]session_set_save_handler()完全解説|カスタムセッションハンドラの実装とデータベース・Redis保存の実践例

PHP

はじめに

PHPのセッションはデフォルトでファイルシステムに保存されますが、本番環境ではデータベースやRedis・Memcachedへの保存が求められる場面が多くあります。複数サーバー構成でのセッション共有、セッションデータの監査ログ、有効期限の細かい制御など、標準のファイルベースハンドラでは対応できないケースに対応するのが session_set_save_handler() です。

この関数を使うと、セッションの**open・close・read・write・destroy・gc(ガベージコレクション)**という6つの操作を自分で実装したカスタムハンドラに差し替えられます。PHP 5.4以降は SessionHandlerInterface を実装したクラスを渡す形式が主流となり、よりオブジェクト指向的に扱えるようになっています。


関数の概要

項目内容
関数名session_set_save_handler()
所属PHP セッション関数
導入バージョンPHP 4以降(クラス形式は PHP 5.4以降)
PHP 8.x対応済み

構文

クラス形式(PHP 5.4以降・推奨)

session_set_save_handler(SessionHandlerInterface $handler, bool $register_shutdown = true): bool

コールバック形式(後方互換)

session_set_save_handler(
    callable $open,
    callable $close,
    callable $read,
    callable $write,
    callable $destroy,
    callable $gc,
    callable $create_sid = ?,
    callable $validate_sid = ?,
    callable $update_timestamp = ?
): bool

戻り値

  • 成功時:true
  • 失敗時:false

$register_shutdown パラメータ

動作
true(デフォルト)内部で session_register_shutdown() を自動呼び出し
false自動登録しない。session_register_shutdown() を明示的に呼ぶ必要がある

通常は true のままで問題ありませんが、シャットダウン処理の順序を細かく制御したい場合は false にして手動管理します。


SessionHandlerInterface のメソッド一覧

メソッド呼び出しタイミング役割
open(string $path, string $name): boolセッション開始時接続の確立など初期化処理
close(): boolセッション終了時接続のクローズなど後処理
read(string $id): string|falseセッションデータ読み込み時IDに対応するデータを返す(存在しなければ空文字)
write(string $id, string $data): boolセッションデータ書き込み時IDに対応するデータを保存する
destroy(string $id): boolsession_destroy() 呼び出し時IDに対応するデータを削除する
gc(int $max_lifetime): int|falseGC(ガベージコレクション)時期限切れセッションを削除し、削除件数を返す

基本的な使い方

<?php
class MySessionHandler implements \SessionHandlerInterface
{
    public function open(string $path, string $name): bool   { return true; }
    public function close(): bool                           { return true; }
    public function read(string $id): string|false          { return ''; }
    public function write(string $id, string $data): bool   { return true; }
    public function destroy(string $id): bool               { return true; }
    public function gc(int $max_lifetime): int|false        { return 0; }
}

$handler = new MySessionHandler();
session_set_save_handler($handler, true);
session_start();

$_SESSION['user'] = 'alice';

実践的なクラスベースの使用例

例1:MySQL(PDO)によるセッション保存ハンドラ

<?php
/**
 * MySQLにセッションデータを保存するカスタムハンドラ
 *
 * テーブル定義:
 * CREATE TABLE sessions (
 *   id         VARCHAR(128) NOT NULL PRIMARY KEY,
 *   data       MEDIUMBLOB   NOT NULL,
 *   expires_at DATETIME     NOT NULL,
 *   created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
 *   INDEX idx_expires (expires_at)
 * ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 */
class PdoSessionHandler implements \SessionHandlerInterface
{
    private \PDO $pdo;

    public function __construct(\PDO $pdo)
    {
        $this->pdo = $pdo;
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
    }

    public function open(string $path, string $name): bool
    {
        return true; // PDO接続はコンストラクタで確立済み
    }

    public function close(): bool
    {
        return true;
    }

    public function read(string $id): string|false
    {
        $stmt = $this->pdo->prepare(
            'SELECT data FROM sessions WHERE id = :id AND expires_at > NOW()'
        );
        $stmt->execute([':id' => $id]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        return $row ? $row['data'] : '';
    }

    public function write(string $id, string $data): bool
    {
        $maxLifetime = (int) ini_get('session.gc_maxlifetime');
        $stmt = $this->pdo->prepare(
            'INSERT INTO sessions (id, data, expires_at)
             VALUES (:id, :data, DATE_ADD(NOW(), INTERVAL :ttl SECOND))
             ON DUPLICATE KEY UPDATE
               data       = VALUES(data),
               expires_at = VALUES(expires_at)'
        );
        return $stmt->execute([
            ':id'   => $id,
            ':data' => $data,
            ':ttl'  => $maxLifetime,
        ]);
    }

    public function destroy(string $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE id = :id');
        return $stmt->execute([':id' => $id]);
    }

    public function gc(int $max_lifetime): int|false
    {
        $stmt = $this->pdo->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
        $stmt->execute();
        return $stmt->rowCount();
    }
}

// --- 登録と使用 ---
/*
$pdo = new PDO('mysql:host=localhost;dbname=myapp;charset=utf8mb4', 'user', 'pass');
$handler = new PdoSessionHandler($pdo);

session_set_save_handler($handler, true);
session_name('AppSession');
session_start();

$_SESSION['user_id'] = 42;
*/

例2:Redis によるセッション保存ハンドラ

<?php
/**
 * Redis にセッションデータを保存するカスタムハンドラ
 * pecl/redis 拡張を使用
 * 複数サーバー構成でのセッション共有に適している
 */
class RedisSessionHandler implements \SessionHandlerInterface
{
    private \Redis $redis;
    private int    $ttl;
    private string $prefix;

    public function __construct(
        string $host   = '127.0.0.1',
        int    $port   = 6379,
        int    $ttl    = 1800,
        string $prefix = 'sess_',
        string $auth   = ''
    ) {
        $this->ttl    = $ttl;
        $this->prefix = $prefix;
        $this->redis  = new \Redis();
        $this->redis->connect($host, $port);
        if ($auth !== '') {
            $this->redis->auth($auth);
        }
    }

    public function open(string $path, string $name): bool
    {
        return $this->redis->isConnected();
    }

    public function close(): bool
    {
        return true; // 接続はコンストラクタで管理
    }

    public function read(string $id): string|false
    {
        $data = $this->redis->get($this->prefix . $id);
        return $data !== false ? $data : '';
    }

    public function write(string $id, string $data): bool
    {
        return $this->redis->setex($this->prefix . $id, $this->ttl, $data);
    }

    public function destroy(string $id): bool
    {
        $this->redis->del($this->prefix . $id);
        return true;
    }

    public function gc(int $max_lifetime): int|false
    {
        // Redis はキーの TTL を自動管理するため GC 不要
        return 0;
    }

    /**
     * Redis に保存されている全セッション数を返す(デバッグ用)
     */
    public function countActiveSessions(): int
    {
        $keys = $this->redis->keys($this->prefix . '*');
        return count($keys);
    }
}

/*
$handler = new RedisSessionHandler(
    host:   'redis.internal',
    port:   6379,
    ttl:    3600,
    prefix: 'myapp_sess_',
    auth:   'secret'
);
session_set_save_handler($handler, true);
session_start();
*/

例3:暗号化セッションハンドラ(既存ハンドラのデコレータ)

<?php
/**
 * 既存のセッションハンドラをラップして、
 * セッションデータを AES-256-GCM で暗号化するデコレータクラス
 * データ漏洩時にセッション内容を保護する
 */
class EncryptedSessionHandler implements \SessionHandlerInterface
{
    private string $key; // 32バイトの暗号化キー

    public function __construct(
        private readonly \SessionHandlerInterface $inner,
        string $encryptionKey
    ) {
        if (strlen($encryptionKey) !== 32) {
            throw new \InvalidArgumentException('暗号化キーは32バイト(256bit)である必要があります');
        }
        $this->key = $encryptionKey;
    }

    public function open(string $path, string $name): bool
    {
        return $this->inner->open($path, $name);
    }

    public function close(): bool
    {
        return $this->inner->close();
    }

    public function read(string $id): string|false
    {
        $encrypted = $this->inner->read($id);
        if ($encrypted === '' || $encrypted === false) {
            return '';
        }
        return $this->decrypt($encrypted) ?? '';
    }

    public function write(string $id, string $data): bool
    {
        return $this->inner->write($id, $this->encrypt($data));
    }

    public function destroy(string $id): bool
    {
        return $this->inner->destroy($id);
    }

    public function gc(int $max_lifetime): int|false
    {
        return $this->inner->gc($max_lifetime);
    }

    private function encrypt(string $plaintext): string
    {
        $iv        = random_bytes(12); // GCM推奨IVサイズ
        $tag       = '';
        $ciphertext = openssl_encrypt(
            $plaintext, 'aes-256-gcm', $this->key,
            OPENSSL_RAW_DATA, $iv, $tag
        );
        // IV + Tag + 暗号文を Base64 エンコードして保存
        return base64_encode($iv . $tag . $ciphertext);
    }

    private function decrypt(string $encoded): ?string
    {
        $decoded    = base64_decode($encoded, true);
        if ($decoded === false || strlen($decoded) < 28) {
            return null;
        }
        $iv         = substr($decoded, 0, 12);
        $tag        = substr($decoded, 12, 16);
        $ciphertext = substr($decoded, 28);

        $result = openssl_decrypt(
            $ciphertext, 'aes-256-gcm', $this->key,
            OPENSSL_RAW_DATA, $iv, $tag
        );
        return $result !== false ? $result : null;
    }
}

/*
$pdo     = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
$inner   = new PdoSessionHandler($pdo);
$handler = new EncryptedSessionHandler($inner, str_repeat('k', 32)); // 本番はenv変数から取得

session_set_save_handler($handler, true);
session_start();
*/

例4:セッション操作を監査ログに記録するハンドラ

<?php
/**
 * セッションの read / write / destroy を監査ログに記録するデコレータ
 * セキュリティ監査・不正アクセス調査・デバッグに使用する
 */
class AuditingSessionHandler implements \SessionHandlerInterface
{
    private array $auditLog = [];

    public function __construct(
        private readonly \SessionHandlerInterface $inner
    ) {}

    public function open(string $path, string $name): bool
    {
        $this->log('open', null, ['path' => $path, 'name' => $name]);
        return $this->inner->open($path, $name);
    }

    public function close(): bool
    {
        $this->log('close');
        return $this->inner->close();
    }

    public function read(string $id): string|false
    {
        $data = $this->inner->read($id);
        $this->log('read', $id, ['found' => ($data !== '' && $data !== false)]);
        return $data;
    }

    public function write(string $id, string $data): bool
    {
        $result = $this->inner->write($id, $data);
        $this->log('write', $id, [
            'size'    => strlen($data),
            'success' => $result,
        ]);
        return $result;
    }

    public function destroy(string $id): bool
    {
        $result = $this->inner->destroy($id);
        $this->log('destroy', $id, [
            'success' => $result,
            'ip'      => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
        ]);
        return $result;
    }

    public function gc(int $max_lifetime): int|false
    {
        $deleted = $this->inner->gc($max_lifetime);
        $this->log('gc', null, ['deleted' => $deleted]);
        return $deleted;
    }

    private function log(string $operation, ?string $sessionId = null, array $meta = []): void
    {
        $entry = [
            'timestamp' => date('Y-m-d H:i:s'),
            'operation' => $operation,
            'session_id'=> $sessionId ? substr($sessionId, 0, 8) . '...' : null,
            'ip'        => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
            'meta'      => $meta,
        ];
        $this->auditLog[] = $entry;

        // 実際にはファイルやDBへ書き込む
        // file_put_contents('/var/log/app/session_audit.log',
        //     json_encode($entry, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND);
    }

    public function getAuditLog(): array
    {
        return $this->auditLog;
    }

    public function printAuditLog(): void
    {
        echo "=== セッション監査ログ ===\n";
        foreach ($this->auditLog as $entry) {
            $meta = json_encode($entry['meta'], JSON_UNESCAPED_UNICODE);
            printf(
                "[%s] %-10s | ID: %-12s | %s\n",
                $entry['timestamp'],
                $entry['operation'],
                $entry['session_id'] ?? '------',
                $meta
            );
        }
    }
}

/*
$pdo       = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
$inner     = new PdoSessionHandler($pdo);
$handler   = new AuditingSessionHandler($inner);

session_set_save_handler($handler, true);
session_start();

$_SESSION['user_id'] = 1;
session_write_close();

$handler->printAuditLog();
*/

例5:SessionHandlerInterface + SessionUpdateTimestampHandlerInterface を実装した完全版ハンドラ

<?php
/**
 * PHP 7.0以降で使える SessionUpdateTimestampHandlerInterface も実装した
 * 完全版セッションハンドラ
 *
 * validateId()        : セッションIDの有効性を検証する
 * updateTimestamp()   : データ変更なしでもタイムスタンプを更新する(セッション延長)
 */
class FullFeaturedSessionHandler implements
    \SessionHandlerInterface,
    \SessionUpdateTimestampHandlerInterface
{
    private array $store = []; // メモリストア(デモ用)

    public function open(string $path, string $name): bool
    {
        return true;
    }

    public function close(): bool
    {
        return true;
    }

    public function read(string $id): string|false
    {
        if (!isset($this->store[$id])) {
            return '';
        }
        $record = $this->store[$id];
        if ($record['expires_at'] < time()) {
            unset($this->store[$id]);
            return '';
        }
        return $record['data'];
    }

    public function write(string $id, string $data): bool
    {
        $ttl = (int) ini_get('session.gc_maxlifetime');
        $this->store[$id] = [
            'data'       => $data,
            'expires_at' => time() + $ttl,
            'updated_at' => time(),
        ];
        return true;
    }

    public function destroy(string $id): bool
    {
        unset($this->store[$id]);
        return true;
    }

    public function gc(int $max_lifetime): int|false
    {
        $now     = time();
        $deleted = 0;
        foreach ($this->store as $id => $record) {
            if ($record['expires_at'] < $now) {
                unset($this->store[$id]);
                $deleted++;
            }
        }
        return $deleted;
    }

    // --- SessionUpdateTimestampHandlerInterface ---

    /**
     * セッションIDがストレージに存在し、かつ有効かを検証する
     * use_strict_mode が有効なときに呼ばれる
     */
    public function validateId(string $id): bool
    {
        $valid = isset($this->store[$id]) && $this->store[$id]['expires_at'] >= time();
        echo "validateId({$id}): " . ($valid ? 'valid' : 'invalid') . "\n";
        return $valid;
    }

    /**
     * セッションデータが変化しなかった場合でもタイムスタンプを更新する
     * lazy_write が有効なときに呼ばれる
     */
    public function updateTimestamp(string $id, string $data): bool
    {
        if (isset($this->store[$id])) {
            $ttl = (int) ini_get('session.gc_maxlifetime');
            $this->store[$id]['expires_at'] = time() + $ttl;
            $this->store[$id]['updated_at'] = time();
            echo "updateTimestamp({$id}): 延長しました\n";
            return true;
        }
        return false;
    }

    public function dumpStore(): void
    {
        echo "=== ストア内容 ===\n";
        foreach ($this->store as $id => $record) {
            printf(
                "ID: %-10s | size: %4d bytes | expires: %s\n",
                substr($id, 0, 8) . '...',
                strlen($record['data']),
                date('H:i:s', $record['expires_at'])
            );
        }
    }
}

$handler = new FullFeaturedSessionHandler();
session_set_save_handler($handler, true);
ini_set('session.use_strict_mode', '1');
session_start();

$_SESSION['demo'] = 'full_featured';
$handler->dumpStore();

例6:ファクトリパターンによるハンドラ選択クラス

<?php
/**
 * 環境設定に応じて最適なセッションハンドラを生成・登録するファクトリクラス
 * アプリケーションのブートストラップ処理で使用することを想定
 */
class SessionHandlerFactory
{
    public function __construct(private readonly array $config) {}

    /**
     * 設定に基づいてハンドラを生成し session_set_save_handler() で登録する
     */
    public function createAndRegister(): \SessionHandlerInterface
    {
        $driver  = $this->config['driver'] ?? 'files';
        $handler = match ($driver) {
            'pdo'    => $this->createPdoHandler(),
            'redis'  => $this->createRedisHandler(),
            'memory' => new FullFeaturedSessionHandler(),
            default  => $this->createDefaultHandler(),
        };

        // 必要に応じてデコレータをスタック
        if ($this->config['encrypt'] ?? false) {
            $key     = $this->config['encryption_key']
                ?? throw new \RuntimeException('暗号化キーが設定されていません');
            $handler = new EncryptedSessionHandler($handler, $key);
            echo "暗号化レイヤー: 有効\n";
        }

        if ($this->config['audit'] ?? false) {
            $handler = new AuditingSessionHandler($handler);
            echo "監査ログレイヤー: 有効\n";
        }

        session_set_save_handler($handler, true);
        echo "ドライバ: {$driver}\n";
        echo "ハンドラ登録完了: " . get_class($handler) . "\n";

        return $handler;
    }

    private function createPdoHandler(): PdoSessionHandler
    {
        $dsn = $this->config['dsn']
            ?? throw new \RuntimeException('PDO DSN が設定されていません');
        $pdo = new \PDO($dsn, $this->config['db_user'] ?? '', $this->config['db_pass'] ?? '');
        return new PdoSessionHandler($pdo);
    }

    private function createRedisHandler(): RedisSessionHandler
    {
        return new RedisSessionHandler(
            host:   $this->config['redis_host']   ?? '127.0.0.1',
            port:   $this->config['redis_port']   ?? 6379,
            ttl:    $this->config['redis_ttl']    ?? 1800,
            prefix: $this->config['redis_prefix'] ?? 'sess_',
            auth:   $this->config['redis_auth']   ?? ''
        );
    }

    private function createDefaultHandler(): \SessionHandler
    {
        // PHP組み込みのデフォルトハンドラをそのまま使用
        return new \SessionHandler();
    }
}

// 利用例
$factory = new SessionHandlerFactory([
    'driver'  => 'memory', // pdo / redis / memory / files
    'encrypt' => false,
    'audit'   => true,
]);

$handler = $factory->createAndRegister();
session_start();
$_SESSION['factory_demo'] = true;

/*
出力例:
監査ログレイヤー: 有効
ドライバ: memory
ハンドラ登録完了: AuditingSessionHandler
*/

デコレータパターンのスタック構成

session_set_save_handler() の真価はデコレータパターンとの組み合わせにあります。

リクエスト
    ↓
AuditingSessionHandler      ← 監査ログ(外側)
    ↓
EncryptedSessionHandler     ← 暗号化(中間)
    ↓
PdoSessionHandler           ← 実際の保存先(内側)
    ↓
MySQL / Redis / etc.
$inner   = new PdoSessionHandler($pdo);
$middle  = new EncryptedSessionHandler($inner, $key);
$outer   = new AuditingSessionHandler($middle);

session_set_save_handler($outer, true);
session_start();

関連関数・インターフェースとの比較

名前種別役割
session_set_save_handler()関数カスタムハンドラを登録する
SessionHandlerInterfaceインターフェース実装必須の6メソッドを定義
SessionUpdateTimestampHandlerInterfaceインターフェースvalidateId() / updateTimestamp() を追加(PHP 7.0以降)
SessionHandlerクラスPHP組み込みデフォルトハンドラ(継承して一部だけオーバーライドも可)
session_module_name()関数'user' を指定することで session_set_save_handler() と連動
session_register_shutdown()関数シャットダウン時の自動書き込みを登録($register_shutdown=false 時に必要)

よくある落とし穴

<?php
// ❌ NG:session_start() の後に登録しても無効
session_start();
session_set_save_handler($handler, true); // 無視される

// ✅ OK:session_start() の前に登録する
session_set_save_handler($handler, true);
session_start();
<?php
// ❌ よくあるミス:read() が false を返すとセッションが壊れる
public function read(string $id): string|false
{
    // 見つからなかった場合に false を返してしまう
    return false; // ← NG
}

// ✅ 見つからない場合は空文字を返す
public function read(string $id): string|false
{
    $data = $this->store[$id] ?? null;
    return $data !== null ? $data : ''; // ← 空文字を返す
}
<?php
// ❌ 注意:write() の $data はシリアライズ済みのバイナリ文字列
// そのままDBの TEXT 型に入れると文字化けすることがある
public function write(string $id, string $data): bool
{
    // TEXT型に入れると壊れる可能性がある
    $stmt->execute([':data' => $data]);
}

// ✅ BLOB型(MEDIUMBLOB など)を使うか Base64 エンコードする
public function write(string $id, string $data): bool
{
    $stmt->execute([':data' => base64_encode($data)]); // ← エンコードして保存
}
// read() では base64_decode() で戻す

まとめ

項目内容
関数名session_set_save_handler(handler|callbacks, bool $register_shutdown = true): bool
主な用途セッションの保存先をDBやRedisなどカスタムストレージに差し替える
呼び出しタイミング必ず session_start() より前
推奨形式SessionHandlerInterface を実装したクラスを渡す形式(PHP 5.4以降)
デコレータ活用暗号化・監査ログ・キャッシュなどをスタックして組み合わせられる
read() の注意見つからない場合は false ではなく空文字を返す
DB保存の注意$dataバイナリなので BLOB 型または Base64 エンコードを使う

session_set_save_handler() は「セッションの保存先を完全に自分でコントロールしたい」すべての場面で活躍します。デコレータパターンを組み合わせることで、MySQL保存・暗号化・監査ログといった機能を関心事ごとに分離しながら積み重ねられる、非常に拡張性の高い設計が可能です。


参考リンク

タイトルとURLをコピーしました