PHP PDO入門
安全なデータベースアクセスの基本
PHPのPDOを使った安全なデータベースアクセスを解説。接続、プリペアドステートメント、トランザクション、エラーハンドリングまで学べます。
こんな人向けの記事です
- PHPでデータベースに接続したい
- SQLインジェクションを防ぐ安全な方法を知りたい
- トランザクション処理を実装したい
Step 1PDOとは(PHP Data Objects)
PDO(PHP Data Objects)は、PHPでデータベースにアクセスするための標準的な拡張モジュールです。MySQL、PostgreSQL、SQLiteなど、複数のデータベースを同じインターフェースで操作できます。
mysql_* 関数や mysqli_* 関数が使われていましたが、PDOを使えばデータベースを切り替えてもコードをほとんど変更する必要がありません。また、プリペアドステートメントによるSQLインジェクション対策が標準で備わっています。
PDOの主なメリットを確認しましょう。
| 特徴 | PDO | mysqli |
|---|---|---|
| 対応DB | MySQL, PostgreSQL, SQLite, Oracle など12種類以上 | MySQLのみ |
| プリペアドステートメント | 名前付き・位置パラメータ両対応 | 位置パラメータのみ |
| API スタイル | オブジェクト指向のみ | 手続き型・オブジェクト指向両方 |
| エラーハンドリング | 例外(PDOException) | エラーコード or 例外 |
| DB切り替え | DSNの変更のみ | コード全体の書き換えが必要 |
// 利用可能なPDOドライバを確認
print_r(PDO::getAvailableDrivers());
// 出力例: Array ( [0] => mysql [1] => pgsql [2] => sqlite )
Step 2データベース接続
PDOでデータベースに接続するには、DSN(Data Source Name)、ユーザー名、パスワードを指定してPDOクラスのインスタンスを作成します。
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 への接続
<?php
// ファイルベースのSQLite
$pdo = new PDO('sqlite:/path/to/database.db');
// メモリ上のSQLite(テスト用途に便利)
$pdo = new PDO('sqlite::memory:');
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);
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文)の実行に使います。
<?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等)の実行に使い、影響を受けた行数を返します。
<?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() にユーザーからの入力を直接埋め込むと、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では必ず使用してください。
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() を使います。
<?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時 | ループ内での繰り返し実行 |
プリペアドステートメントの再利用
<?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)ます。
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をスロー | 推奨 |
基本的なエラーハンドリング
<?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接続クラスの完全版
<?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_ERRMODEをERRMODE_EXCEPTIONに設定したATTR_EMULATE_PREPARESをfalseに設定したATTR_DEFAULT_FETCH_MODEをFETCH_ASSOCに設定した- DSNに
charset=utf8mb4を指定した - ユーザー入力を含むSQLにはプリペアドステートメントを使った
query()/exec()にユーザー入力を直接埋め込んでいない- 複数の関連するSQL操作にはトランザクションを使った
- トランザクション内で必ず commit または rollBack を呼んでいる
- 本番環境ではエラー詳細をユーザーに表示していない
- エラー情報はログファイルに記録している