Menu

PHPのPDO:MySQLへの接続とプリペアドステートメント

PDOはPHPのデータベース層です。new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', $user, $pass)で接続し、値を含むすべてのクエリをprepare()とexecute([$value])で実行します。接続、プリペアドステートメント、行の取得、挿入、トランザクション、WHERE INを学びます。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

PDO(PHP Data Objects)は、データベースとやり取りするための組み込みの方法です。new PDO($dsn, $user, $password)で接続し、値を含むクエリはすべてprepare()とexecute()で実行して、値がSQLとは別に送られるようにします。

<?php
$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'shop_user', 'secret', [
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();      // an array, or false if no row matched

このページの例はデータベースサーバーが必要なので、普通のコードとして示しています。下のSQLインジェクションの例は実行できます。

PDOでMySQLに接続する

1つ目の引数はDSNで、ドライバー名のあとに接続の詳細が続きます。接続は1か所にまとめ、必要な場所でincludeしましょう。

<?php
// db.php
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=shop;charset=utf8mb4';

$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,  // the default since PHP 8.0
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,        // rows as ['id' => 1, ...]
    PDO::ATTR_EMULATE_PREPARES   => false,                   // real prepared statements, real int types
];

try {
    $pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), $options);
} catch (PDOException $e) {
    error_log($e->getMessage());
    http_response_code(500);
    exit('Database unavailable.');
}

return $pdo;
  • charset=utf8mb4はDSNに含めます。これがないと、アクセント付きの文字や絵文字が?や文字化けしたバイトで届くことがあります。
  • $e->getMessage()を訪問者に出力してはいけません。ホストやユーザー名が含まれていることがあります。ログに残しましょう。
  • パスワードはコードに書かず、環境変数か、Webのルートの外の設定ファイルに置きます。

ほかのデータベースではDSNが変わるだけです。PostgreSQLならpgsql:host=localhost;dbname=shop、SQLiteのファイルならsqlite:/var/data/app.dbです。それぞれにPDOのドライバー(pdo_mysql、pdo_pgsql、pdo_sqlite)のインストールが必要で、php -mで入っているものを一覧できます。

?と:nameのプリペアドステートメント

プレースホルダーは値が入る場所を示します。位置で指定する?のプレースホルダーにはリストを、名前付きのものには連想配列を使います。

<?php
// Positional
$stmt = $pdo->prepare('SELECT * FROM products WHERE category = ? AND price < ?');
$stmt->execute(['books', 20]);

// Named: easier to read when there are many values
$stmt = $pdo->prepare('SELECT * FROM products WHERE category = :cat AND price < :max');
$stmt->execute(['cat' => 'books', 'max' => 20]);

// bindValue when you want to set the type explicitly
$stmt = $pdo->prepare('SELECT * FROM products ORDER BY id LIMIT :limit');
$stmt->bindValue('limit', 10, PDO::PARAM_INT);
$stmt->execute();

プレースホルダーを引用符で囲んではいけません。WHERE name = '?'は文字どおりのテキスト?と比較してしまいます。プリペアドステートメントは違う値で何度でも実行できるので、ループの中ではなく、ループの前で1回だけ準備しましょう。

SQLで文字列を連結するのが危険な理由

このページで唯一実行できるブロックです。同じ入力に対して、間違った方法と正しい方法でクエリを組み立てます。$emailを変えて、もう一度実行してみてください。

連結したクエリでは、入力の中の引用符が文字列を閉じ、OR '1'='1'がすべての行で条件をtrueにするので、クエリはすべてのユーザーを返します。ログインフォームなら、パスワードを知らずにログインできてしまうということで、ドライバーが1回の呼び出しで複数の文を受け付けるなら、'; DROP TABLE users; --のような入力で攻撃者の選んだ2つ目の文が実行されます。プリペアドの版では、データベースはSQLと値を別々に受け取るので、引用符は誰にも一致しないメールアドレスの中の1文字にすぎません。

addslashes()のようなエスケープ関数は解決策になりません。接続の文字セットに依存するケースを見逃すからです。数値や「自分のコードから来る」値も含め、すべての値にプレースホルダーを使いましょう。

行を取得する:fetch、fetchAll、fetchColumn

<?php
// One row
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([42]);
$user = $stmt->fetch();                 // ['id' => 42, 'name' => 'Ada', ...] or false
if ($user === false) {
    http_response_code(404);
}

// All rows
$rows = $pdo->query('SELECT id, name FROM users ORDER BY name')->fetchAll();

// Loop row by row without building an array of all rows
$stmt = $pdo->query('SELECT id, name FROM users');
foreach ($stmt as $row) {
    echo $row['name'], "\n";
}

// One value
$count = $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();

// Two columns as key => value
$names = $pdo->query('SELECT id, name FROM users')->fetchAll(PDO::FETCH_KEY_PAIR);  // [42 => 'Ada', ...]

SQLに外部からの値が含まれないならquery()で問題ありません。変数が関わったとたんにprepare()に切り替えましょう。

挿入、更新、削除

<?php
$stmt = $pdo->prepare('INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?)');
$stmt->execute([$email, $name, password_hash($password, PASSWORD_DEFAULT)]);
$id = (int) $pdo->lastInsertId();

$stmt = $pdo->prepare('UPDATE users SET name = ? WHERE id = ?');
$stmt->execute([$newName, $id]);
echo $stmt->rowCount();                 // rows changed

$stmt = $pdo->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
$stmt->execute();

lastInsertId()は文字列を返すので、キャストします。MySQLでは、UPDATEのあとのrowCount()は実際に変わった行を数えるので、名前をすでに持っている値に設定すると0になります。パスワードは平文ではなく、password_hashで保存しましょう。

トランザクション

トランザクションは、複数の文を一緒に成功させるか一緒に失敗させます。何かが例外を投げたらロールバックし、何も保存されません。

<?php
try {
    $pdo->beginTransaction();

    $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')->execute([100, $from]);
    $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')->execute([100, $to]);
    $pdo->prepare('INSERT INTO transfers (from_id, to_id, amount) VALUES (?, ?, ?)')->execute([$from, $to, 100]);

    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $e;
}

これは、エラーが例外を投げること(PHP 8.0以降のデフォルト)に頼っているので、失敗した文はcatchに飛びます。MySQLでは、テーブルはInnoDBでなければなりません。MyISAMはトランザクションを無視します。Throwableと再スローについてはtry/catchのページで扱っています。

配列を使ったWHERE IN

1つの?が持てるのは1つの値なので、配列でIN (?)としても動きません。値ごとにプレースホルダーを1つ作ります。

<?php
$ids = [3, 8, 15];

if ($ids) {
    $placeholders = implode(',', array_fill(0, count($ids), '?'));   // "?,?,?"
    $stmt = $pdo->prepare("SELECT * FROM products WHERE id IN ($placeholders)");
    $stmt->execute($ids);
    $products = $stmt->fetchAll();
} else {
    $products = [];                     // IN () would be a syntax error
}

SQLに埋め込まれる文字列は疑問符とカンマだけなので、クエリは今でも安全です。

プレースホルダーのよくあるミス

  • テーブル名とカラム名。 ORDER BY ?はカラムではなく一定の文字列で並べ替えてしまいます。識別子は許可リストから選びます:$sort = in_array($_GET['sort'] ?? '', ['name', 'price'], true) ? $_GET['sort'] : 'name';としてから、$sortをSQLに入れます。
  • エミュレートされたプリペアでのLIMIT ?。 MySQLでデフォルトのATTR_EMULATE_PREPARESのままexecute(['10'])とすると、構文エラーになるLIMIT '10'が送られます。PDO::PARAM_INTでバインドするか、エミュレーションをオフにしましょう。
  • 同じ名前付きプレースホルダーを2回使う。 WHERE first = :q OR last = :qが動くのはエミュレートされたプリペアのときだけです。本物のプリペアではSQLSTATE[HY093]: Invalid parameter numberになるので、:q1と:q2を使います。
  • fetch()がfalseを返すことを忘れる。 falseに対する$user['name']は警告とnullになります。先に結果を確認しましょう。

よくある質問

PDOでMySQLに接続するには?

DSN、ユーザー、パスワードでPDOオブジェクトを作ります:$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'user', 'secret', [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);。接続に失敗するとPDOExceptionが投げられるので、キャッチしてメッセージをログに残し、訪問者には一般的なエラーを表示します。

プリペアドステートメントでSQLインジェクションを防げますか?

値については防げます。prepare('... WHERE id = ?')とexecute([$id])では値がデータとして送られ、クエリを変えることは決してできません。プレースホルダーはテーブル名、カラム名、ASC/DESCの代わりにはなれないので、それらはSQLに入れる前に許可した名前の一覧と照合しましょう。

PDOのfetchとfetchAllの違いは何ですか?

fetch()は次の行を返し(もうなければfalse)、1行だけの場合や大きな結果をループする場合に向いています。fetchAll()はすべての行を一度に配列で返します。fetchColumn()は次の行から1つの値を返し、SELECT COUNT(*)に便利です。

PDOで配列を使ってWHERE INするには?

値ごとに?を1つ作り、配列をexecute()に渡します:$in = implode(',', array_fill(0, count($ids), '?')); $stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ($in)"); $stmt->execute($ids);。IN ()は構文エラーなので、先に配列が空でないことを確認しましょう。

PDOとmysqliのどちらを使うべきですか?

MySQL専用の機能が必要でない限りPDOを使いましょう。APIはMySQL、PostgreSQL、SQLiteなどで共通で、名前付きプレースホルダーに対応し、PHP 8.0以降はデフォルトでエラー時に例外を投げます。mysqliはMySQLとMariaDBでしか使えません。

Coddyのプログラミング言語のイラスト

Coddyでコードを学ぼう

始める