【PHP入門】PDOとMySQLでCRUDを作る プリペアドステートメントの実践

Webアプリでは、フォームから受け取ったデータをデータベースへ保存し、一覧表示・編集・削除します。この4操作を、Create・Read・Update・Deleteの頭文字からCRUDと呼びます。

この記事では、PHPのPDOとMySQLを使って小さなタスク管理アプリを作ります。SQLインジェクションを防ぐプリペアドステートメント、出力時エスケープ、CSRF対策、例外処理も含めます。

先に安全なフォーム処理例外処理を読んでおくと理解しやすくなります。

完成するアプリ

次の機能を持つタスク管理アプリを作ります。

  • タスクを登録する(Create)
  • 登録済みタスクを一覧表示する(Read)
  • タイトルと完了状態を変更する(Update)
  • 不要なタスクを削除する(Delete)

必要な環境

  • PHP 8.2以降
  • MySQL 8系または互換性のあるMariaDB
  • PDO MySQL拡張

WindowsはXAMPP、MacはMAMPでも練習できます。

ファイル構成

task-app/
├── bootstrap.php
├── functions.php
├── index.php
├── create.php
├── edit.php
└── delete.php

データベースとテーブルを作る

MySQLへ接続し、次のSQLを実行します。

CREATE DATABASE php_learning
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

USE php_learning;

CREATE TABLE tasks (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(100) NOT NULL,
    completed TINYINT(1) NOT NULL DEFAULT 0,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP
);

文字コードには、絵文字も扱えるutf8mb4を使用します。

PDOでMySQLへ接続する

bootstrap.phpでセッションを開始し、環境変数から接続情報を読み込みます。パスワードを公開ディレクトリやGitへ保存しないようにします。

<?php
// bootstrap.php
declare(strict_types=1);

session_start();

require_once __DIR__ . "/functions.php";

$host = getenv("DB_HOST") ?: "127.0.0.1";
$port = getenv("DB_PORT") ?: "3306";
$database = getenv("DB_NAME") ?: "php_learning";
$username = getenv("DB_USER") ?: "root";
$password = getenv("DB_PASSWORD") ?: "";

$dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4";

try {
    $pdo = new PDO($dsn, $username, $password, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);
} catch (PDOException $exception) {
    error_log((string) $exception);
    http_response_code(500);
    exit("データベースへ接続できませんでした");
}

PDO::ATTR_ERRMODEPDO::ERRMODE_EXCEPTIONにすると、SQL実行時の問題を例外として処理できます。利用者向け画面には接続先やパスワードを表示しません。

共通関数を作る

functions.phpに、HTMLエスケープ、CSRFトークン、ID検証をまとめます。

<?php
// functions.php
declare(strict_types=1);

function escape(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        "UTF-8"
    );
}

function csrfToken(): string
{
    if (!isset($_SESSION["csrf_token"])) {
        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
    }

    return $_SESSION["csrf_token"];
}

function verifyCsrfToken(): void
{
    $sessionToken = $_SESSION["csrf_token"] ?? "";
    $postedToken = (string) ($_POST["csrf_token"] ?? "");

    if ($sessionToken === "" || !hash_equals($sessionToken, $postedToken)) {
        http_response_code(403);
        exit("不正なリクエストです");
    }
}

function requestId(): int
{
    $id = filter_input(INPUT_GET, "id", FILTER_VALIDATE_INT, [
        "options" => ["min_range" => 1],
    ]);

    if ($id === false || $id === null) {
        http_response_code(400);
        exit("正しいIDを指定してください");
    }

    return $id;
}

function redirect(string $location): never
{
    header("Location: {$location}", true, 303);
    exit;
}

Read:タスクを一覧表示する

固定されたSQLで全件取得するときはquery()を使えます。並び順をSQLで指定します。

<?php
// index.php
declare(strict_types=1);

require __DIR__ . "/bootstrap.php";

$statement = $pdo->query(
    "SELECT id, title, completed, created_at
     FROM tasks
     ORDER BY id DESC"
);
$tasks = $statement->fetchAll();
?>
<!doctype html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>タスク管理</title>
</head>
<body>
    <h1>タスク管理</h1>

    <form method="post" action="create.php">
        <input type="hidden" name="csrf_token"
               value="<?= escape(csrfToken()) ?>">
        <label for="title">新しいタスク</label>
        <input id="title" name="title" maxlength="100" required>
        <button type="submit">登録</button>
    </form>

    <?php if ($tasks === []): ?>
        <p>タスクはありません。</p>
    <?php else: ?>
        <ul>
            <?php foreach ($tasks as $task): ?>
                <li>
                    <?= $task["completed"] ? "【完了】" : "【未完了】" ?>
                    <?= escape($task["title"]) ?>

                    <a href="edit.php?id=<?= (int) $task["id"] ?>">編集</a>

                    <form method="post"
                          action="delete.php?id=<?= (int) $task["id"] ?>"
                          style="display:inline">
                        <input type="hidden" name="csrf_token"
                               value="<?= escape(csrfToken()) ?>">
                        <button type="submit">削除</button>
                    </form>
                </li>
            <?php endforeach; ?>
        </ul>
    <?php endif; ?>
</body>
</html>

データベースから取得したタイトルにもescape()を使います。「自分で保存した値だから安全」とは考えず、HTMLへ出す場所でエスケープしてください。

Create:プリペアドステートメントで登録する

利用者の値をSQL文字列へ直接連結してはいけません。プレースホルダー:titleを含むSQLをprepare()し、execute()で値を渡します。

<?php
// create.php
declare(strict_types=1);

require __DIR__ . "/bootstrap.php";

if ($_SERVER["REQUEST_METHOD"] !== "POST") {
    http_response_code(405);
    exit("POSTで送信してください");
}

verifyCsrfToken();

$title = trim((string) ($_POST["title"] ?? ""));

if ($title === "" || mb_strlen($title) > 100) {
    http_response_code(422);
    exit("タスクは1文字以上100文字以内で入力してください");
}

$statement = $pdo->prepare(
    "INSERT INTO tasks (title) VALUES (:title)"
);
$statement->execute(["title" => $title]);

redirect("index.php");

危険なSQLの作り方

<?php
// 危険な例:実際のアプリでは使用しない
$sql = "INSERT INTO tasks (title) VALUES ('" . $_POST["title"] . "')";
$pdo->exec($sql);

入力値がSQLの一部として解釈され、SQLインジェクションにつながります。入力値は必ずプレースホルダーへ渡します。

Update:編集画面で更新する

GETでは編集対象を表示し、POSTでは検証した値で更新します。

<?php
// edit.php
declare(strict_types=1);

require __DIR__ . "/bootstrap.php";

$id = requestId();

$select = $pdo->prepare(
    "SELECT id, title, completed FROM tasks WHERE id = :id"
);
$select->execute(["id" => $id]);
$task = $select->fetch();

if ($task === false) {
    http_response_code(404);
    exit("タスクが見つかりません");
}

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    verifyCsrfToken();

    $title = trim((string) ($_POST["title"] ?? ""));
    $completed = isset($_POST["completed"]) ? 1 : 0;

    if ($title === "" || mb_strlen($title) > 100) {
        http_response_code(422);
        exit("タスクは1文字以上100文字以内で入力してください");
    }

    $update = $pdo->prepare(
        "UPDATE tasks
         SET title = :title, completed = :completed
         WHERE id = :id"
    );
    $update->execute([
        "title" => $title,
        "completed" => $completed,
        "id" => $id,
    ]);

    redirect("index.php");
}
?>
<!doctype html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>タスク編集</title>
</head>
<body>
    <h1>タスク編集</h1>
    <form method="post">
        <input type="hidden" name="csrf_token"
               value="<?= escape(csrfToken()) ?>">

        <label for="title">タスク名</label>
        <input id="title" name="title" maxlength="100" required
               value="<?= escape($task["title"]) ?>">

        <label>
            <input type="checkbox" name="completed"
                   <?= $task["completed"] ? "checked" : "" ?>>
            完了
        </label>

        <button type="submit">更新</button>
    </form>
    <a href="index.php">一覧へ戻る</a>
</body>
</html>

Delete:POSTで削除する

削除は状態を変更する操作なので、GETではなくPOSTとCSRFトークンを使います。

<?php
// delete.php
declare(strict_types=1);

require __DIR__ . "/bootstrap.php";

if ($_SERVER["REQUEST_METHOD"] !== "POST") {
    http_response_code(405);
    exit("POSTで送信してください");
}

verifyCsrfToken();
$id = requestId();

$statement = $pdo->prepare(
    "DELETE FROM tasks WHERE id = :id"
);
$statement->execute(["id" => $id]);

redirect("index.php");

プリペアドステートメントを使う理由

プリペアドステートメントでは、SQLの構造と入力値を分けて扱います。値をSQLとして解釈させないため、SQLインジェクション対策になります。

ただし、テーブル名や並び順などSQLの構造部分はプレースホルダーにできません。並び順を利用者に選ばせる場合は、許可した値の対応表を用意してください。

実行方法

接続情報を環境変数へ設定し、アプリのディレクトリでPHPの開発サーバーを起動します。

export DB_HOST=127.0.0.1
export DB_PORT=3306
export DB_NAME=php_learning
export DB_USER=root
export DB_PASSWORD='ローカル環境のパスワード'

php -S 127.0.0.1:8000

ブラウザでhttp://127.0.0.1:8000/index.phpを開き、登録・一覧・編集・削除を確認します。開発サーバーは学習用です。そのまま本番公開には使用しないでください。

よくあるトラブル

  • could not find driver:PDO MySQL拡張が有効か確認する
  • Access denied:ユーザー名、パスワード、接続元権限を確認する
  • Unknown database:データベースを先に作成する
  • 文字化け:DSNとテーブルをutf8mb4にする
  • headers already sentheader()より前に文字を出力しない

練習問題

  1. タスクに期限を表すdue_date列を追加する
  2. 登録・編集フォームで日付形式を検証する
  3. 一覧を未完了、完了の順に並べる
  4. 存在しないIDを削除した場合のメッセージを追加する

まとめ

  • PDOへ接続するときは例外モードとutf8mb4を設定する
  • 利用者の値はSQLへ連結せず、プリペアドステートメントへ渡す
  • HTML出力時のエスケープと、POST操作のCSRF対策を忘れない
  • 登録成功後はPRGで一覧へ戻す

次は、PHPプロジェクトの依存関係とクラス読み込みを整理するComposerとPSR-4オートロードへ進みましょう。

PHP学習ロードマップへ戻る

関連記事

コメント

この記事へのコメントはありません。