RRelayer
Home/Tutorial

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.db

Create 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'))
);
SQL

Setting 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: register add in the page factory and capture $errors by 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. AddTodoForm registers add;

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 id via 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 $_POST directly — pass $form to handlers and

safeParse($form) to validation.

segments [id] and layouts.

Last updated: 2026-05-24
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