Todo アプリを作る#
ルーティング・Database・ サーバーアクション・バリデーション を束ねて、小さな Todo アプリを作ります。完成形は /todos の 1 ページで、 一覧・追加(検証つき)・完了トグル・削除を行います。追加・更新・削除はすべて Post/Redirect/Get(PRG)です。
UI は PSX コンポーネントに分割します。ページは DI・サーバーアクション・組み立てだけを担い、表示は AddTodoForm / TodoList / Todo に分けます。データ取得も TodoList コンポーネントの中で行います——コンポーネントはプレーンなクロージャで DI 注入が使えないので、Relayer::container() で Database を取得します(サービスと DI 参照)。
前提は はじめに を済ませていること(PHP 8.5・ vendor/bin/relayer init 済み)。DB は Relayer 標準の Database を SQLite で使います(追加依存ゼロ)。
1. テーブルを用意#
SQLite を使います。Database の DSN は 絶対パスが必要です(相対パスはプロセスの cwd 基準で解決され、ハマりやすい)。.env に DSN を足します。
# 環境に合わせて絶対パスに読み替えてください
DATABASE_DSN=sqlite:/srv/app/var/app.dbテーブルを作ります(プロジェクト直下で実行)。
sqlite3 var/app.db <<'SQL'
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
SQLDATABASE_DSN を設定すると Database が DI コンテナに自動登録され、ページ/コンポーネントが型で受け取れます(詳細は Database)。
2. 構成#
src/
Pages/todos/page.psx ルート /todos:コンポーネントの組み立てだけ
Components/
AddTodoForm.psx 追加フォーム:add を自己登録(検証エラー表示つき)
TodoList.psx 一覧:データ取得+toggle/remove を自己登録
Todo.psx 1 件分の行(完了トグル/削除)サーバーアクションは各コンポーネントが自己登録します(Action::create())。 AddTodoForm が add、TodoList が toggle / remove を登録し、Database や PageContext も Relayer::container() / PageContext::current() で各自が取得します。その結果、ページはアクションも DI も持たず、組み立てだけになります(サーバーアクション / サービスと DI)。
3. ページ(page.psx)#
src/Pages/todos/page.psx は、アクションも DI も持たず、ただ AddTodoForm と TodoList を組み立てるだけです。
<?php
declare(strict_types=1);
// @psx-runtime App\Components\AddTodoForm
// @psx-runtime App\Components\TodoList
use App\Components\AddTodoForm;
use App\Components\TodoList;
use Polidog\UsePhp\Html\H;
return function (): Closure {
return function () {
return (
<section>
<h1>Todo</h1>
<AddTodoForm />
<TodoList />
</section>
);
};
};ポイント:
- 関数スタイルのページで JSX を返すので
use Polidog\UsePhp\Html\H;が必要
(JSX は H:: 呼び出しにコンパイルされる)。
- ページから参照する PSX コンポーネントは
use App\Components\...;で取り込み、
かつ // @psx-runtime App\Components\... を添えます(前者で JSX タグ→FQCN 解決、後者でランタイム登録の宣言)。
- 二段(ファクトリが描画クロージャを返す)形にします。サブコンポーネント
自己登録アクションは「事前レンダリング → ディスパッチ → 再レンダリング」で処理されるため、描画のたびにコンポーネントが再実行される二段形が必要です(サーバーアクション)。
4. 追加フォーム(AddTodoForm.psx)#
add アクションをこのコンポーネントが自己登録します。Database は Relayer::container()、PageContext は PageContext::current() から取得し、 Action::create() でハンドラを登録します。
検証失敗時はエラー付きで再描画しますが、サブコンポーネント自己登録のアクションは二段レンダリング(事前レンダリング → ディスパッチ → 再レンダリング)で処理されます。各レンダリングでコンポーネントのクロージャは再実行されるので、エラーを普通のローカル変数に置くと再描画で消えます。そこで static に保持します——Relayer の既定はリクエストごとに別プロセス(classic mode)なので、static はリクエスト境界でリセットされます。
<?php
declare(strict_types=1);
namespace App\Components;
use Polidog\Relayer\Db\Database;
use Polidog\Relayer\Relayer;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\Relayer\Router\Form\Action;
use Polidog\Relayer\Validation\Validator;
use Polidog\UsePhp\Html\H;
use function Polidog\UsePhp\Runtime\fc;
return fc(function (array $props) {
// 二段レンダリングをまたいで検証エラーを保持する(classic mode では
// リクエストごとに別プロセスなのでリクエスト境界でリセットされる)。
static $errors = [];
$ctx = PageContext::current();
$db = Relayer::container()->get(Database::class);
$add = Action::create('add', function (array $form) use ($ctx, $db, &$errors): void {
$result = Validator::object([
'title' => Validator::string()->trim()->min(1),
])->safeParse($form);
if (!$result->success) {
$errors = $result->errors; // 再レンダリングで表示される
return;
}
$db->insert('todos', ['title' => $result->data['title']]);
$ctx->redirect('/todos'); // 成功は PRG(再レンダリングなし)
});
return (
<form action={$add} method="post">
<input name="title" placeholder="新しいタスク" />
<button>追加</button>
{isset($errors['title'])
? (<p className="error">{$errors['title']}</p>)
: null}
</form>
);
});永続ワーカー(プロセスがリクエストをまたいで生き続ける構成)で動かすなら、
staticは使わず、addをページのファクトリで登録して$errorsを参照キャプチャ(use (…, &$errors))する形が安全です——ファクトリは二段レンダリングの間 1 度しか走らないので、その変数が状態の置き場所になります(サーバーアクション)。
5. 一覧とデータ取得(TodoList.psx)#
データ取得に加えて、行ごとの toggle / remove アクションをここで自己登録します。コンポーネントはクロージャなので、Database は Relayer::container() から、現在の PageContext は PageContext::current() から取得し、Action::create() でアクションを登録してトークンを各 Todo へ渡します。アクションは行ごとに登録すると名前が衝突するので、ここで 1 度ずつ登録します。
<?php
declare(strict_types=1);
namespace App\Components;
use Polidog\Relayer\Db\Database;
use Polidog\Relayer\Relayer;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\Relayer\Router\Form\Action;
use Polidog\UsePhp\Html\H;
use function Polidog\UsePhp\Runtime\fc;
return fc(function (array $props) {
$db = Relayer::container()->get(Database::class);
$ctx = PageContext::current();
// 完了トグル: 隠しフィールドの id を読み、done を反転
$toggle = Action::create('toggle', function (array $form) use ($db, $ctx): void {
$id = (int) ($form['id'] ?? 0);
$todo = $db->fetchOne('SELECT done FROM todos WHERE id = :id', ['id' => $id])
?? $ctx->notFound();
$db->update('todos', ['done' => $todo['done'] ? 0 : 1], ['id' => $id]);
$ctx->redirect('/todos');
});
// 削除
$remove = Action::create('remove', function (array $form) use ($db, $ctx): void {
$db->delete('todos', ['id' => (int) ($form['id'] ?? 0)]);
$ctx->redirect('/todos');
});
$todos = $db->fetchAll('SELECT id, title, done FROM todos ORDER BY done, id DESC');
return [] === $todos
? (<p>タスクはありません。</p>)
: (
<ul>
{array_map(fn ($t) => (
<Todo todo={$t} toggle={$toggle} remove={$remove} />
), $todos)}
</ul>
);
});Relayer::container()(サービス)と PageContext::current()(現在のページ)はどちらもコンポーネント用の取得口です。Action::create(name, handler) は PageContext::current()->action() に委譲し、フォームに埋め込むトークンを返します。toggle / remove は成功時に redirect()(PRG)するので、サブコンポーネント登録時の二段レンダリングとも素直にかみ合います(仕組みは サーバーアクション)。同じ App\Components 名前空間にあるので <Todo> は use 文なしで解決されます(名前空間 → FQCN)。 {array_map(...)} は <ul> の唯一の子にします(usePHP の規約。兄弟要素と混ぜない)。
6. 1 件の行(Todo.psx)#
1 つの Todo を受け取り、完了トグルと削除のフォームを描画します。
<?php
declare(strict_types=1);
namespace App\Components;
use Polidog\UsePhp\Html\H;
use function Polidog\UsePhp\Runtime\fc;
return fc(function (array $props) {
$todo = $props['todo'];
$toggle = $props['toggle'];
$remove = $props['remove'];
return (
<li>
<form action={$toggle} method="post">
<input type="hidden" name="id" value={(string) $todo['id']} />
<button>{$todo['done'] ? '↺' : '✓'}</button>
</form>
<span className={$todo['done'] ? 'done' : ''}>{$todo['title']}</span>
<form action={$remove} method="post">
<input type="hidden" name="id" value={(string) $todo['id']} />
<button>削除</button>
</form>
</li>
);
});value 属性は文字列のみ受け付けるので、id(整数)は (string) にキャストします。
これで完成です。php -S 127.0.0.1:8000 -t public を起動して <http://127.0.0.1:8000/todos> を開くと、追加・完了トグル・削除が動きます。
ここで効いている規約#
- アクションは各コンポーネントが自己登録。
addはAddTodoForm、
toggle / remove は TodoList が Action::create() で登録します。ページはアクションを持ちません(サーバーアクション)。
- 二段レンダリングと状態。サブコンポーネント自己登録アクションは
「事前レンダリング → ディスパッチ → 再レンダリング」で処理されます。 add の検証エラーはこの 2 回のレンダリングをまたぐので static で保持します(classic mode =リクエストごとに別プロセスなのでリクエスト境界でリセット。永続ワーカーなら add をページのファクトリ登録+&$errors 参照キャプチャにするのが安全)。toggle / remove は成功時に redirect() するので状態共有は不要です。
- サービス/コンテキストの取得。コンポーネントはクロージャでコンストラクタ
注入が使えないので、Database は Relayer::container()、現在の PageContext は PageContext::current() から取ります(サービスと DI)。共有 PSX コンポーネントは fc(function (array $props) { … }) で書き、 src/Components/ に置けば自動でコンパイル・登録されます。
- CSRF はサーバーアクションが自動付与。
$ctx->action()でも
Action::create() でも、返るトークンを <form action={...}> に置くだけ。
- 行ごとの操作(トグル/削除)は、対象 1 件分の
idを隠しフィールドで
POST します。toggle / remove は TodoList で 1 度ずつ登録し(行ごとに登録すると名前が衝突する)、成功時に redirect() で PRG——リロードによる二重 POST を防ぎます。
- 無い行への操作は
$ctx->notFound()で表明します
(ルーティングとページ の 404)。
- 既定のエラーメッセージはローカライズされます。空入力は解決ロケールに
応じて「入力してください。」のように表示されます。独自メッセージは制約メソッドの第 2 引数で渡せます(バリデーション / 多言語化)。
$_POSTを直接読まず、ハンドラには$form、検証にはsafeParse($form)
を通します。
次に読む#
- ルーティングとページ — 動的セグメント
[id]や
レイアウトでアプリを広げる。
変更履歴 (6)
- チュートリアルから Tehilim 連携セクションを削除(/docs/tehilim 専用ページは存続)
- add も AddTodoForm の Action::create 自己登録へ(static でエラー保持)。ページはアクション/DI なしの組み立てのみに
- toggle/remove を TodoList の Action::create 自己登録へ移動(add はページ登録のまま)
- v0.24.0: TodoList を Relayer::container() に変更し db prop を廃止
- Split tutorial into PSX components (Todo/TodoList/AddTodoForm), remove repository section; fix &$errors by-ref, H import, (string) id cast
- Add Todo app tutorial (new チュートリアル category)