Build a Todo app#
This tutorial ties routing, Database, server actions, and validation together into a small Todo app. The finished app is a single /todos page that lists, adds (with validation), toggles done, and deletes todos. Every add/update/delete is a Post/Redirect/Get (PRG).
The UI is split into PSX components. The page only does DI, server actions, and composition; rendering is split across AddTodoForm / TodoList / Todo. Data fetching happens inside the TodoList component — since components are plain closures with no DI injection, it pulls Database from Relayer::container() (see services & DI).
It assumes you have done getting started (PHP 8.5, vendor/bin/relayer init). The data layer is Relayer's built-in Database over SQLite (zero extra dependencies).
1. Create the table#
We use SQLite. Database's DSN must be an absolute path (a relative path is resolved against the process's cwd, a common pitfall). Add the DSN to .env:
# adjust to an absolute path for your environment
DATABASE_DSN=sqlite:/srv/app/var/app.dbCreate the table (run from the project root):
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'))
);
SQLSetting DATABASE_DSN auto-registers Database in the DI container, so a page or component can receive it by type (see Database).
2. Structure#
src/
Pages/todos/page.psx route /todos: just composition
Components/
AddTodoForm.psx add form: self-registers add (with validation error)
TodoList.psx the list: fetch + self-register toggle/remove
Todo.psx one row (toggle done / delete)Each component self-registers its server action (Action::create()): AddTodoForm registers add, TodoList registers toggle / remove, and each pulls Database / PageContext from Relayer::container() / PageContext::current(). As a result, the page holds no actions and no DI — just composition (see server actions / services & DI).
3. The page (page.psx)#
src/Pages/todos/page.psx holds no actions and no DI — it just composes AddTodoForm and 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>
);
};
};Notes:
- A function-style page that returns JSX needs
use Polidog\UsePhp\Html\H;
(JSX compiles to H:: calls).
- A PSX component referenced from the page is imported with
use App\Components\…; and declared with // @psx-runtime App\Components\… (the first resolves the JSX tag to an FQCN; the second declares it for runtime registration).
- Use the two-level form (the factory returns a render closure).
Sub-component self-registered actions are handled as "pre-render → dispatch → re-render", so the components must re-run on each render — which the two-level form does (server actions).
4. The add form (AddTodoForm.psx)#
This component self-registers the add action. It pulls Database from Relayer::container() and PageContext from PageContext::current(), then registers the handler with Action::create().
On a validation failure it re-renders with the error, but a sub-component self-registered action is handled as a double render (pre-render → dispatch → re-render). The component closure re-runs on each render, so a plain local would lose the error — keep it in a static. Relayer's default is one process per request (classic mode), so the static resets at the request boundary.
<?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) {
// Hold the validation error across the double render (pre-render →
// dispatch → re-render). Under classic mode (one process per request)
// this static resets at the request boundary.
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; // shown on the re-render
return;
}
$db->insert('todos', ['title' => $result->data['title']]);
$ctx->redirect('/todos'); // success is PRG (no re-render)
});
return (
<form action={$add} method="post">
<input name="title" placeholder="New task" />
<button>Add</button>
{isset($errors['title'])
? (<p className="error">{$errors['title']}</p>)
: null}
</form>
);
});If you run a persistent worker (the process lives across requests), avoid the
static: registeraddin the page factory and capture$errorsby reference (use (…, &$errors)) — the factory runs once across the double render, so its variable is the place to hold the state (server actions).
5. The list and data fetching (TodoList.psx)#
Beyond fetching, this component self-registers the per-row toggle / remove actions. Since components are closures, it pulls Database from Relayer::container() and the current PageContext from PageContext::current(), registers the actions with Action::create(), and passes the tokens to each Todo. Registering per row would collide on the action name, so register them once here.
<?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();
// Toggle done: read the hidden id field, flip 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');
});
// Delete
$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>No tasks yet.</p>)
: (
<ul>
{array_map(fn ($t) => (
<Todo todo={$t} toggle={$toggle} remove={$remove} />
), $todos)}
</ul>
);
});Relayer::container() (services) and PageContext::current() (the current page) are both component-side accessors. Action::create(name, handler) delegates to PageContext::current()->action() and returns the token to embed in the form. Since toggle / remove redirect() (PRG) on success, they fit the double-render that sub-component-registered actions trigger (see server actions). Because it is in the same App\Components namespace, <Todo> resolves without a use statement (namespace → FQCN). The {array_map(...)} is the sole child of <ul> (a usePHP rule — don't mix it with sibling elements).
6. One row (Todo.psx)#
Takes a single todo and renders its toggle and delete forms.
<?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>Delete</button>
</form>
</li>
);
});The value attribute only accepts strings, so cast id (an integer) with (string).
That's the whole app. Start php -S 127.0.0.1:8000 -t public and open <http://127.0.0.1:8000/todos> — adding, toggling, and deleting all work.
What the conventions are doing here#
- Each component self-registers its action.
AddTodoFormregistersadd;
TodoList registers toggle / remove — both with Action::create(). The page has no actions (server actions).
- Double render and state. Sub-component self-registered actions are
handled as "pre-render → dispatch → re-render". add's validation error spans those two renders, so it is held in a static (classic mode = one process per request, so it resets at the request boundary; for a persistent worker, prefer registering add in the page factory with a &$errors reference). toggle / remove redirect() on success, so they share no state.
- Getting services / context. Components are closures with no constructor
injection, so Database comes from Relayer::container() and the current PageContext from PageContext::current() (services & DI). Shared PSX components are written as fc(function (array $props) { … }); put them in src/Components/ and they are compiled and registered automatically.
- CSRF is automatic with server actions. Whether
$ctx->action()or
Action::create(), embed the returned token in <form action={...}>.
- Per-row actions (toggle / delete) POST the one row's
idvia a hidden
field. toggle / remove are registered once in TodoList (per-row would collide on the name) and redirect() for PRG on success — preventing a duplicate POST on reload.
- Acting on a missing row declares "not found" with
$ctx->notFound()
(the 404 in routing & pages).
- Default error messages are localized. An empty input shows something
like "入力してください。" depending on the resolved locale; pass a custom message as a constraint method's second argument (validation / i18n).
- Don't read
$_POSTdirectly — pass$formto handlers and
safeParse($form) to validation.
Read next#
- Routing & pages — grow the app with dynamic
segments [id] and layouts.
- Server actions — choosing between redirect and abort.
- Validation — types, constraints, per-field errors.
- Authentication — for per-user todos.
Change history (6)
- Remove Tehilim variant section from the tutorial
- Self-register add in AddTodoForm (static errors); page is composition-only
- Move toggle/remove to TodoList self-registration via Action::create (add stays page-registered)
- v0.24.0: TodoList uses Relayer::container(), drop db prop
- Component-split rewrite (en)
- Add English Todo app tutorial