公開日 2026-03-23

WSL2 + Docker + PHP + PostgreSQLで最小CRUDを作る

WSL2とDocker上で生PHPとPostgreSQLを使った最小CRUDを作り、DB付きアプリの流れを確認する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 新規デモ環境を作成して起動する
  4. 3. PostgreSQLを初期化して接続を確認する
  5. 4. 一覧と作成を最小実装する
  6. コードのポイント
  7. 5. 更新と削除を追加してCRUDを完成させる
  8. コードのポイント
  9. 6. 壊れたときに戻せる初期化手順を用意する
  10. 7. よくある詰まりを症状別に切り分ける
  11. 8. まとめと次の一歩

VS Codeで始めるPHP開発環境 と同じく、WSL2 + DockerRemote - WSL 前提で進めます。
フレームワークは使わず、生PHP(PDO)だけで「作成 -> 一覧 -> 更新 -> 削除」を最小構成で通します。

最短で「DB付きPHPアプリが実際に動く」体験を得つつ、画面確認だけで終わらせず、psql でも同じ結果を確かめます。

対象読者: VS Codeで始めるPHP開発環境 などの前提記事を完了し、生PHP + PostgreSQL でCRUDを初めて通したい読者。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL連携有効)

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
記事内のサービス名は app(PHP)と db(PostgreSQL)に固定しています。

1. ゴールと非対象

この記事で到達する状態:

  • localhost:8080 でPHP画面を開き、tasks の作成/一覧/更新/削除ができる
  • 画面の結果と psql の結果が一致することを確認できる
  • compose.yml / Dockerfile / init.sql / index.php の最小構成を再現できる

この記事で扱わない内容:

  • 認証/認可
  • ORM(Laravel Eloquent / Doctrine など)
  • トランザクション設計の詳細
  • 本番運用(バックアップ、監視、高可用性)

2. 新規デモ環境を作成して起動する

ここから先のディレクトリ作成・起動確認はシェルコマンドで進めます。
ファイルは全文を掲載するので、VS Code貼り付けでも cat > ファイル名 <<'EOF' 形式でも構いません。ここでは全文コピペで再現できることを優先します。

まず、作業ディレクトリを作成します。

mkdir -p ~/projects/php-postgresql-minimal-crud-demo
cd ~/projects/php-postgresql-minimal-crud-demo
mkdir -p docker/php docker/db public
code .

compose.yml を作成します。

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /var/www/html
    volumes:
      - ./:/var/www/html
    env_file:
      - .env
    command: ["php", "-S", "0.0.0.0:8080", "-t", "/var/www/html/public"]
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./docker/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  db-data:

appdepends_on: condition: service_healthydb のヘルスチェック成功後に起動します。
depends_on だけでは DB の起動完了(接続受付)までは待たないため、初回接続エラーを減らす目的でこの構成にしています。

docker/php/Dockerfile を作成します。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends libpq-dev \
    && docker-php-ext-install pdo_pgsql \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /var/www/html

docker/db/init.sql は2章時点でプレースホルダだけ作成します(中身は3章で確定)。

-- 3章で tasks テーブル定義を記載します。

.env.example を作成します。
接続値をコードへ直書きせず、環境変数として扱うためです。4章で getenv から参照します。

POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app

APP_DB_HOST=db
APP_DB_PORT=5432
APP_DB_NAME=app
APP_DB_USER=app
APP_DB_PASS=app

.env を用意して起動します。

cp .env.example .env
docker compose up -d --build
docker compose ps

.env は Git へコミットしない想定です。.gitignore.env を追加して管理対象外にしてください。

確認ポイント:

  • appdbUp になっている
  • localhost:8080 ポートが公開されている

詰まったとき:

docker compose logs app
docker compose logs db

3. PostgreSQLを初期化して接続を確認する

2章で作成した docker/db/init.sql を、tasks テーブル定義に置き換えます。

CREATE TABLE IF NOT EXISTS tasks (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  is_done BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

init.sql は DB 初回起動時に読み込まれるため、2章で既に起動済みならボリュームを初期化して再起動します。

docker compose down -v
docker compose up -d --build

psql でテーブルを確認します。

コマンド全体をシングルクォートで渡し、コンテナ内シェル(sh -c)で POSTGRES_USER / POSTGRES_DB を展開します。

docker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\dt"'
docker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\d tasks"'

ここでテーブルが見えない場合は、6章の初期化手順で再実行してください。
以降も、画面確認と psql 確認をセットで進めます。

4. 一覧と作成を最小実装する

public/index.php を作成します(Read/Create版)。

<?php
declare(strict_types=1);

function h(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}

$host = getenv('APP_DB_HOST') ?: 'db';
$port = getenv('APP_DB_PORT') ?: '5432';
$dbName = getenv('APP_DB_NAME') ?: 'app';
$user = getenv('APP_DB_USER') ?: 'app';
$pass = getenv('APP_DB_PASS') ?: 'app';

$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $dbName);
$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];

$errorMessage = '';
$tasks = [];

try {
    $pdo = new PDO($dsn, $user, $pass, $options);

    if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'create') {
        $title = trim((string) ($_POST['title'] ?? ''));

        if ($title === '') {
            $errorMessage = 'タスク名は必須です。';
        } else {
            $stmt = $pdo->prepare('INSERT INTO tasks (title) VALUES (:title)');
            $stmt->execute([':title' => $title]);

            header('Location: /index.php');
            exit;
        }
    }

    $tasks = $pdo->query('SELECT id, title, is_done, created_at FROM tasks ORDER BY id DESC')->fetchAll();
} catch (Throwable $e) {
    $errorMessage = 'DB接続またはSQL実行でエラーが発生しました: ' . $e->getMessage();
}
?>
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tasks Read/Create</title>
  <style>
    body { font-family: sans-serif; margin: 24px; }
    form { margin-bottom: 16px; }
    input[type="text"] { min-width: 280px; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
    .error { color: #b00020; font-weight: bold; }
  </style>
</head>
<body>
  <h1>タスク一覧(Read/Create)</h1>

  <?php if ($errorMessage !== ''): ?>
    <p class="error"><?= h($errorMessage) ?></p>
  <?php endif; ?>

  <form method="post" action="/index.php">
    <input type="hidden" name="action" value="create">
    <input type="text" name="title" placeholder="やることを入力" required>
    <button type="submit">作成</button>
  </form>

  <table>
    <thead>
      <tr>
        <th>ID</th>
        <th>タイトル</th>
        <th>状態</th>
        <th>作成日時</th>
      </tr>
    </thead>
    <tbody>
      <?php if ($tasks === []): ?>
        <tr>
          <td colspan="4">タスクはまだありません。</td>
        </tr>
      <?php else: ?>
        <?php foreach ($tasks as $task): ?>
          <tr>
            <td><?= h((string) $task['id']) ?></td>
            <td><?= h((string) $task['title']) ?></td>
            <td><?= $task['is_done'] === 't' ? '完了' : '未完了' ?></td>
            <td><?= h((string) $task['created_at']) ?></td>
          </tr>
        <?php endforeach; ?>
      <?php endif; ?>
    </tbody>
  </table>
</body>
</html>

このサンプルでは getenv(...) ?: '...' のフォールバック値を入れているため、.env が無い場合でも動く可能性があります。
ただし本来は .env の値を正として運用する前提なので、接続情報は .env 側で管理してください。

確認手順:

  1. ブラウザで http://localhost:8080/index.php を開く
  2. タスク名を入力して 作成 を押す
  3. 一覧に反映されることを確認する
  4. psql でも同じ内容が見えることを確認する
docker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT id, title, is_done, created_at FROM tasks ORDER BY id DESC;"'

コードのポイント

① DSN と PDO エラーモード

sprintf でホスト・ポート・DB名を組み立て、.env 側の変更だけで接続先を切り替えられる構成にしています。PDO::ERRMODE_EXCEPTION により SQL エラーが例外として投げられ、catch (Throwable $e) で一括処理できます。

$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $dbName);
$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];

② POST 処理と PRG(Post/Redirect/Get)

POST 成功後に Location ヘッダでリダイレクトします。ブラウザのリロード時に同じ POST が再送されるのを防ぐ定石パターンです。header() の後は必ず exit して処理を止めてください。

header('Location: /index.php');
exit;

fetchAll() で全件取得

接続時に FETCH_ASSOC を設定済みのため、fetchAll() は連想配列の配列を返します。id DESC で新しい順に並べています。

$tasks = $pdo->query('SELECT id, title, is_done, created_at FROM tasks ORDER BY id DESC')->fetchAll();

5. 更新と削除を追加してCRUDを完成させる

4章からの変更点:

  1. action=update のPOST処理を追加
  2. action=delete のPOST処理を追加
  3. 一覧の各行に更新フォームと削除フォームを追加

public/index.php を次の最終版へ更新します(全文掲載)。

<?php
declare(strict_types=1);

function h(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}

$host = getenv('APP_DB_HOST') ?: 'db';
$port = getenv('APP_DB_PORT') ?: '5432';
$dbName = getenv('APP_DB_NAME') ?: 'app';
$user = getenv('APP_DB_USER') ?: 'app';
$pass = getenv('APP_DB_PASS') ?: 'app';

$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $dbName);
$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];

$errorMessage = '';
$tasks = [];

try {
    $pdo = new PDO($dsn, $user, $pass, $options);

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $action = (string) ($_POST['action'] ?? '');

        if ($action === 'create') {
            $title = trim((string) ($_POST['title'] ?? ''));

            if ($title === '') {
                $errorMessage = 'タスク名は必須です。';
            } else {
                $stmt = $pdo->prepare('INSERT INTO tasks (title) VALUES (:title)');
                $stmt->execute([':title' => $title]);

                header('Location: /index.php');
                exit;
            }
        } elseif ($action === 'update') {
            // 更新処理
            $id = filter_var($_POST['id'] ?? null, FILTER_VALIDATE_INT);
            $title = trim((string) ($_POST['title'] ?? ''));
            $isDone = isset($_POST['is_done']) ? 1 : 0;

            if ($id === false || $id === null) {
                $errorMessage = '更新対象のIDが不正です。';
            } elseif ($title === '') {
                $errorMessage = 'タスク名は必須です。';
            } else {
                $stmt = $pdo->prepare(
                    'UPDATE tasks SET title = :title, is_done = :is_done WHERE id = :id'
                );
                $stmt->execute([
                    ':title' => $title,
                    // BOOLEAN へ 1/0 を明示して渡す
                    ':is_done' => $isDone,
                    ':id' => $id,
                ]);

                header('Location: /index.php');
                exit;
            }
        } elseif ($action === 'delete') {
            // 削除処理
            $id = filter_var($_POST['id'] ?? null, FILTER_VALIDATE_INT);

            if ($id === false || $id === null) {
                $errorMessage = '削除対象のIDが不正です。';
            } else {
                $stmt = $pdo->prepare('DELETE FROM tasks WHERE id = :id');
                $stmt->execute([':id' => $id]);

                header('Location: /index.php');
                exit;
            }
        }
    }

    $tasks = $pdo->query('SELECT id, title, is_done, created_at FROM tasks ORDER BY id DESC')->fetchAll();
} catch (Throwable $e) {
    $errorMessage = 'DB接続またはSQL実行でエラーが発生しました: ' . $e->getMessage();
}
?>
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tasks CRUD</title>
  <style>
    body { font-family: sans-serif; margin: 24px; }
    form { margin-bottom: 12px; }
    input[type="text"] { min-width: 220px; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: top; }
    .inline-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0; }
    .error { color: #b00020; font-weight: bold; }
  </style>
</head>
<body>
  <h1>タスク一覧(CRUD)</h1>

  <?php if ($errorMessage !== ''): ?>
    <p class="error"><?= h($errorMessage) ?></p>
  <?php endif; ?>

  <form method="post" action="/index.php">
    <input type="hidden" name="action" value="create">
    <input type="text" name="title" placeholder="やることを入力" required>
    <button type="submit">作成</button>
  </form>

  <table>
    <thead>
      <tr>
        <th>ID</th>
        <th>タイトル / 状態(更新)</th>
        <th>作成日時</th>
        <th>削除</th>
      </tr>
    </thead>
    <tbody>
      <?php if ($tasks === []): ?>
        <tr>
          <td colspan="4">タスクはまだありません。</td>
        </tr>
      <?php else: ?>
        <?php foreach ($tasks as $task): ?>
          <tr>
            <td><?= h((string) $task['id']) ?></td>
            <td>
              <!-- 各行を更新できるフォーム -->
              <form method="post" action="/index.php" class="inline-form">
                <input type="hidden" name="action" value="update">
                <input type="hidden" name="id" value="<?= h((string) $task['id']) ?>">
                <input type="text" name="title" value="<?= h((string) $task['title']) ?>" required>
                <label>
                  <input
                    type="checkbox"
                    name="is_done"
                    value="1"
                    <?= $task['is_done'] === 't' ? 'checked' : '' ?>
                  >
                  完了
                </label>
                <button type="submit">更新</button>
              </form>
            </td>
            <td><?= h((string) $task['created_at']) ?></td>
            <td>
              <!-- 各行を削除できるフォーム -->
              <form method="post" action="/index.php" onsubmit="return confirm('削除しますか?');">
                <input type="hidden" name="action" value="delete">
                <input type="hidden" name="id" value="<?= h((string) $task['id']) ?>">
                <button type="submit">削除</button>
              </form>
            </td>
          </tr>
        <?php endforeach; ?>
      <?php endif; ?>
    </tbody>
  </table>
</body>
</html>

is_doneisset($_POST['is_done']) ? 1 : 0 で明示的に 1/0 へ変換してからSQLに渡しています。
学習段階では「フォーム値 -> SQL値」の変換を明示しておくと挙動を追いやすくなります。

動作確認手順:

  1. 1件作成する
  2. 同じ行でタイトルを変更し、必要なら完了チェックを付けて 更新
  3. 削除 を押して一覧から消えることを確認
  4. psql で結果が一致することを確認
docker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT id, title, is_done, created_at FROM tasks ORDER BY id DESC;"'

コードのポイント

① ID の整数バリデーション

FILTER_VALIDATE_INT は整数でない値(文字列や null)を false または null に変換します。後続の === false || === null チェックと組み合わせて、不正な ID が SQL に渡るのを防ぎます。

$id = filter_var($_POST['id'] ?? null, FILTER_VALIDATE_INT);

② チェックボックスの BOOLEAN 変換

チェックボックスは未チェック時に POST データに含まれません。isset() で有無を判定し、明示的に 1/0 へ変換してから PDO に渡すことで、PostgreSQL の BOOLEAN 型に確実にマップできます。

$isDone = isset($_POST['is_done']) ? 1 : 0;

③ UPDATE の名前付きプレースホルダ

:is_done に変換済みの $isDone(1/0)を渡しています。WHERE 句に :id を含めることで、操作対象の行を特定します。

$stmt = $pdo->prepare(
    'UPDATE tasks SET title = :title, is_done = :is_done WHERE id = :id'
);
$stmt->execute([
    ':title' => $title,
    // BOOLEAN へ 1/0 を明示して渡す
    ':is_done' => $isDone,
    ':id' => $id,
]);

6. 壊れたときに戻せる初期化手順を用意する

検証中に状態が崩れたら、次の手順で初期状態に戻します。

docker compose down -v
docker compose up -d --build
docker compose ps
docker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\dt"'

ポイント:

  • down -v は DB ボリュームも削除します
  • init.sql の反映確認を必ず行ってから 4章/5章の検証を再開します

7. よくある詰まりを症状別に切り分ける

症状まず確認することよくある原因対処該当章
could not find driverdocker compose exec app php -m を実行して pgsql / pdo_pgsql の有無を確認pdo_pgsql 未導入 / イメージ未再ビルドdocker/php/Dockerfile を確認して docker compose up -d --build2章
SQLSTATE[08006] connection refuseddocker compose psdocker compose logs dbdb 未起動 / ホスト名やポート不一致.envAPP_DB_HOST=dbAPP_DB_PORT=5432 を確認し再起動2章, 4章
password authentication faileddocker compose exec db sh -c 'echo $POSTGRES_USER && echo $POSTGRES_DB'.env の値とDB初期化時の値が不一致.env を見直し、必要なら 6章の down -v で再作成2章, 6章
relation "tasks" does not existdocker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\dt"'init.sql 未反映 / 旧ボリュームが残っている3章の init.sql を確認し、6章の初期化を実行3章, 6章

切り分けに使う最小コマンド:

docker compose ps
docker compose logs app
docker compose logs db
docker compose exec app php -m | grep -i pgsql
docker compose exec db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "\dt"'

8. まとめと次の一歩

本記事で、次の状態を再現しました。

  • 生PHP(PDO)で tasks のCRUDを1画面で動かせる
  • 画面確認と psql 確認をセットで回せる
  • 詰まったときに docker compose down -v で復旧できる

次の一歩:

  1. VS Code + Xdebugでステップ実行入門(WSL2 + Docker) でCRUD処理のデバッグを実践する
  2. VS CodeでPHPコード整形をそろえる(EditorConfig + PHP CS Fixer最小導入) でコードスタイルを固定する
  3. CRUDの次段として、PHPUnitでユニットテストを追加する(別記事で扱う予定)