【連載第9回】保存したデータを画面に出そう!PHPのループ処理と「XSS対策」の絶対ルール

LAMP講座

【連載第9回】「ブラックボックス」状態のアプリを見える化する

未経験からWebアプリ開発を目指す「AlmaLinux 9とLAMP環境で作る!Webアプリ開発完全ロードマップ(全12回)」の第9回です。

前回、私たちはPHPを使って「投稿機能(Create)」を実装しました。
フォームに文字を入力し、写真を選んでボタンを押すと、データベースとサーバーに保存される仕組みです。

しかし、今の状態はまるで「投入口しかないポスト」のようなものです。
手紙(データ)を入れることはできますが、中に入った手紙を取り出して読むことができません。
データベースの中身を確認するために、いちいち黒い画面でSQLコマンドを叩くのは、Webアプリとは言えませんよね。

今回は、データベースに保存されたデータをPHPで一括取得し、HTMLの中に埋め込んで一覧表示する「Read(読み取り)」機能を実装します。
これにより、掲示板としての機能がついに完成します。

コウ君

先生!前回の投稿機能、ちゃんと動いているか不安で夜も眠れませんでした…。
「投稿しました!」って出るけど、下に何も表示されないから実感が湧かなくて。
今日でやっと自分の投稿が見れるんですね!

リナックス先生

その不安も今日で解消よ。
データを画面に表示するのは簡単そうに見えるけど、実は「セキュリティの最大の落とし穴」が潜んでいる場所でもあるの。
ただ表示するだけだと、ハッカーにサイトを乗っ取られる危険があるわ。
今日は「安全な表示方法(サニタイズ)」についても徹底的に叩き込むから覚悟してね!

本講座のカリキュラム(全12回)

現在地は第9回です。アプリ開発編も佳境に入ってきました。

  1. サーバー準備編:なぜVPSが必要?AlmaLinux 9の初期設定とSSH接続
  2. Webサーバー編:Apache(httpd)のインストールとファイアウォール設定
  3. データベース編:MariaDB(MySQL)のインストールとセキュリティ設定
  4. プログラミング言語編:PHP 8.xの導入と設定ファイルのチューニング
  5. 権限・パーミッション編:LinuxでWebサイトを公開するための「所有者」の概念
  6. 接続テスト編:PHPからデータベース(DB)に接続してみよう
  7. アプリ開発①:HTML/CSSで掲示板の「見た目」を作る
  8. アプリ開発②:投稿機能(Create)の実装とデータの保存
  9. 【今回】アプリ開発③:一覧表示機能(Read)と画像表示の仕組み
  10. アプリ開発④:編集・削除機能(Update/Delete)の実装
  11. セキュリティ編:XSSやSQLインジェクション対策の基礎
  12. 公開編:独自ドメイン設定と無料SSL(Let’s Encrypt)でHTTPS化

仕組み:どうやってデータを表示するの?

コードを書く前に、今回の処理の流れ(ロジック)を整理しましょう。
データを一覧表示するには、以下の3ステップが必要です。

  1. 全件取得(SELECT):
    PHPからデータベースに「保存されている投稿を全部、新しい順にちょうだい!」と命令を出します。
  2. 配列への格納:
    データベースから返ってきたデータ(複数件)を、PHPの変数(配列)に一時保管します。
  3. ループ処理(foreach):
    HTMLの中で「データの数だけ同じデザインを繰り返す」という処理を行い、画面に書き出します。

手順1:データを取得するPHPコードを書く

それでは、index.php を編集していきましょう。
前回(第8回)作成したコードに、「データを取得する処理」を追記します。

vi /var/www/html/bbs/index.php

追記する場所は、前回の if ($_SERVER['REQUEST_METHOD'] === 'POST') { ... } のブロックが終わった直後、かつ ?>(PHP終了タグ)の前です。

追記するコード(データ取得部分)

// ▼ 投稿データの取得処理(GETでもPOSTでも実行する)
$posts = []; // 空の配列を用意

try {
    $pdo = new PDO($dsn, $user, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    // 投稿日時が新しい順(DESC)に全件取得
    $sql = "SELECT * FROM posts ORDER BY created_at DESC";
    $stmt = $pdo->query($sql);

    // 取得したデータを配列に格納
    $posts = $stmt->fetchAll(PDO::FETCH_ASSOC);

} catch (PDOException $e) {
    $error_message = 'DB接続エラー: ' . $e->getMessage();
}

解説:

  • SELECT * FROM posts : posts テーブルの全てのカラム(名前、コメント、画像パスなど)を取得します。
  • ORDER BY created_at DESC : これが重要です。「作成日時(created_at)」を基準に「降順(DESC)」、つまり「新しい投稿が上」に来るように並べ替えています。これを忘れると、古い投稿が一番上に表示されてしまいます。
  • fetchAll(PDO::FETCH_ASSOC) : 取得したデータを、使いやすい「連想配列」の形でごっそり変数 $posts に入れます。

手順2:HTMLの中でデータをループ表示する

次は、画面の見た目を作っているHTML部分の修正です。
第7回で書いた「ダミー記事(リナックス先生のテスト投稿など)」を削除し、代わりにPHPのループ処理を埋め込みます。

<div class="timeline"> の中身を、以下のように書き換えてください。

書き換え後のHTMLコード

        <!-- 投稿一覧エリア -->
        <div class="timeline">
            <?php foreach ($posts as $post): ?>
                <article class="post">
                    <div class="post-header">
                        <span class="post-name">
                            <?php echo htmlspecialchars($post['name'], ENT_QUOTES, 'UTF-8'); ?>
                        </span>
                        <span class="post-date">
                            <?php echo date('Y/m/d H:i', strtotime($post['created_at'])); ?>
                        </span>
                    </div>
                    <div class="post-content">
                        <p>
                            <?php echo nl2br(htmlspecialchars($post['comment'], ENT_QUOTES, 'UTF-8')); ?>
                        </p>
                        
                        <!-- 画像がある場合のみ表示 -->
                        <?php if (!empty($post['image_path'])): ?>
                            <img src="./images/<?php echo htmlspecialchars($post['image_path'], ENT_QUOTES, 'UTF-8'); ?>" alt="投稿画像" class="post-image">
                        <?php endif; ?>
                    </div>
                </article>
            <?php endforeach; ?>

            <!-- 投稿が一件もない場合の表示 -->
            <?php if (empty($posts)): ?>
                <p style="text-align: center; color: #888;">まだ投稿はありません。一番乗りになろう!</p>
            <?php endif; ?>
        </div>

【最重要】XSS(クロス・サイト・スクリプティング)対策

さて、ここが今回の記事で一番重要なポイントです。
先ほどのコードの中に、何度も出てきた呪文のような関数に気づきましたか?

htmlspecialchars($post['name'], ENT_QUOTES, 'UTF-8')

「これ、書かなくても表示されるから消してもいいですか?」
絶対にダメです。これを消すと、あなたのサイトは犯罪の踏み台になります。

XSS攻撃とは?

もし、悪意のあるユーザーが「名前」の欄に、以下のような文字を入力して投稿したらどうなるでしょうか?

<script>alert('ウイルスに感染しました!');</script>

もし htmlspecialchars を使わずにそのまま表示してしまうと、ブラウザはこの文字を「HTMLのプログラム」だと勘違いして実行してしまいます。
その結果、この掲示板を見た瞬間に、画面に変なポップアップが出たり、最悪の場合、閲覧者のクッキー情報(ログイン情報)が盗まれ、勝手にアカウントを乗っ取られる被害が発生します。

これを防ぐのが XSS対策(サニタイズ) です。

htmlspecialcharsの役割

この関数は、HTMLにとって特別な意味を持つ記号を、ただの「文字」に変換(エスケープ)してくれます。

  • < (小なり) → &lt;
  • > (大なり) → &gt;
  • " (ダブルクォート) → &quot;

これにより、<script> というタグを入力されても、ブラウザは「『<script>』という文字を表示すればいいんだな」と正しく解釈し、プログラムとしては実行されなくなります。

リナックス先生

「ユーザーが入力したデータを画面に出す時は、必ず htmlspecialchars を通す」。
これはWebエンジニアの義務教育であり、絶対のルールよ。
これを忘れるエンジニアは現場に入れてもらえないくらい重大なことだから、指が勝手に打つくらい体に染み込ませてね!

その他のこだわりポイント

改行の反映(nl2br)

HTMLでは、ソースコード上で改行しても、画面上ではスペース扱いになって繋がり、改行されません。
ユーザーがコメント欄で改行した場合、それを画面にも反映させるために nl2br() という関数を使っています。

注意: 順番が大事です。
nl2br(htmlspecialchars(...)) のように、先に無害化(htmlspecialchars)してから、最後に改行変換(nl2br)をします。逆にしてしまうと、せっかく nl2br が作った <br> タグまで無害化されてしまい、改行タグという文字が表示されてしまいます。

画像のパス指定

<img src="./images/<?php echo ... ?>" ...>

データベースには「ファイル名(例:photo.jpg)」しか入っていません。
そのため、表示する時は ./images/ というフォルダ名を頭にくっつけてあげる必要があります。

完全版ソースコード(index.php)

前回の「投稿処理」と今回の「表示処理」を合体させた、index.php の完成形を掲載します。
記述ミスがないか不安な方は、以下のコードを丸ごとコピーして上書きしてください。

<?php
// エラー表示(本番環境では削除または0にする)
ini_set('display_errors', 1);
error_reporting(E_ALL);

// DB接続情報
$dsn = 'mysql:dbname=app_db;host=localhost;charset=utf8mb4';
$user = 'app_user';
$password = 'password123';

$success_message = null;
$error_message = null;

// ▼ 1. 投稿機能(POST時の処理)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $name = $_POST['name'] ?? '';
    $comment = $_POST['comment'] ?? '';
    $image_filename = null;

    if (empty($name) || empty($comment)) {
        $error_message = '名前とコメントは必須です。';
    } else {
        // 画像アップロード
        if (!empty($_FILES['image']['name']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
            $original_name = $_FILES['image']['name'];
            $extension = pathinfo($original_name, PATHINFO_EXTENSION);
            $new_filename = date('YmdHis') . '_' . uniqid() . '.' . $extension;
            $upload_dir = '/var/www/html/bbs/images/';
            
            if (move_uploaded_file($_FILES['image']['tmp_name'], $upload_dir . $new_filename)) {
                $image_filename = $new_filename;
            } else {
                $error_message = '画像のアップロードに失敗しました。';
            }
        }

        // DB保存
        if (empty($error_message)) {
            try {
                $pdo = new PDO($dsn, $user, $password);
                $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
                $sql = "INSERT INTO posts (name, comment, image_path) VALUES (:name, :comment, :image)";
                $stmt = $pdo->prepare($sql);
                $stmt->bindValue(':name', $name, PDO::PARAM_STR);
                $stmt->bindValue(':comment', $comment, PDO::PARAM_STR);
                $stmt->bindValue(':image', $image_filename, $image_filename ? PDO::PARAM_STR : PDO::PARAM_NULL);
                $stmt->execute();
                $success_message = '投稿しました!';
            } catch (PDOException $e) {
                $error_message = 'DBエラー: ' . $e->getMessage();
            }
        }
    }
}

// ▼ 2. 一覧表示機能(GET時の処理)
$posts = [];
try {
    $pdo = new PDO($dsn, $user, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    // 作成日時の新しい順に取得
    $sql = "SELECT * FROM posts ORDER BY created_at DESC";
    $stmt = $pdo->query($sql);
    $posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
    $error_message = 'データ取得エラー: ' . $e->getMessage();
}
?>

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ひとこと掲示板</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>ひとこと掲示板</h1>
    </header>

    <main>
        <?php if ($success_message): ?>
            <div class="alert success">
                <?php echo htmlspecialchars($success_message, ENT_QUOTES, 'UTF-8'); ?>
            </div>
        <?php endif; ?>

        <?php if ($error_message): ?>
            <div class="alert error">
                <?php echo htmlspecialchars($error_message, ENT_QUOTES, 'UTF-8'); ?>
            </div>
        <?php endif; ?>

        <div class="form-container">
            <form action="index.php" method="post" enctype="multipart/form-data">
                <div class="input-group">
                    <label>ニックネーム</label>
                    <input type="text" name="name" required placeholder="例:名無しさん">
                </div>
                <div class="input-group">
                    <label>コメント</label>
                    <textarea name="comment" required placeholder="今の気持ちをつぶやこう"></textarea>
                </div>
                <div class="input-group">
                    <label>画像(任意)</label>
                    <input type="file" name="image" accept="image/*">
                </div>
                <button type="submit" name="submit" class="submit-btn">投稿する</button>
            </form>
        </div>

        <hr>

        <div class="timeline">
            <?php foreach ($posts as $post): ?>
                <article class="post">
                    <div class="post-header">
                        <span class="post-name">
                            <?php echo htmlspecialchars($post['name'], ENT_QUOTES, 'UTF-8'); ?>
                        </span>
                        <span class="post-date">
                            <?php echo date('Y/m/d H:i', strtotime($post['created_at'])); ?>
                        </span>
                    </div>
                    <div class="post-content">
                        <p>
                            <?php echo nl2br(htmlspecialchars($post['comment'], ENT_QUOTES, 'UTF-8')); ?>
                        </p>
                        <?php if (!empty($post['image_path'])): ?>
                            <img src="./images/<?php echo htmlspecialchars($post['image_path'], ENT_QUOTES, 'UTF-8'); ?>" alt="投稿画像" class="post-image">
                        <?php endif; ?>
                    </div>
                </article>
            <?php endforeach; ?>

            <?php if (empty($posts)): ?>
                <p style="text-align: center; color: #888;">まだ投稿はありません。一番乗りになろう!</p>
            <?php endif; ?>
        </div>
    </main>

    <footer>
        <p>&copy; 2026 Linux Koubou BBS Project</p>
    </footer>
</body>
</html>

動作確認:感動の瞬間

それでは、ブラウザでページを再読み込みしてみましょう。
前回テスト投稿した内容が、きれいに一覧表示されているはずです!

さらに、新しい投稿をしてみてください。
「投稿する」ボタンを押すと、画面上部に「投稿しました!」と出て、すぐ下のタイムラインの一番上に自分の投稿が追加される。

この一連の流れこそが、Webアプリケーションの基本サイクルです。
FacebookもTwitter(X)も、基本はこの仕組みの延長線上にあります。

次回予告:投稿の「編集」と「削除」

これで「投稿(Create)」と「表示(Read)」が完成しました。
しかし、人間誰しも書き間違いをします。「あ、間違えた!」と思った時に、編集や削除ができないと不便ですよね。

次回は、CRUD(クラッド)機能の残り2つ、「Update(更新)」と「Delete(削除)」を実装します。
「どの投稿を消すのか?」を識別するための IDの受け渡し が鍵になりますよ。

リナックス先生

ついに掲示板として遊べるようになったわね!
コウ君、友達に自慢してきてもいいわよ。
でも、「XSS対策」の話はちゃんと覚えておいてね。安全第一がプロへの近道よ!

▼この講座で使用している推奨VPS

【2026年最新】Linuxサーバー構築におすすめのVPS比較3選!現役エンジニアが速度とコスパで厳選
Linuxの勉強、まだ「自分のPC」でやって消耗していませんか?「Linuxを覚えたいけど、環境構築でエラーが出て先に進めない…」「VirtualBoxを入れたらパソコンが重くなった…」これは、Linux学習を始める9割の人がぶつかる壁です...

コメント