VS Codeで始めるPHP開発環境 と同じく、WSL2 + Docker と Remote - 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:
app は depends_on: condition: service_healthy で db のヘルスチェック成功後に起動します。
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 を追加して管理対象外にしてください。
確認ポイント:
appとdbがUpになっている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 側で管理してください。
確認手順:
- ブラウザで
http://localhost:8080/index.phpを開く - タスク名を入力して
作成を押す - 一覧に反映されることを確認する
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章からの変更点:
action=updateのPOST処理を追加action=deleteのPOST処理を追加- 一覧の各行に更新フォームと削除フォームを追加
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_done は isset($_POST['is_done']) ? 1 : 0 で明示的に 1/0 へ変換してからSQLに渡しています。
学習段階では「フォーム値 -> SQL値」の変換を明示しておくと挙動を追いやすくなります。
動作確認手順:
- 1件作成する
- 同じ行でタイトルを変更し、必要なら完了チェックを付けて
更新 削除を押して一覧から消えることを確認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 driver | docker compose exec app php -m を実行して pgsql / pdo_pgsql の有無を確認 | pdo_pgsql 未導入 / イメージ未再ビルド | docker/php/Dockerfile を確認して docker compose up -d --build | 2章 |
SQLSTATE[08006] connection refused | docker compose ps と docker compose logs db | db 未起動 / ホスト名やポート不一致 | .env の APP_DB_HOST=db と APP_DB_PORT=5432 を確認し再起動 | 2章, 4章 |
password authentication failed | docker compose exec db sh -c 'echo $POSTGRES_USER && echo $POSTGRES_DB' | .env の値とDB初期化時の値が不一致 | .env を見直し、必要なら 6章の down -v で再作成 | 2章, 6章 |
relation "tasks" does not exist | docker 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で復旧できる
次の一歩:
- VS Code + Xdebugでステップ実行入門(WSL2 + Docker) でCRUD処理のデバッグを実践する
- VS CodeでPHPコード整形をそろえる(EditorConfig + PHP CS Fixer最小導入) でコードスタイルを固定する
- CRUDの次段として、PHPUnitでユニットテストを追加する(別記事で扱う予定)