- PHP
PHP PDOの使い方|MySQL接続からprepare・bindValueまで
PDO(PHP Data Objects)とは、PHPからデータベースを操作するための標準の仕組みで、new PDO(DSN, ユーザー名, パスワード, オプション) で接続し、prepare() → bindValue() → execute() → fetch() の順でSQLを実行します。 MySQLでもPostgreSQLでもSQLiteでも同じ書き方で扱えるのが特長です。
この記事では、そのまま貼って動く接続テンプレートとCRUDのコードを、PHP 8.4.21 と MySQL 8.0.46 で実際に動かした出力つきで紹介します。あわせて、bindValue と bindParam の違い、fetch 系メソッドの使い分け、そして SQLSTATE[HY000] [2002] などのエラーを実際に再現して原因と対処をまとめた逆引き表を用意しました。エラー文で検索してたどり着いた方は「PDOのエラー逆引き表」から読んでも大丈夫です。
PDOの早見表
まずは全体像です。PDOの処理は「接続 → 準備 → 値を結びつける → 実行 → 取り出す」の5段階と覚えると迷いません。
| やりたいこと | 書き方 | 戻り値・ポイント |
|---|---|---|
| 接続する | new PDO($dsn, $user, $pass, $options) |
PDOオブジェクト。接続できないと PDOException |
| SQLを準備する | $stmt = $pdo->prepare('... WHERE id = :id') |
PDOStatement。値の場所は :名前 か ? |
| 値を結びつける | $stmt->bindValue(':id', 1, PDO::PARAM_INT) |
型を指定できる。迷ったらこれ |
| 実行する | $stmt->execute() |
成功で true |
| まとめて結びつけて実行 | $stmt->execute([':id' => 1]) |
値はすべて文字列として渡る |
| 1行取り出す | $stmt->fetch() |
配列。行がなければ false |
| 全行取り出す | $stmt->fetchAll() |
配列の配列。行がなければ [] |
| 1つの値だけ取り出す | $stmt->fetchColumn() |
COUNT(*) などに便利 |
| 変更された行数 | $stmt->rowCount() |
UPDATE・DELETEの件数 |
| 採番されたID | $pdo->lastInsertId() |
文字列で返る(例:"1") |
| 値のないSQLを実行 | $pdo->query('SELECT ...') / $pdo->exec('CREATE ...') |
外から来た値を含めないSQL専用 |
| トランザクション | beginTransaction() / commit() / rollBack() |
複数の更新をひとまとめにする |
| 切断する | $pdo = null; |
スクリプト終了時は自動で切断 |
接続時におすすめのオプションは次の4点です(理由は次の章で説明します)。
| 設定 | 値 | 役割 |
|---|---|---|
DSNの charset |
utf8mb4 |
日本語と絵文字を正しくやり取りする |
PDO::ATTR_ERRMODE |
PDO::ERRMODE_EXCEPTION |
エラーを例外として受け取る(PHP 8.0以降は既定) |
PDO::ATTR_DEFAULT_FETCH_MODE |
PDO::FETCH_ASSOC |
結果を「列名 => 値」の連想配列で受け取る |
PDO::ATTR_EMULATE_PREPARES |
false |
MySQL本体のプリペアドステートメントを使う |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PHP PDOの使い方|MySQL接続からprepare・bindValueまで」
動作確認した環境
この記事のコードと出力は、次の環境で実行したものです。
| 項目 | バージョン |
|---|---|
| PHP | 8.4.21(CLI) |
| 拡張モジュール | PDO、pdo_mysql(mysqlnd) |
| MySQL | 8.0.46 |
MySQL 8.0 は2026年4月にサポートが終了しています。これから環境を用意する場合は MySQL 8.4(LTS)などサポート中の版を選ぶとよいでしょう。この記事のPHP側のコードは、接続先のMySQLの版に依存しない書き方にしてあります。
準備として、MySQL側にデータベースとユーザーを作っておきます。
CREATE DATABASE shop CHARACTER SET utf8mb4;
CREATE USER 'app'@'%' IDENTIFIED BY 'secret';
GRANT ALL ON shop.* TO 'app'@'%';
PDOとMySQL用ドライバが使えるかは、次のコマンドで確認できます。
php -m | grep -i pdo
php -r 'print_r(PDO::getAvailableDrivers());'
PDO
pdo_mysql
pdo_pgsql
pdo_sqlite
Array
(
[0] => mysql
[1] => pgsql
[2] => sqlite
)
配列に mysql が入っていれば準備完了です。入っていない場合は、あとの章の「could not find driver」を参照してください。
MySQLに接続するテンプレート
接続処理は1つのファイルにまとめておくと、どの画面からも同じ設定で使えます。次の db.php をそのまま使えます(ホスト・ポート・DB名・ユーザーは自分の環境に合わせてください)。
<?php
// db.php
declare(strict_types=1);
function db(): PDO
{
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=shop;charset=utf8mb4';
return new PDO($dsn, 'app', 'secret', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
使う側は require して呼び出すだけです。
<?php
require __DIR__ . '/db.php';
$pdo = db();
echo 'server: ', $pdo->getAttribute(PDO::ATTR_SERVER_VERSION), PHP_EOL;
server: 8.0.46-0ubuntu0.24.04.4
DSNの書き方
DSN(Data Source Name)は「どのデータベースに、どうつなぐか」を1行で表した文字列です。先頭の mysql: がドライバ名で、そのあとに キー=値 を ; で区切って並べます。
| キー | 例 | 説明 |
|---|---|---|
host |
127.0.0.1 |
接続先のホスト名かIPアドレス |
port |
3306 |
ポート番号。省略すると 3306 |
dbname |
shop |
使うデータベース名 |
charset |
utf8mb4 |
通信に使う文字コード |
unix_socket |
/var/run/mysqld/mysqld.sock |
ソケットファイルでつなぐ場合(host の代わりに書く) |
charset に書くのは MySQL での名前です。utf-8 とハイフンつきで書くと SQLSTATE[HY000] [2019] Unknown character set になります。 絵文字も扱える utf8mb4 を指定しておくと、次のように絵文字入りの文字列もそのまま往復できました。
$pdo->exec("INSERT INTO items (name, price) VALUES ('付箋📝', 300)");
echo $pdo->query('SELECT name FROM items ORDER BY id DESC LIMIT 1')->fetchColumn();
付箋📝
4つのオプションを指定する理由
ERRMODE_EXCEPTION は、SQLでエラーが起きたときに PDOException を投げる設定です。PHP 8.0 以降はこれが既定になっており、今回の PHP 8.4 でも、オプションなしで接続した PDO の ATTR_ERRMODE は ERRMODE_EXCEPTION でした。明示しておくと、PHP 7系の環境にコードを持っていったときも同じ動きになります。
FETCH_ASSOC は、結果を連想配列で受け取る設定です。既定は FETCH_BOTH で、列名と番号の両方が入るため1行あたりのデータが2倍になります。
print_r($pdo->query('SELECT id, name FROM items WHERE id=1')->fetch(PDO::FETCH_BOTH));
Array
(
[id] => 1
[0] => 1
[name] => ノート
[1] => ノート
)
EMULATE_PREPARES => false は、プレースホルダの処理をPHP側の模倣(エミュレーション)ではなく、MySQLサーバー側に任せる設定です。pdo_mysql の既定は true(エミュレーションあり)です。false にすると、SQL文と値が別々にサーバーへ送られ、1回の query() に複数のSQL文を書くこともできなくなります。実際に試した結果が次の表です。
| 試したこと | エミュレーションあり(既定) | エミュレーションなし(false) |
|---|---|---|
query("SELECT 1; SELECT 2") |
実行できた | 1064 の構文エラー |
LIMIT :lim に execute([':lim' => '2']) |
1064 の構文エラー('2' と引用符つきになる) |
実行できた |
同じ名前 :q をSQL内で2回使う |
実行できた | SQLSTATE[HY093]: Invalid parameter number |
| 整数列の取得結果 | int(1) |
int(1) |
false にした場合、同じ名前のプレースホルダは1つのSQLで1回だけ使える点を覚えておいてください(:q1、:q2 のように名前を分けます)。
ユーザー名やパスワードは、コードに直接書かずに getenv('DB_PASS') のように環境変数から読むと、リポジトリに秘密の値が入りません。
CRUDのコピペコード
ここからは、文房具の在庫を管理する items テーブルを例に、登録・取得・更新・削除を順に実行します。まずテーブルを作ります。値を含まないSQLは exec() で実行できます。
<?php
require __DIR__ . '/db.php';
$pdo = db();
$pdo->exec('DROP TABLE IF EXISTS items');
$pdo->exec('CREATE TABLE items (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price INT NOT NULL,
stock INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
INSERTとlastInsertId
$stmt = $pdo->prepare('INSERT INTO items (name, price, stock) VALUES (:name, :price, :stock)');
$stmt->bindValue(':name', 'ノート', PDO::PARAM_STR);
$stmt->bindValue(':price', 180, PDO::PARAM_INT);
$stmt->bindValue(':stock', 50, PDO::PARAM_INT);
$stmt->execute();
echo 'lastInsertId: ', $pdo->lastInsertId(), PHP_EOL;
var_dump($pdo->lastInsertId());
// 同じステートメントを使い回して複数行を登録する
$rows = [['ボールペン', 120, 200], ['消しゴム', 80, 0], ['ノートカバー', 650, 12], ['50%OFFシール', 30, 99]];
foreach ($rows as [$n, $p, $s]) {
$stmt->execute([':name' => $n, ':price' => $p, ':stock' => $s]);
}
echo 'lastInsertId: ', $pdo->lastInsertId(), PHP_EOL;
lastInsertId: 1
string(1) "1"
lastInsertId: 5
lastInsertId() は $stmt ではなく $pdo のメソッドで、文字列を返します。 整数として使うときは (int)$pdo->lastInsertId() とします。また、prepare() は1回だけ行い、execute() を繰り返すと、同じSQLを何度も解析せずに済みます。
SELECTで1行を取得する
$stmt = $pdo->prepare('SELECT id, name, price, stock FROM items WHERE id = :id');
$stmt->bindValue(':id', 1, PDO::PARAM_INT);
$stmt->execute();
var_dump($stmt->fetch());
// 存在しないIDの場合
$stmt->execute([':id' => 999]);
var_dump($stmt->fetch());
array(4) {
["id"]=>
int(1)
["name"]=>
string(9) "ノート"
["price"]=>
int(180)
["stock"]=>
int(50)
}
bool(false)
行が見つからないとき fetch() は false を返します。 そのため、取得後は次のように確認してから使うと安心です。
$item = $stmt->fetch();
if ($item === false) {
http_response_code(404);
exit('商品が見つかりません');
}
echo $item['name'];
SELECTで複数行を取得する
$stmt = $pdo->prepare('SELECT id, name, price FROM items WHERE price >= :min ORDER BY id');
$stmt->execute([':min' => 100]);
foreach ($stmt->fetchAll() as $row) {
echo $row['id'], ' ', $row['name'], ' ', $row['price'], PHP_EOL;
}
1 ノート 180
2 ボールペン 120
4 ノートカバー 650
PDOStatement はそのまま foreach に渡すこともできます。行数が多いときは、全行を配列にためずに1行ずつ処理できます。
foreach ($pdo->query('SELECT id, name FROM items ORDER BY id LIMIT 2') as $r) {
echo $r['id'], ':', $r['name'], PHP_EOL;
}
1:ノート
2:ボールペン
UPDATEとrowCount
$stmt = $pdo->prepare('UPDATE items SET price = :price WHERE id = :id');
$stmt->execute([':price' => 200, ':id' => 1]);
echo 'UPDATE rowCount: ', $stmt->rowCount(), PHP_EOL;
$stmt->execute([':price' => 200, ':id' => 1]);
echo '同じ値で再UPDATE rowCount: ', $stmt->rowCount(), PHP_EOL;
UPDATE rowCount: 1
同じ値で再UPDATE rowCount: 0
MySQLの rowCount() は「実際に値が変わった行数」を返します。 同じ値で上書きした2回目は 0 になりました。「該当する行があったか」を知りたい場合は、先に SELECT で存在を確かめるか、接続オプションに PDO::MYSQL_ATTR_FOUND_ROWS => true を加えて「条件に一致した行数」を返すようにします(このオプションは今回は未実行で、一般に知られた仕様として紹介しています)。
DELETE
$stmt = $pdo->prepare('DELETE FROM items WHERE id = :id');
$stmt->execute([':id' => 3]);
echo 'DELETE rowCount: ', $stmt->rowCount(), PHP_EOL;
DELETE rowCount: 1
bindValue・bindParam・execute配列渡しの違い
値を結びつける方法は3つあります。結論から言うと、型を指定したいときは bindValue、手早く書きたいときは execute の配列渡し、の2つを使い分ければ十分です。
| 方法 | 渡すもの | 値が決まるタイミング | 型の指定 | 向いている場面 |
|---|---|---|---|---|
bindValue(':id', $v, 型) |
値そのもの | bindValue を呼んだ時点 |
できる | 通常はこれ。LIMIT や整数列 |
bindParam(':id', $v, 型) |
変数への参照 | execute を呼んだ時点 |
できる | 変数を書き換えながら繰り返し実行する場合 |
execute([':id' => $v]) |
値の配列 | execute を呼んだ時点 |
できない(すべて文字列扱い) | 条件が少ない短いSQL |
値が決まるタイミングの違い
$stmt = $pdo->prepare('SELECT name FROM items WHERE id = :id');
$id = 1;
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$id = 2; // このあとで変数を書き換える
$stmt->execute();
echo 'bindValue: ', $stmt->fetchColumn(), PHP_EOL;
$id = 1;
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
$id = 2; // このあとで変数を書き換える
$stmt->execute();
echo 'bindParam: ', $stmt->fetchColumn(), PHP_EOL;
bindValue: ノート
bindParam: ボールペン
bindValue は呼んだ時点の値(1)を覚えますが、bindParam は変数そのものと結びつくため、execute の時点の値(2)が使われます。
bindParamをforeachで使うと結果が0件になる
bindParam でよく起きるのが、foreach のループ変数を結びつけてしまうケースです。
$params = [':min' => 100, ':max' => 300];
$stmt = $pdo->prepare('SELECT name FROM items WHERE price >= :min AND price <= :max ORDER BY id');
// 直す前:bindParam にループ変数を渡す
foreach ($params as $key => $val) {
$stmt->bindParam($key, $val, PDO::PARAM_INT);
}
$stmt->execute();
echo 'bindParam: ', json_encode($stmt->fetchAll(PDO::FETCH_COLUMN), JSON_UNESCAPED_UNICODE), PHP_EOL;
// 直した後:bindValue に変える
foreach ($params as $key => $val) {
$stmt->bindValue($key, $val, PDO::PARAM_INT);
}
$stmt->execute();
echo 'bindValue: ', json_encode($stmt->fetchAll(PDO::FETCH_COLUMN), JSON_UNESCAPED_UNICODE), PHP_EOL;
bindParam: []
bindValue: ["ノート","ボールペン"]
:min も :max も同じ変数 $val への参照になり、ループが終わった時点の値(300)が両方に使われます。その結果「価格が300以上かつ300以下」という条件になり、0件でした。エラーにならず結果だけが変わるので、気づきにくい点です。bindValue に変えるだけで解決します。
また、bindParam に 100 のような値を直接書くと Argument #2 ($var) could not be passed by reference というエラーになります。
bindParamが役立つ場面
変数を1回だけ結びつけ、値を入れ替えながら繰り返し実行する場合は bindParam が簡潔です。
$stmt = $pdo->prepare('SELECT :n AS n');
$n = null;
$stmt->bindParam(':n', $n, PDO::PARAM_STR);
foreach (['A', 'B', 'C'] as $n) {
$stmt->execute();
echo $stmt->fetchColumn();
}
ABC
execute配列渡しは「すべて文字列」
execute() に配列で渡した値は、すべて文字列(PDO::PARAM_STR)として扱われます。SELECT :a のように渡した値をそのまま返すSQLで確かめました。
$s = $pdo->prepare('SELECT :a AS a, :b AS b, :c AS c');
$s->execute([':a' => 10, ':b' => null, ':c' => true]);
var_dump($s->fetch());
$s->bindValue(':a', 10, PDO::PARAM_INT);
$s->bindValue(':b', null, PDO::PARAM_NULL);
$s->bindValue(':c', true, PDO::PARAM_BOOL);
$s->execute();
var_dump($s->fetch());
array(3) {
["a"]=>
string(2) "10"
["b"]=>
NULL
["c"]=>
string(1) "1"
}
array(3) {
["a"]=>
int(10)
["b"]=>
NULL
["c"]=>
int(1)
}
整数の列と比較する WHERE id = :id のような条件では、MySQLが文字列を数値に読み替えるため、配列渡しでも同じ結果になります。型をはっきりさせたい値(LIMIT、数値計算、真偽値)は bindValue で型を指定する、と決めておくと迷いません。
| 型の定数 | 使う値 |
|---|---|
PDO::PARAM_STR |
文字列(省略時の既定) |
PDO::PARAM_INT |
整数 |
PDO::PARAM_BOOL |
真偽値 |
PDO::PARAM_NULL |
NULL |
PDO::PARAM_LOB |
画像などの大きなバイナリ |
:名前 と ? のどちらを使うか
プレースホルダには、名前つき(:id)と疑問符(?)の2種類があります。
| 種類 | 書き方 | executeに渡す配列 | 向いている場面 |
|---|---|---|---|
| 名前つき | WHERE id = :id |
[':id' => 1](['id' => 1] も可) |
条件が多く、読みやすさを優先したいSQL |
| 疑問符 | WHERE id = ? |
[1](順番どおり) |
IN句など、個数が変わるSQL |
1つのSQLの中で2種類を混ぜることはできません(混ぜると SQLSTATE[HY093]: Invalid parameter number: mixed named and positional parameters になります)。
fetch・fetchAll・fetchColumnの違い
結果の取り出し方は、欲しい形で選びます。
| メソッド | 返すもの | 行がないとき | 向いている場面 |
|---|---|---|---|
fetch() |
1行分の配列 | false |
主キーで1件を取得 |
fetchAll() |
全行の配列 | [](空の配列) |
一覧表示 |
fetchColumn() |
先頭行の1列目の値 | false |
COUNT(*)、MAX()、存在確認 |
fetchAll(PDO::FETCH_COLUMN) |
1列目だけの配列 | [] |
IDの一覧、名前の一覧 |
fetchAll(PDO::FETCH_KEY_PAIR) |
「1列目 => 2列目」の配列 | [] |
セレクトボックスの選択肢 |
fetch(PDO::FETCH_OBJ) |
stdClass オブジェクト |
false |
$row->name と書きたいとき |
setFetchMode(PDO::FETCH_CLASS, クラス名) |
指定クラスのオブジェクト | false |
型つきのクラスで受け取りたいとき |
それぞれの実行結果です。
var_dump($pdo->query('SELECT COUNT(*) FROM items')->fetchColumn());
print_r($pdo->query('SELECT name FROM items ORDER BY id')->fetchAll(PDO::FETCH_COLUMN));
print_r($pdo->query('SELECT id, name FROM items ORDER BY id')->fetchAll(PDO::FETCH_KEY_PAIR));
var_dump($pdo->query('SELECT * FROM items WHERE id=999')->fetchAll());
int(4)
Array
(
[0] => ノート
[1] => ボールペン
[2] => ノートカバー
[3] => 50%OFFシール
)
Array
(
[1] => ノート
[2] => ボールペン
[4] => ノートカバー
[5] => 50%OFFシール
)
array(0) {
}
クラスで受け取る例です。
final class Item
{
public int $id;
public string $name;
public int $price;
}
$st = $pdo->query('SELECT id, name, price FROM items WHERE id=2');
$st->setFetchMode(PDO::FETCH_CLASS, Item::class);
var_dump($st->fetch());
object(Item)#5 (3) {
["id"]=>
int(2)
["name"]=>
string(15) "ボールペン"
["price"]=>
int(120)
}
件数を知りたいときは、fetchAll() して count() するより、SQLで COUNT(*) を実行して fetchColumn() で受け取るほうが、取り出すデータが1つの値だけで済みます。
LIKE・IN句・LIMITの書き方
プレースホルダで迷いやすい3つのパターンです。
LIKEで部分一致検索をする
% はSQL側ではなく、結びつける値の側に付けます。 また、検索語に含まれる % や _ はワイルドカードとして働くので、addcslashes() でエスケープしておきます。
$kw = 'ノート';
$stmt = $pdo->prepare('SELECT name FROM items WHERE name LIKE :kw ORDER BY id');
$stmt->bindValue(':kw', '%' . addcslashes($kw, '\\%_') . '%', PDO::PARAM_STR);
$stmt->execute();
print_r($stmt->fetchAll(PDO::FETCH_COLUMN));
Array
(
[0] => ノート
[1] => ノートカバー
)
エスケープの有無で結果がどう変わるかを、検索語 % で確かめました。
| 検索語 | エスケープ | 結果 |
|---|---|---|
% |
なし | 4件(全件が一致) |
% |
あり | 1件(50%OFFシール のみ) |
IN句に配列を渡す
IN句では、配列の要素数と同じ数の ? を組み立てます。
$ids = [1, 2, 5];
$ph = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT id, name FROM items WHERE id IN ($ph) ORDER BY id";
echo $sql, PHP_EOL;
$stmt = $pdo->prepare($sql);
$stmt->execute($ids);
print_r($stmt->fetchAll(PDO::FETCH_KEY_PAIR));
SELECT id, name FROM items WHERE id IN (?,?,?) ORDER BY id
Array
(
[1] => ノート
[2] => ボールペン
[5] => 50%OFFシール
)
SQLに埋め込んでいるのは ? と , だけなので、値は安全にプレースホルダ経由で渡せます。なお、$ids が空のときは IN () となって構文エラーになるため、先に if ($ids === []) { return []; } のように分岐しておきます。
IN (:ids) に '1,2,5' という1つの文字列を渡した場合は、次のとおり1件だけが返りました。1つのプレースホルダは1つの値として扱われるためです。
IN (:ids) に '1,2,5': Array
(
[0] => 1
)
LIMITとOFFSET
$stmt = $pdo->prepare('SELECT id FROM items ORDER BY id LIMIT :lim OFFSET :off');
$stmt->bindValue(':lim', 2, PDO::PARAM_INT);
$stmt->bindValue(':off', 1, PDO::PARAM_INT);
$stmt->execute();
print_r($stmt->fetchAll(PDO::FETCH_COLUMN));
Array
(
[0] => 2
[1] => 4
)
LIMIT と OFFSET は bindValue に PDO::PARAM_INT を付けて渡すのが確実です。 エミュレーションありの設定で execute([':lim' => '2']) と文字列を渡すと、LIMIT '2' と引用符つきで組み立てられて次のエラーになりました。
SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''2'' at line 1
テーブル名・列名・ORDER BYは許可リストで切り替える
プレースホルダで渡せるのは「値」だけです。テーブル名を :t で渡すと構文エラーになりました。
SQLSTATE[42000]: Syntax error or access violation: 1064 ... near '?' at line 1
並び順を画面から選ばせたいときは、あらかじめ用意した候補の中から選ぶ形にします。
$sortable = ['price' => 'price', 'new' => 'created_at', 'name' => 'name'];
$column = $sortable[$_GET['sort'] ?? 'new'] ?? 'created_at';
$stmt = $pdo->query("SELECT id, name FROM items ORDER BY {$column} DESC");
トランザクションで複数の更新をまとめる
トランザクションは、複数の更新を「全部成功」か「全部なかったこと」のどちらかにする仕組みです。beginTransaction() で始め、最後まで進んだら commit()、途中で例外が出たら rollBack() という形がテンプレートです。次の例は、ある商品の在庫を別の商品へ移します。
function moveStock(PDO $pdo, int $from, int $to, int $qty): void
{
$pdo->beginTransaction();
try {
$dec = $pdo->prepare('UPDATE items SET stock = stock - :q WHERE id = :id AND stock >= :q2');
$dec->execute([':q' => $qty, ':q2' => $qty, ':id' => $from]);
if ($dec->rowCount() === 0) {
throw new RuntimeException("id={$from} の在庫が足りません");
}
$inc = $pdo->prepare('UPDATE items SET stock = stock + :q WHERE id = :id');
$inc->execute([':q' => $qty, ':id' => $to]);
$pdo->commit();
echo "commit: {$from} → {$to} に {$qty} 個", PHP_EOL;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
echo 'rollBack: ', $e->getMessage(), PHP_EOL;
}
}
$show = fn() => print(json_encode(
$pdo->query('SELECT id, stock FROM items WHERE id IN (1,2)')->fetchAll(PDO::FETCH_KEY_PAIR)
) . PHP_EOL);
$show();
moveStock($pdo, 1, 2, 10);
$show();
moveStock($pdo, 1, 2, 500);
$show();
{"1":50,"2":200}
commit: 1 → 2 に 10 個
{"1":40,"2":210}
rollBack: id=1 の在庫が足りません
{"1":40,"2":210}
10個の移動は確定し、在庫を超える500個の移動は取り消されて、数字が変わっていないことが確認できます。エミュレーションなしの設定では同じ名前を2回使えないため、数量は :q と :q2 に分けています。
トランザクションで押さえておきたい点をまとめます。
| ポイント | 説明 |
|---|---|
rollBack() の前に inTransaction() を確認 |
トランザクションが始まっていない状態で呼ぶと There is no active transaction になる |
CREATE TABLE などのDDLは自動で確定される |
MySQLではDDLを実行した時点でトランザクションが終わる。今回の確認でも、DDLのあと inTransaction() は false になり、commit() は There is no active transaction になった |
| 入れ子にはできない | トランザクション中にもう一度 beginTransaction() を呼ぶと There is already an active transaction |
| テーブルは InnoDB で作る | トランザクションに対応したストレージエンジンが必要 |
PDOのエラー逆引き表
ここからは、よく目にするエラーを実際に発生させ、表示された文をそのまま載せます。SQLSTATE[HY000] のあとの [2002] や [1045] という数字がMySQLのエラー番号で、原因を見分ける手がかりになります。
接続時のエラー
| 表示されたエラー文 | 再現した条件 | 原因 | 対処 |
|---|---|---|---|
SQLSTATE[HY000] [2002] Connection refused |
ポート番号を別の番号にした | そのホスト・ポートでMySQLが待ち受けていない | MySQLが起動しているか、port が合っているかを確認する |
SQLSTATE[HY000] [2002] No such file or directory |
host=localhost で接続した |
localhost はソケットファイル経由の接続になり、そのファイルが見つからない |
host=127.0.0.1 に変えるか、unix_socket= に実際のパスを書く |
SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo for db.invalid failed: Name or service not known |
存在しないホスト名にした | ホスト名を名前解決できない | ホスト名のつづり、Docker Compose ならサービス名を確認する |
SQLSTATE[HY000] [1045] Access denied for user 'app'@'localhost' (using password: YES) |
パスワードを違う値にした | ユーザー名かパスワードが一致しない | 値を見直す。'app'@'接続元' の組でユーザーが作られているかも確認する |
SQLSTATE[HY000] [1049] Unknown database 'shopp' |
dbname のつづりを変えた |
その名前のデータベースがない | SHOW DATABASES; で名前を確認する。未作成なら CREATE DATABASE する |
SQLSTATE[HY000] [1044] Access denied for user 'app'@'%' to database 'mysql' |
権限のないDBを指定した | ユーザーにそのDBへの権限がない | GRANT ... ON DB名.* TO ... で権限を付ける |
SQLSTATE[HY000] [2019] Unknown character set |
charset=utf-8 と書いた |
MySQLにない文字コード名 | charset=utf8mb4 と書く |
could not find driver |
DSNを mysqli: で始めた/pdo_mysql を読み込まずに実行した |
DSN先頭のドライバ名に対応するPDOドライバがない | 下の「could not find driver の確認手順」を参照 |
PDO::__construct(): Argument #1 ($dsn) must be a valid data source name |
DSNの先頭に mysql: を付けなかった |
DSNの形式が正しくない | mysql:host=... の形にする |
[2002] には3種類のメッセージがあります。localhost と 127.0.0.1 は同じに見えて接続方法が異なり、localhost はソケットファイル、127.0.0.1 はTCP(ポート)で接続します。 No such file or directory が出たら、まず 127.0.0.1 に変えて試すと切り分けが早く進みます。ソケットで接続したい場合は次のように書きます(実際のパスは mysql -e "SHOW VARIABLES LIKE 'socket'" で確認できます)。
$pdo = new PDO('mysql:unix_socket=/var/run/mysqld/mysqld.sock;dbname=shop;charset=utf8mb4', 'app', 'secret');
could not find driver の確認手順
could not find driver は、MySQL用のPDOドライバ(pdo_mysql)が読み込まれていないときに出ます。pdo_mysql を読み込まない状態でPHPを起動して再現しました。
php -n -d extension=pdo -r 'print_r(PDO::getAvailableDrivers());
try { new PDO("mysql:host=127.0.0.1;dbname=shop","app","secret"); }
catch (Throwable $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }'
Array
(
)
PDOException: could not find driver
確認と対処は次の順で進めます。
| 手順 | 確認すること | 対処 |
|---|---|---|
| 1 | php -r 'print_r(PDO::getAvailableDrivers());' に mysql があるか |
なければ手順2へ |
| 2 | 拡張モジュールが入っているか | Ubuntu・Debian は apt install php8.4-mysql、Docker公式イメージは docker-php-ext-install pdo_mysql、Windows は php.ini の extension=pdo_mysql を有効にする(今回の環境では未実行。各環境の一般的な手順) |
| 3 | DSNの先頭が mysql: になっているか |
mysqli: や MySQL: になっていないか確認する |
| 4 | CLIとWebサーバーで設定が同じか | php --ini と、ブラウザで phpinfo() を開いた結果を見比べる。変更後はWebサーバーやPHP-FPMを再起動する |
SQL実行時のエラー
| 表示されたエラー文 | 再現した条件 | 原因と対処 |
|---|---|---|
SQLSTATE[HY093]: Invalid parameter number |
プレースホルダが2つのSQLに値を1つだけ渡した | SQL中のプレースホルダの数と、渡した値の数をそろえる |
SQLSTATE[HY093]: Invalid parameter number: parameter was not defined |
:id に対して [':idd' => 1] を渡した |
名前のつづりを合わせる |
SQLSTATE[HY093]: Invalid parameter number: mixed named and positional parameters |
? と :price を同じSQLに書いた |
どちらか一方にそろえる |
SQLSTATE[HY093]: Invalid parameter number(エミュレーションなし) |
同じ :q を2回使った |
:q1、:q2 のように名前を分ける |
SQLSTATE[42S02]: Base table or view not found: 1146 Table 'shop.item' doesn't exist |
テーブル名を item と書いた |
テーブル名と、接続先のDB名を確認する |
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'nama' in 'field list' |
列名を nama と書いた |
列名のつづりを確認する |
SQLSTATE[42000]: Syntax error or access violation: 1064 ... near 'FORM items' at line 1 |
FROM を FORM と書いた |
near '...' の直前を見直す |
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '1' for key 'items.PRIMARY' |
既にある主キーで INSERT した | 一意制約に当たっている。値を変えるか、更新に切り替える |
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'name' cannot be null |
NOT NULL の列に null を渡した |
入力チェックを加える |
Call to a member function fetch() on false |
ERRMODE_SILENT でSQLエラーのあと fetch() を呼んだ |
query() が false を返している。ERRMODE_EXCEPTION にすると本来のエラー文が見える |
SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active. |
exec() で SELECT 文を実行した直後に、次のSQLを実行した |
SELECT は query() で実行し、fetchAll() で結果を取り出してから次へ進む |
HY093 は「プレースホルダと渡した値の対応が合っていない」という意味です。数・名前・種類の混在・同名の重複、の4つを順に確認すると解決できます。
例外を受け取って原因を調べる
PDOException からは、エラー文のほかにコードも取り出せます。
try {
$pdo->query('SELECT * FROM item');
} catch (PDOException $e) {
echo 'errorInfo: ', json_encode($e->errorInfo), PHP_EOL;
}
errorInfo: ["42S02",1146,"Table 'shop.item' doesn't exist"]
| 取り出し方 | 内容 | 例 |
|---|---|---|
$e->getMessage() |
エラー文の全体 | SQLSTATE[42S02]: Base table or view not found: ... |
$e->getCode() |
SQL実行時は SQLSTATE(文字列)、接続時はMySQLのエラー番号(整数) | '42S02' / 1045 |
$e->errorInfo[0] |
SQLSTATE | 42S02 |
$e->errorInfo[1] |
MySQLのエラー番号 | 1146 |
$e->errorInfo[2] |
MySQLのメッセージ | Table 'shop.item' doesn't exist |
画面に出すときは、エラー文をそのまま表示せず、ログに記録して利用者には「しばらくしてからお試しください」のような案内を出す形にすると、テーブル名などの内部情報が外に出ません。
try {
$pdo = db();
} catch (PDOException $e) {
error_log($e->getMessage());
http_response_code(500);
exit('ただいま接続できません。しばらくしてからお試しください。');
}
文字列連結でSQLを組み立てるとどうなるか
プリペアドステートメントを使う理由を、実際の動きで確認します。入力値をそのままSQLにつなげた場合と、prepare を使った場合を比べました。
$input = "' OR '1'='1";
// 文字列連結で組み立てた場合
$sql = "SELECT id, name FROM items WHERE name = '" . $input . "'";
echo $sql, PHP_EOL;
echo '連結: ', count($pdo->query($sql)->fetchAll()), '件', PHP_EOL;
// プリペアドステートメントの場合
$s = $pdo->prepare('SELECT id, name FROM items WHERE name = :name');
$s->execute([':name' => $input]);
echo 'prepare: ', count($s->fetchAll()), '件', PHP_EOL;
SELECT id, name FROM items WHERE name = '' OR '1'='1'
連結: 4件
prepare: 0件
文字列連結では、入力値がSQLの一部として解釈され、条件が常に真になって全4件が返りました。prepare を使うと、入力値は最後まで「1つの値」として扱われるため、同じ入力でも0件です。 これがSQLインジェクションを防ぐ基本の仕組みです。
| 書き方 | 外から来た値の扱い | 使いどころ |
|---|---|---|
prepare() + bindValue()/execute() |
値として渡される | フォーム・URL・Cookieなど外から来る値を含むSQLはすべてこれ |
query()/exec() |
SQL文字列に含めたものがそのまま実行される | 値を含まない固定のSQL |
PHP 8.4のPdo\Mysqlサブクラス
PHP 8.4 では、ドライバごとのPDOサブクラスが追加されました。MySQL用は Pdo\Mysql です。公式マニュアルの Pdo\Mysql のページで確認したところ、導入は PHP 8.4.0、MySQL固有の定数(Pdo\Mysql::ATTR_INIT_COMMAND など)と、直前のSQLの警告数を返す getWarningCount() メソッドを持つクラスと説明されています。手元の PHP 8.4.21 でも動かしました。
$m = PDO::connect('mysql:host=127.0.0.1;port=3306;dbname=shop;charset=utf8mb4', 'app', 'secret');
echo get_class($m), PHP_EOL;
$m2 = new Pdo\Mysql('mysql:host=127.0.0.1;port=3306;dbname=shop;charset=utf8mb4', 'app', 'secret');
$m2->query("SELECT CAST('abc' AS SIGNED)")->fetchAll();
echo 'getWarningCount: ', $m2->getWarningCount(), PHP_EOL;
echo get_class(new PDO('mysql:host=127.0.0.1;port=3306;dbname=shop', 'app', 'secret')), PHP_EOL;
var_dump(Pdo\Mysql::ATTR_INIT_COMMAND === PDO::MYSQL_ATTR_INIT_COMMAND);
Pdo\Mysql
getWarningCount: 1
PDO
bool(true)
| 接続の書き方 | 得られるクラス | 特徴 |
|---|---|---|
new PDO('mysql:...') |
PDO |
これまでどおり。既存のコードはそのまま動く |
PDO::connect('mysql:...') |
Pdo\Mysql |
DSNに合わせたサブクラスが返る |
new Pdo\Mysql('mysql:...') |
Pdo\Mysql |
MySQL専用であることを型で表せる |
Pdo\Mysql に MySQL 以外のDSNを渡すと、次のように案内つきの例外になりました。
PDOException: Pdo\Mysql::__construct() cannot be used for connecting to the "sqlite" driver, either call Pdo\Sqlite::__construct() or PDO::__construct() instead
これまでの new PDO() もそのまま使えるので、急いで書き換える必要はありません。 新しく書くコードで、MySQL固有の機能を使うことを型で明示したい場合に Pdo\Mysql を選ぶとよいでしょう。
PDOを使うときのチェックリスト
| 確認項目 | 内容 |
|---|---|
DSNに charset=utf8mb4 がある |
日本語・絵文字を正しく扱う |
ERRMODE_EXCEPTION を指定している |
エラーを例外で受け取り、原因のメッセージを確認できる |
FETCH_ASSOC を既定にしている |
結果を列名でだけ受け取る |
| 外から来る値はすべてプレースホルダで渡している | SQL文字列に変数を直接つなげない |
LIMIT・OFFSET は PDO::PARAM_INT で結びつけている |
設定によらず動く |
fetch() の結果が false の場合を処理している |
見つからないときの表示を用意する |
| 複数の更新はトランザクションで囲んでいる | 途中で止まっても中途半端な状態を残さない |
| パスワードをコードに直接書いていない | 環境変数や設定ファイルから読み込む |
| エラー文をそのまま画面に出していない | ログに記録し、利用者には案内文を出す |
よくある質問
Q. PDOとmysqliはどちらを使えばよいですか?
A. どちらもMySQLに接続できます。PDOはMySQL以外のデータベースにも同じ書き方で対応でき、名前つきプレースホルダ(:id)が使えます。mysqliはMySQL専用で、MySQL固有の機能を細かく使えます。これから書くコードで特に理由がなければ、PDOを選ぶと応用が利きます。
Q. bindValue と bindParam はどちらを使えばよいですか?
A. 通常は bindValue を使います。bindValue は呼んだ時点の値を結びつけ、bindParam は変数への参照を結びつけて execute() の時点の値を使います。foreach の中で bindParam を使うと全部のプレースホルダが最後の値になるため、ループでは bindValue が向いています。
Q. prepare と query はどう使い分けますか?
A. フォームやURLなど外から来る値を含むSQLは prepare() を使い、値はプレースホルダで渡します。query() は SELECT COUNT(*) FROM items のように、値を含まない固定のSQLに使います。
Q. 接続はいつ閉じればよいですか?
A. PHPはスクリプトが終わると自動で接続を閉じるので、通常は何も書かなくて大丈夫です。長く動くバッチなどで早めに閉じたいときは、$stmt = null; $pdo = null; のように、PDOとステートメントの変数に null を代入します。
まとめ
- 接続は
new PDO($dsn, $user, $pass, $options)。DSNにcharset=utf8mb4、オプションにERRMODE_EXCEPTION・FETCH_ASSOC・EMULATE_PREPARES => falseを指定する - SQLの実行は
prepare()→bindValue()→execute()→fetch()の順。外から来る値は必ずプレースホルダで渡す bindValueは値、bindParamは変数への参照を結びつける。ループではbindValue、手早く書くならexecuteの配列渡しfetch()は1行(なければfalse)、fetchAll()は全行(なければ[])、fetchColumn()は1つの値- LIKEの
%は値の側に付け、IN句は要素数ぶんの?を組み立て、LIMITはPDO::PARAM_INTで渡す [2002]は接続先、[1045]は認証、[1049]はDB名、HY093 はプレースホルダの対応を確認する- PHP 8.4 では
Pdo\Mysqlサブクラスが加わり、PDO::connect()で取得できる。従来のnew PDO()もそのまま使える
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PHP PDOの使い方|MySQL接続からprepare・bindValueまで」
株式会社RJCでは、PDOを使ったデータベース操作の基礎を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。