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_ERRMODEをPDO::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 sent:
header()より前に文字を出力しない
練習問題
- タスクに期限を表す
due_date列を追加する - 登録・編集フォームで日付形式を検証する
- 一覧を未完了、完了の順に並べる
- 存在しないIDを削除した場合のメッセージを追加する
まとめ
- PDOへ接続するときは例外モードと
utf8mb4を設定する - 利用者の値はSQLへ連結せず、プリペアドステートメントへ渡す
- HTML出力時のエスケープと、POST操作のCSRF対策を忘れない
- 登録成功後はPRGで一覧へ戻す
次は、PHPプロジェクトの依存関係とクラス読み込みを整理するComposerとPSR-4オートロードへ進みましょう。
コメント