基礎

PHP PDO入門|安全なデータベースアクセスの基本

PHP PDO データベース

PHP PDO入門
安全なデータベースアクセスの基本

PHPのPDOを使った安全なデータベースアクセスを解説。接続、プリペアドステートメント、トランザクション、エラーハンドリングまで学べます。

こんな人向けの記事です

  • PHPでデータベースに接続したい
  • SQLインジェクションを防ぐ安全な方法を知りたい
  • トランザクション処理を実装したい

Step 1PDOとは(PHP Data Objects)

PDO(PHP Data Objects)は、PHPでデータベースにアクセスするための標準的な拡張モジュールです。MySQL、PostgreSQL、SQLiteなど、複数のデータベースを同じインターフェースで操作できます。

なぜPDOを使うべきか
以前は mysql_* 関数や mysqli_* 関数が使われていましたが、PDOを使えばデータベースを切り替えてもコードをほとんど変更する必要がありません。また、プリペアドステートメントによるSQLインジェクション対策が標準で備わっています。

PDOの主なメリットを確認しましょう。

特徴 PDO mysqli
対応DB MySQL, PostgreSQL, SQLite, Oracle など12種類以上 MySQLのみ
プリペアドステートメント 名前付き・位置パラメータ両対応 位置パラメータのみ
API スタイル オブジェクト指向のみ 手続き型・オブジェクト指向両方
エラーハンドリング 例外(PDOException) エラーコード or 例外
DB切り替え DSNの変更のみ コード全体の書き換えが必要
PDOがサポートするドライバの確認
// 利用可能なPDOドライバを確認
print_r(PDO::getAvailableDrivers());
// 出力例: Array ( [0] => mysql [1] => pgsql [2] => sqlite )

Step 2データベース接続

PDOでデータベースに接続するには、DSN(Data Source Name)、ユーザー名、パスワードを指定してPDOクラスのインスタンスを作成します。

MySQL への接続

MySQL接続の基本
<?php
try {
    $dsn = 'mysql:host=localhost;dbname=myapp;charset=utf8mb4';
    $username = 'root';
    $password = 'secret';

    $pdo = new PDO($dsn, $username, $password);

    echo "接続成功";
} catch (PDOException $e) {
    echo "接続失敗: " . $e->getMessage();
    exit(1);
}

SQLite への接続

SQLite接続
<?php
// ファイルベースのSQLite
$pdo = new PDO('sqlite:/path/to/database.db');

// メモリ上のSQLite(テスト用途に便利)
$pdo = new PDO('sqlite::memory:');

PostgreSQL への接続

PostgreSQL接続
<?php
$dsn = 'pgsql:host=localhost;port=5432;dbname=myapp';
$pdo = new PDO($dsn, 'postgres', 'secret');

推奨する接続オプション

PDOの接続時には、以下のオプションを設定することを強く推奨します。

推奨オプション付きの接続
<?php
$dsn = 'mysql:host=localhost;dbname=myapp;charset=utf8mb4';

$options = [
    // エラー時に例外をスローする(必須)
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    // フェッチモードを連想配列に設定
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    // PDOのエミュレーションを無効化(真のプリペアドステートメントを使用)
    PDO::ATTR_EMULATE_PREPARES   => false,
];

$pdo = new PDO($dsn, 'root', 'secret', $options);
ATTR_EMULATE_PREPARES を false にする理由
デフォルトではPDOがSQLをエミュレーションでプリペアしますが、false にするとデータベースドライバが直接プリペアドステートメントを処理します。これにより型の安全性が向上し、SQLインジェクションに対してより堅牢になります。
オプション 設定値 説明
ATTR_ERRMODE ERRMODE_EXCEPTION エラー時にPDOExceptionをスロー
ATTR_DEFAULT_FETCH_MODE FETCH_ASSOC 結果を連想配列で取得
ATTR_EMULATE_PREPARES false ネイティブプリペアドステートメントを使用

Step 3クエリの実行(query, exec)

PDOでSQLを実行する方法は主に3つあります。query()exec()、そして次のステップで解説するprepare()です。

query() - SELECT文の実行

query() は結果セットを返すSQL(主にSELECT文)の実行に使います。

query()の基本
<?php
// 全ユーザーを取得
$stmt = $pdo->query('SELECT id, name, email FROM users');

// 1行ずつ取得(連想配列)
while ($row = $stmt->fetch()) {
    echo $row['name'] . ' - ' . $row['email'] . "
";
}

// 全行を一括取得
$users = $stmt->fetchAll();

// 特定のカラムだけ取得
$names = $pdo->query('SELECT name FROM users')
              ->fetchAll(PDO::FETCH_COLUMN);

フェッチモードの種類

フェッチモード 戻り値の形式 使用場面
FETCH_ASSOC 連想配列 最も一般的。カラム名でアクセス
FETCH_NUM 数値インデックス配列 カラム位置でアクセス
FETCH_BOTH 連想+数値の両方 デフォルト(非推奨、メモリ無駄)
FETCH_OBJ stdClassオブジェクト オブジェクトとしてアクセス
FETCH_CLASS 指定クラスのインスタンス 独自クラスにマッピング
FETCH_COLUMN 単一カラムの値 1カラムだけ取得したい場合

exec() - INSERT/UPDATE/DELETE の実行

exec() は結果セットを返さないSQL(INSERT、UPDATE、DELETE、CREATE TABLE等)の実行に使い、影響を受けた行数を返します。

exec()の基本
<?php
// テーブル作成
$pdo->exec('CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)');

// データ削除(影響行数を取得)
$deletedRows = $pdo->exec("DELETE FROM users WHERE active = 0");
echo "{$deletedRows}件を削除しました";
query() と exec() にユーザー入力を含めてはいけない
query()exec() にユーザーからの入力を直接埋め込むと、SQLインジェクションの脆弱性が生まれます。ユーザー入力を含むSQLには必ず次のステップで解説するプリペアドステートメントを使ってください。
危険なコード(絶対にやってはいけない)
<?php
// NG: SQLインジェクションの脆弱性!
$name = $_GET['name'];
$stmt = $pdo->query("SELECT * FROM users WHERE name = '$name'");
// 攻撃者が name=' OR 1=1 -- を送ると全レコードが取得される

Step 4プリペアドステートメント(prepare, execute)

プリペアドステートメントは、SQLテンプレートとパラメータを分離してデータベースに送信する仕組みです。SQLインジェクションを根本的に防止できるため、ユーザー入力を含むSQLでは必ず使用してください。

プリペアドステートメントの仕組み
1. prepare: SQLテンプレート(プレースホルダ付き)をDBに送信し、構文解析・最適化される
2. execute: パラメータの値だけを送信して実行する
SQLの構造とデータが完全に分離されるため、パラメータに悪意のあるSQLが含まれていても「ただの文字列」として処理されます。

名前付きプレースホルダ(:name 形式)

名前付きプレースホルダ
<?php
// SELECT: ユーザーの検索
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $_POST['email']]);
$user = $stmt->fetch();

// INSERT: 新規ユーザーの登録
$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');
$stmt->execute([
    ':name'  => $_POST['name'],
    ':email' => $_POST['email'],
]);

// 挿入されたレコードのIDを取得
$newId = $pdo->lastInsertId();
echo "登録成功 ID: {$newId}";

位置プレースホルダ(? 形式)

位置プレースホルダ
<?php
// ?の順番にパラメータをバインド
$stmt = $pdo->prepare('SELECT * FROM users WHERE age >= ? AND age <= ?');
$stmt->execute([20, 30]);
$users = $stmt->fetchAll();

bindValue() と bindParam() の違い

より厳密な型指定が必要な場合は、bindValue()bindParam() を使います。

bindValue()とbindParam()
<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id AND active = :active');

// bindValue: 値を直接バインド(型を明示指定)
$stmt->bindValue(':id', 42, PDO::PARAM_INT);
$stmt->bindValue(':active', true, PDO::PARAM_BOOL);
$stmt->execute();

// bindParam: 変数への参照をバインド(execute時点の変数値が使われる)
$id = 1;
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindParam(':id', $id, PDO::PARAM_INT);

$id = 42;  // execute時にはこの値が使われる
$stmt->execute();
メソッド バインド対象 バインドタイミング 主な用途
bindValue() 呼び出し時 固定値のバインド
bindParam() 変数の参照 execute時 ループ内での繰り返し実行

プリペアドステートメントの再利用

同じSQLを繰り返し実行
<?php
// 一度prepareすれば何度でもexecuteできる
$stmt = $pdo->prepare('INSERT INTO logs (level, message) VALUES (:level, :message)');

$logs = [
    [':level' => 'INFO',  ':message' => 'ユーザーがログインしました'],
    [':level' => 'WARN',  ':message' => 'パスワード試行回数超過'],
    [':level' => 'ERROR', ':message' => 'データベース接続タイムアウト'],
];

foreach ($logs as $log) {
    $stmt->execute($log);
}
echo count($logs) . "件のログを登録しました";

Step 5トランザクション(beginTransaction, commit, rollBack)

トランザクションは、複数のSQL操作を「ひとまとまり」として実行する仕組みです。すべての操作が成功すれば確定(commit)、どれか1つでも失敗すれば全体を取り消し(rollback)ます。

トランザクションのACID特性
Atomicity(原子性): 全て成功するか、全て失敗するか
Consistency(一貫性): データの整合性が保たれる
Isolation(分離性): 他のトランザクションの影響を受けない
Durability(永続性): 確定したデータは消えない

基本的なトランザクション

トランザクションの基本パターン
<?php
try {
    // トランザクション開始
    $pdo->beginTransaction();

    // 送金元の残高を減らす
    $stmt = $pdo->prepare('UPDATE accounts SET balance = balance - :amount WHERE id = :id');
    $stmt->execute([':amount' => 10000, ':id' => 1]);

    // 送金先の残高を増やす
    $stmt = $pdo->prepare('UPDATE accounts SET balance = balance + :amount WHERE id = :id');
    $stmt->execute([':amount' => 10000, ':id' => 2]);

    // 送金履歴を記録
    $stmt = $pdo->prepare('INSERT INTO transfers (from_id, to_id, amount) VALUES (?, ?, ?)');
    $stmt->execute([1, 2, 10000]);

    // すべて成功したらコミット
    $pdo->commit();
    echo "送金が完了しました";

} catch (PDOException $e) {
    // エラー発生時はロールバック
    $pdo->rollBack();
    echo "送金に失敗しました: " . $e->getMessage();
}
トランザクション中のエラー処理
beginTransaction() を呼んだら、必ず commit() または rollBack() で終了してください。トランザクションが開いたままだと、他の接続からのアクセスがブロックされる可能性があります。

在庫管理の実践例

在庫管理のトランザクション
<?php
function purchaseItem(PDO $pdo, int $userId, int $itemId, int $quantity): bool
{
    try {
        $pdo->beginTransaction();

        // 在庫数を確認(FOR UPDATEで行ロック)
        $stmt = $pdo->prepare(
            'SELECT stock FROM items WHERE id = :id FOR UPDATE'
        );
        $stmt->execute([':id' => $itemId]);
        $item = $stmt->fetch();

        if (!$item || $item['stock'] < $quantity) {
            $pdo->rollBack();
            return false;  // 在庫不足
        }

        // 在庫を減らす
        $stmt = $pdo->prepare(
            'UPDATE items SET stock = stock - :qty WHERE id = :id'
        );
        $stmt->execute([':qty' => $quantity, ':id' => $itemId]);

        // 注文レコードを作成
        $stmt = $pdo->prepare(
            'INSERT INTO orders (user_id, item_id, quantity) VALUES (?, ?, ?)'
        );
        $stmt->execute([$userId, $itemId, $quantity]);

        $pdo->commit();
        return true;

    } catch (PDOException $e) {
        $pdo->rollBack();
        throw $e;
    }
}

Step 6エラーハンドリング(PDOException)

PDOのエラーモードを ERRMODE_EXCEPTION に設定すると、SQL実行時のエラーがすべてPDOExceptionとしてスローされます。

エラーモードの種類

エラーモード 動作 推奨
ERRMODE_SILENT エラーを無視(デフォルト) 非推奨
ERRMODE_WARNING PHP警告を発生 非推奨
ERRMODE_EXCEPTION PDOExceptionをスロー 推奨

基本的なエラーハンドリング

PDOExceptionのキャッチ
<?php
try {
    $pdo = new PDO($dsn, $user, $pass, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);

    $stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (?, ?)');
    $stmt->execute(['田中太郎', 'tanaka@example.com']);

} catch (PDOException $e) {
    // エラー情報を取得
    echo "エラーコード: " . $e->getCode() . "
";
    echo "エラーメッセージ: " . $e->getMessage() . "
";

    // SQLSTATE エラーコードで条件分岐
    if ($e->getCode() === '23000') {
        echo "このメールアドレスは既に登録されています";
    }
}

よく遭遇するSQLSTATEコード

SQLSTATE 意味 対処法
23000 一意制約違反(重複) INSERT前にSELECTで確認、またはINSERT IGNOREを使用
42S02 テーブルが存在しない テーブル名を確認、マイグレーションを実行
42000 SQL構文エラー SQLの文法を確認
HY000 一般エラー getMessage()で詳細を確認
08004 接続拒否 ホスト名・認証情報を確認

本番環境での安全なエラーハンドリング

本番環境向けのエラー処理
<?php
function getDbConnection(): PDO
{
    static $pdo = null;

    if ($pdo === null) {
        try {
            $pdo = new PDO(
                'mysql:host=localhost;dbname=myapp;charset=utf8mb4',
                'root',
                'secret',
                [
                    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES   => false,
                ]
            );
        } catch (PDOException $e) {
            // 本番ではエラー詳細をユーザーに見せない
            error_log('DB接続エラー: ' . $e->getMessage());
            http_response_code(500);
            echo 'システムエラーが発生しました。しばらくしてからお試しください。';
            exit(1);
        }
    }

    return $pdo;
}

// 使用例
try {
    $pdo = getDbConnection();
    $users = $pdo->query('SELECT * FROM users')->fetchAll();
} catch (PDOException $e) {
    error_log('SQLエラー: ' . $e->getMessage());
    // ユーザーには汎用メッセージを表示
    echo 'データの取得に失敗しました';
}
本番環境でのセキュリティ注意
$e->getMessage() にはデータベースのホスト名やテーブル構造などの情報が含まれます。本番環境ではユーザーに直接表示せず、必ずログファイルに記録してください。

PDO接続クラスの完全版

再利用可能なPDO接続クラス
<?php
class Database
{
    private static ?PDO $instance = null;

    public static function getInstance(): PDO
    {
        if (self::$instance === null) {
            $config = require __DIR__ . '/config/database.php';

            self::$instance = new PDO(
                $config['dsn'],
                $config['username'],
                $config['password'],
                [
                    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES   => false,
                ]
            );
        }

        return self::$instance;
    }

    // クローンとシリアライズを禁止(シングルトン)
    private function __clone() {}
    public function __wakeup() { throw new \Exception('Cannot unserialize'); }
}

// 使い方
$pdo = Database::getInstance();
$users = $pdo->query('SELECT * FROM users')->fetchAll();

PDOチェックリスト

  • ATTR_ERRMODEERRMODE_EXCEPTION に設定した
  • ATTR_EMULATE_PREPARESfalse に設定した
  • ATTR_DEFAULT_FETCH_MODEFETCH_ASSOC に設定した
  • DSNに charset=utf8mb4 を指定した
  • ユーザー入力を含むSQLにはプリペアドステートメントを使った
  • query() / exec() にユーザー入力を直接埋め込んでいない
  • 複数の関連するSQL操作にはトランザクションを使った
  • トランザクション内で必ず commit または rollBack を呼んでいる
  • 本番環境ではエラー詳細をユーザーに表示していない
  • エラー情報はログファイルに記録している