Building a Todo Application

An assembled-app recipe: build a working todo app incrementally, starting from a tiny Kernel and adding one capability at a time.

Introduction

The Forge Kernel is a tiny PHP kernel you use to build your app — it is not a framework you have to learn all at once. This tutorial follows that idea literally. We start with a plain todo app that works with normal HTML forms and page reloads. Then, one step at a time, we add a capability and fork the app a little further. This is what it means to assemble an app: take the kernel, plug in a few capabilities, and your app starts to look like a framework only once you've assembled it.

At the end, the same todo app will have user accounts, real-time updates, background events, and tests — but you'll understand every piece, because you added it one at a time.

What You'll Build, Step by Step

  • Step 1 — a plain todo app: a migration, a model, a controller, and a view, saved to a database with normal forms and redirects.
  • Step 2 — user accounts with ForgeAuth, so each user only sees their own todos.
  • Step 3 — real-time updates with ForgeWire, no full page reloads.
  • Step 4 — background events with ForgeEvents.
  • Step 5 — automated tests with ForgeTesting.

Capabilities We'll Use

Each step plugs in a capability. Here's the full set — you'll install them one per step, so you only bring in what you actually need:

  • ForgeRouter: HTTP routes, controllers, middleware, and responses (Step 1).
  • ForgeView: views, layouts, and escaping (Step 1).
  • ForgeDatabaseSQL + ForgeSqlOrm: migrations and models (Step 1).
  • ForgeAuth + ForgeAppAuth: user accounts and the complete auth flow (Step 2).
  • ForgeWire: real-time interactivity (Step 3).
  • ForgeEvents: background event processing (Step 4).
  • ForgeTesting: automated tests (Step 5).

Prerequisites

  • PHP 8.3 or higher
  • Forge Kernel installed
  • Basic PHP and object-oriented programming

Note: This tutorial assumes you've installed Forge Kernel. If not, follow the Getting Started guide first.

Project Setup

We start with a working Forge Kernel project and a web blueprint. Then we install only the capabilities Step 1 needs. Everything else (accounts, reactivity, events, tests) gets added later, one step at a time.

Create a Kernel Project

Install the Kernel and a web blueprint for your app. The blueprint gives you the structure and a working forge.php CLI:

# Install the kernel + a starter web blueprint for a new project
php forge.php package:install-project --blueprint=web

This gives you a project with the Kernel, a public/ directory, and the CLI at forge.php. Verify it works:

# The Kernel version is available on the CLI
php forge.php --version

# Start the built-in server (ForgeRouter's `serve` command)
php forge.php serve

Point your browser at http://localhost:8000 to confirm the app responds.

Install Step 1 Capabilities

For the plain todo app we need routing, views, and a database. Install these four capabilities with ForgePackageManager:

# HTTP routing, controllers, and middleware
php forge.php package:install-module --module=forge-router

# Views and layouts
php forge.php package:install-module --module=forge-view

# Database migrations
php forge.php package:install-module --module=forge-database-sql

# Object-relational models
php forge.php package:install-module --module=forge-sql-orm

ForgeRouter registers the web middleware group and the other modules register what they need. You'll see their middlewares appear in your config/middleware.php file, which controls ordering. Modules register their own middlewares — this file just decides the order:

<?php

return [
    'global' => [
        \Modules\ForgeRouter\Http\Middlewares\ObservabilityMiddleware::class,
    ],
    'web' => [
        \Modules\ForgeRouter\Http\Middlewares\SessionMiddleware::class,
        \Modules\ForgeRouter\Http\Middlewares\CsrfMiddleware::class,
    ],
    'api' => [],
    'auth' => [],
];

Project Structure

By the end of Step 1, our app will have this structure. (Later steps add accounts, reactive updates, events, and tests.)

app/
├── Controllers/
│   └── TodoController.php
├── Models/
│   └── Todo.php
├── Database/
│   └── Migrations/
│       └── 2025_01_01_000000_CreateTodosTable.php
└── resources/
    └── views/
        ├── layouts/
        │   └── main.php
        └── todos/
            └── index.php

Environment Configuration

Ensure your .env file points at a database. SQLite is the default and needs no separate server:

APP_ENV=local
APP_DEBUG=true

DB_DRIVER=sqlite
DB_DATABASE=storage/database/database.sqlite

Step 1: A Plain Todo App

In this step we build a normal, working todo app with no accounts, no real-time updates and no JavaScript magic. It's just HTML forms that POST to controller methods, touch the database, and redirect back. This is the foundation everything else builds on.

Create the Migration

Generate a migration for the todos table. The Kernel provides a generate:migration command:

php forge.php generate:migration --table=todos --name=CreateTodosTable

ForgeDatabaseSQL describes schema with attributes on the migration class: a #[Table] attribute on the class and a #[Column] attribute per property. The generated file lives at app/Database/Migrations/:

<?php

declare(strict_types=1);

namespace App\Database\Migrations;

use Modules\ForgeDatabaseSQL\DB\Attributes\Column;
use Modules\ForgeDatabaseSQL\DB\Attributes\Index;
use Modules\ForgeDatabaseSQL\DB\Attributes\Table;
use Modules\ForgeDatabaseSQL\DB\Enums\ColumnType;
use Modules\ForgeDatabaseSQL\DB\Migrations\Migration;

#[Table(name: 'todos')]
#[Index(columns: ['title'], name: 'idx_todos_title')]
class CreateTodosTable extends Migration
{
    #[Column(name: 'id', type: ColumnType::INTEGER, primaryKey: true, autoIncrement: true)]
    public readonly int $id;

    #[Column(name: 'title', type: ColumnType::STRING)]
    public readonly string $title;

    #[Column(name: 'description', type: ColumnType::TEXT, nullable: true)]
    public readonly ?string $description;

    #[Column(name: 'completed', type: ColumnType::BOOLEAN, default: false)]
    public readonly bool $completed;

    #[Column(name: 'created_at', type: ColumnType::DATETIME, nullable: true)]
    public readonly ?string $createdAt;

    #[Column(name: 'updated_at', type: ColumnType::DATETIME, nullable: true)]
    public readonly ?string $updatedAt;
}

The base Migration class reads these attributes and generates the SQL for you, so there's no up()/down() boilerplate to write. Run the migration:

php forge.php db:migrate --type=app

Create the Model

ForgeSqlOrm maps rows to plain PHP objects. The model points at the table with a #[Table] attribute and maps each column with #[Column]. Create app/Models/Todo.php:

<?php

declare(strict_types=1);

namespace App\Models;

use Modules\ForgeSqlOrm\ORM\Attributes\Column;
use Modules\ForgeSqlOrm\ORM\Attributes\Table;
use Modules\ForgeSqlOrm\ORM\Model;
use Modules\ForgeSqlOrm\ORM\Values\Cast;
use Modules\ForgeSqlOrm\Traits\HasTimeStamps;

#[Table('todos')]
class Todo extends Model
{
    use HasTimeStamps;

    #[Column(primary: true, cast: Cast::INT)]
    public ?int $id = null;

    #[Column(cast: Cast::STRING)]
    public string $title;

    #[Column(cast: Cast::STRING)]
    public ?string $description = null;

    #[Column(cast: Cast::BOOL)]
    public bool $completed = false;
}

The model gives you save(), delete(), and a query builder via Todo::query() so you can find, order, and filter rows.

Create the Controller

ForgeRouter maps HTTP routes to controller methods with the #[Endpoint] attribute. Controllers are plain classes — there's no base class — and they pull in the ViewHelper trait to render views. Create app/Controllers/TodoController.php:

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Models\Todo;
use Forge\Core\Helpers\Flash;
use Modules\ForgeRouter\Attributes\Layout;
use Modules\ForgeRouter\Attributes\Routable;
use Modules\ForgeRouter\Helpers\Redirect;
use Modules\ForgeRouter\Http\Attributes\UseMiddleware;
use Modules\ForgeRouter\Http\Request;
use Modules\ForgeRouter\Http\Response;
use Modules\ForgeRouter\Routing\Endpoint;
use Modules\ForgeView\Traits\ViewHelper;

#[Routable]
#[UseMiddleware('web')]
final class TodoController
{
    use ViewHelper;

    #[Endpoint('/todos')]
    #[Layout('main')]
    public function index(Request $request): Response
    {
        $todos = Todo::query()->orderBy('id', 'DESC')->get();
        return $this->view('todos/index', ['todos' => $todos]);
    }

    #[Endpoint('/todos', 'POST')]
    public function store(Request $request): Response
    {
        $title = trim((string) $request->input('title'));

        if ($title === '') {
            Flash::set('error', 'Title is required');
            return Redirect::to('/todos');
        }

        $todo = new Todo();
        $todo->title = $title;
        $todo->description = $request->input('description');
        $todo->completed = false;
        $todo->save();

        Flash::set('success', 'Todo created');
        return Redirect::to('/todos');
    }

    #[Endpoint('/todos/{id}/toggle', 'POST')]
    public function toggle(int $id): Response
    {
        $todo = Todo::query()->id($id)->first();
        if ($todo) {
            $todo->completed = !$todo->completed;
            $todo->save();
        }
        return Redirect::to('/todos');
    }

    #[Endpoint('/todos/{id}', 'DELETE')]
    public function destroy(int $id): Response
    {
        Todo::query()->id($id)->first()?->delete();
        Flash::set('success', 'Todo deleted');
        return Redirect::to('/todos');
    }
}

A few things to notice:

  • #[Routable] marks the class as a controller.
  • #[Endpoint(path, method)] maps a route. Route parameters such as {id} are injected into the method by name.
  • #[UseMiddleware('web')] applies the session and CSRF middleware group to every route on this controller.
  • A method parameter named request receives the current Request$request->input('title') reads form data.

Create the Views

ForgeView renders PHP templates. The #[Layout('main')] attribute picks the layout, which receives the page content as $content. First, the layout at app/resources/views/layouts/main.php:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= e($layoutProps['title'] ?? 'Todos') ?></title>
    <link rel="stylesheet" href="/assets/css/app.css">
    <?= raw(csrf_meta()) ?>
</head>
<body class="bg-gray-50">
    <main class="max-w-4xl mx-auto px-4 py-8">
        <?php if (Flash::has('success')): ?>
            <div class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded mb-4">
                <?= e(Flash::get('success')) ?>
            </div>
        <?php endif; ?>

        <?php if (Flash::has('error')): ?>
            <div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-4">
                <?= e(Flash::get('error')) ?>
            </div>
        <?php endif; ?>

        <?= $content ?>
    </main>
</body>
</html>

Now the todos index view at app/resources/views/todos/index.php:

<h1 class="text-2xl font-bold mb-6">My Todos</h1>

<form method="POST" action="/todos" class="mb-6 flex gap-4">
    <?= raw(csrf_input()) ?>
    <input type="text" name="title" placeholder="Todo title..." required
        class="flex-1 px-4 py-2 border border-gray-300 rounded-md">
    <input type="text" name="description" placeholder="Description (optional)"
        class="flex-1 px-4 py-2 border border-gray-300 rounded-md">
    <button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-md">
        Add Todo
    </button>
</form>

<div class="space-y-3">
    <?php foreach ($todos as $todo): ?>
        <div class="flex items-center gap-4 p-4 border border-gray-200 rounded-md
            <?= $todo->completed ? 'bg-gray-50' : 'bg-white' ?>">
            <div class="flex-1">
                <h3 class="font-semibold <?= $todo->completed ? 'line-through text-gray-500' : 'text-gray-900' ?>">
                    <?= e($todo->title) ?>
                </h3>
                <?php if ($todo->description): ?>
                    <p class="text-sm text-gray-600 mt-1"><?= e($todo->description) ?></p>
                <?php endif; ?>
            </div>
            <form method="POST" action="/todos/<?= $todo->id ?>/toggle" class="inline">
                <?= raw(csrf_input()) ?>
                <button type="submit"
                        class="px-4 py-2 <?= $todo->completed ? 'bg-yellow-600' : 'bg-green-600' ?> text-white rounded-md">
                    <?= $todo->completed ? 'Undo' : 'Complete' ?>
                </button>
            </form>
            <form method="POST" action="/todos/<?= $todo->id ?>" class="inline">
                <?= raw(csrf_input()) ?>
                <input type="hidden" name="_method" value="DELETE">
                <button type="submit"
                        class="px-4 py-2 bg-red-600 text-white rounded-md"
                        onclick="return confirm('Are you sure?')">
                    Delete
                </button>
            </form>
        </div>
    <?php endforeach; ?>

    <?php if (empty($todos)): ?>
        <p class="text-gray-500 text-center py-8">No todos yet. Create your first one above!</p>
    <?php endif; ?>
</div>

Try It

Start the server and visit http://localhost:8000/todos. You can add, complete, and delete todos with plain browser navigation. Nothing is JavaScript-driven yet — and that's exactly the point. It works.

php forge.php serve

What we did: the Kernel let us assemble a routing capability, a view capability, and a data capability into a working app. We added no framework — we just plugged capabilities into the kernel.

Step 2: Add User Accounts

Our todo app works, but anyone can edit anyone's todos. Let's add user accounts so each user only sees their own items. ForgeAppAuth is the distributable auth capability: it ships the User model, the auth middleware, and the complete account flow with registration, login, logout, password reset, and recovery — all ready to wire into your app.

Install ForgeAppAuth

php forge.php package:install-module --module=forge-app-auth

ForgeAppAuth depends on the forge-auth service (already installed) plus the router, view, database, and ORM capabilities you already have, so one command is all it takes. It brings the users table migration (it runs db:migrate on install) and the User model at Modules\ForgeAppAuth\Models\User. It also ships the Modules\ForgeAppAuth\Middlewares\AuthMiddleware ; add it to the auth group in config/middleware.php, and protect routes with #[UseMiddleware(['web', 'auth'])]:

<?php

return [
    "web" => [
        \Modules\ForgeRouter\Http\Middlewares\SessionMiddleware::class,
        \Modules\ForgeRouter\Http\Middlewares\CsrfMiddleware::class,
    ],
    "auth" => [
        \Modules\ForgeAppAuth\Middlewares\AuthMiddleware::class,
    ],
];

Give Todos an Owner

Generate a migration that adds a user_id column to the todos table:

php forge.php generate:migration --table=todos --name=AddUserIdToTodosTable
php forge.php db:migrate --type=app
<?php

declare(strict_types=1);

namespace App\Database\Migrations;

use Modules\ForgeDatabaseSQL\DB\Attributes\AddColumn;
use Modules\ForgeDatabaseSQL\DB\Attributes\Table;
use Modules\ForgeDatabaseSQL\DB\Attributes\Relations\BelongsTo;
use Modules\ForgeDatabaseSQL\DB\Enums\ColumnType;
use Modules\ForgeDatabaseSQL\DB\Migrations\Migration;
use Modules\ForgeAppAuth\Models\User;

#[Table(name: 'todos')]
#[AddColumn(name: 'user_id', type: ColumnType::INTEGER, nullable: true)]
#[BelongsTo(related: User::class, foreignKey: 'user_id')]
class AddUserIdToTodosTable extends Migration
{
}

Update the Model

Add a user_id property to the Todo model so it maps the new column:

<?php

declare(strict_types=1);

namespace App\Models;

use Modules\ForgeSqlOrm\ORM\Attributes\Column;
use Modules\ForgeSqlOrm\ORM\Attributes\Table;
use Modules\ForgeSqlOrm\ORM\Model;
use Modules\ForgeSqlOrm\ORM\Values\Cast;
use Modules\ForgeSqlOrm\Traits\HasTimeStamps;

#[Table('todos')]
class Todo extends Model
{
    use HasTimeStamps;

    #[Column(primary: true, cast: Cast::INT)]
    public ?int $id = null;

    #[Column(cast: Cast::INT)]
    public ?int $user_id = null;

    #[Column(cast: Cast::STRING)]
    public string $title;

    #[Column(cast: Cast::STRING)]
    public ?string $description = null;

    #[Column(cast: Cast::BOOL)]
    public bool $completed = false;
}

Scope the Controller to the Current User

Protect the routes with the auth middleware group and scope every query by the logged-in user. The current user comes from Modules\ForgeAppAuth\Services\UserContext:

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Models\Todo;
use Forge\Core\Helpers\Flash;
use Modules\ForgeAppAuth\Models\User;
use Modules\ForgeAppAuth\Services\UserContext;
use Modules\ForgeRouter\Attributes\Layout;
use Modules\ForgeRouter\Attributes\Routable;
use Modules\ForgeRouter\Helpers\Redirect;
use Modules\ForgeRouter\Http\Attributes\UseMiddleware;
use Modules\ForgeRouter\Http\Request;
use Modules\ForgeRouter\Http\Response;
use Modules\ForgeRouter\Routing\Endpoint;
use Modules\ForgeView\Traits\ViewHelper;

#[Routable]
#[UseMiddleware(['web', 'auth'])]
final class TodoController
{
    use ViewHelper;

    public function __construct(
        private readonly UserContext $userContext,
    ) {}

    #[Endpoint('/todos')]
    #[Layout('main')]
    public function index(): Response
    {
        $userId = $this->currentUserId();
        $todos = Todo::query()->where('user_id', $userId)->orderBy('id', 'DESC')->get();

        return $this->view('todos/index', [
            'todos' => $todos,
            'user' => $this->userContext->current(),
        ]);
    }

    #[Endpoint('/todos', 'POST')]
    public function store(Request $request): Response
    {
        $title = trim((string) $request->input('title'));

        if ($title === '') {
            Flash::set('error', 'Title is required');
            return Redirect::to('/todos');
        }

        $todo = new Todo();
        $todo->user_id = $this->currentUserId();
        $todo->title = $title;
        $todo->description = $request->input('description');
        $todo->completed = false;
        $todo->save();

        Flash::set('success', 'Todo created');
        return Redirect::to('/todos');
    }

    #[Endpoint('/todos/{id}/toggle', 'POST')]
    public function toggle(int $id): Response
    {
        $todo = $this->findOwnedTodo($id);
        if ($todo) {
            $todo->completed = !$todo->completed;
            $todo->save();
        }
        return Redirect::to('/todos');
    }

    #[Endpoint('/todos/{id}', 'DELETE')]
    public function destroy(int $id): Response
    {
        $this->findOwnedTodo($id)?->delete();
        Flash::set('success', 'Todo deleted');
        return Redirect::to('/todos');
    }

    private function currentUserId(): int
    {
        return (int) $this->userContext->current()?->getId();
    }

    private function findOwnedTodo(int $id): ?Todo
    {
        return Todo::query()
            ->where('id', $id)
            ->where('user_id', $this->currentUserId())
            ->first();
    }
}

Note how the ownership check is now part of the query itself: only todos whose user_id matches the current user can ever be found.

The Complete Account Flow

ForgeAppAuth already provides the whole account flow via its AuthController under the /auth prefix, so you don't have to build any of it:

  • POST /auth/register — create an account
  • POST /auth/login — sign in
  • POST /auth/logout — sign out
  • /auth/forgot-password — request a reset link
  • /auth/reset-password — set a new password

Because our controller already passes the current user into the view, the layout's navigation can switch between the auth links and a user badge:

<nav class="bg-white shadow-sm border-b mb-8">
    <div class="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
        <a href="/todos" class="text-xl font-bold text-gray-900">Todo App</a>
        <div class="flex items-center space-x-4">
            <?php if ($user): ?>
                <span class="text-gray-600"><?= e($user->getEmail()) ?></span>
                <form method="POST" action="/auth/logout">
                    <?= raw(csrf_input()) ?>
                    <button class="text-blue-600 hover:text-blue-800">Logout</button>
                </form>
            <?php else: ?>
                <a href="/auth/login" class="text-blue-600 hover:text-blue-800">Login</a>
                <a href="/auth/register" class="text-blue-600 hover:text-blue-800">Register</a>
            <?php endif; ?>
        </div>
    </div>
</nav>

Try It

Register two accounts and create todos in each. Each user only sees their own todo list. Visiting /todos while logged out redirects you to the login page.

What we did: we added a single capability — accounts — on top of the plain app. The kernel resolved the current user from the session, and our queries simply scoped by it.

Step 3: Make It Reactive (ForgeWire)

The todo app works with normal page reloads. Let's make it reactive: actions happen instantly in the browser without a full refresh, while the logic stays safely on the server. ForgeWire wires your controller to the page.

Install ForgeWire

ForgeWire registers its middleware into the web group automatically — you don't touch the middleware config:

php forge.php package:install-module --module=forge-wire

Mark the Controller Reactive

Marking a controller #[Reactive] tells ForgeWire to intercept its actions. Add three things to the existing controller from Step 2:

  • #[Reactive] on the class — enables reactivity.
  • #[State] on properties — values preserved between updates.
  • #[Action] on methods — callable from the view.
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Models\Todo;
use Modules\ForgeAppAuth\Services\UserContext;
use Modules\ForgeRouter\Attributes\Layout;
use Modules\ForgeRouter\Attributes\Routable;
use Modules\ForgeRouter\Http\Attributes\UseMiddleware;
use Modules\ForgeRouter\Routing\Endpoint;
use Modules\ForgeView\Traits\ViewHelper;
use Modules\ForgeWire\Attributes\Action;
use Modules\ForgeWire\Attributes\Reactive;
use Modules\ForgeWire\Attributes\State;

#[Routable]
#[UseMiddleware(['web', 'auth'])]
#[Reactive]
final class TodoController
{
    use ViewHelper;

    #[State]
    public string $newTodoTitle = '';

    #[State]
    public string $newTodoDescription = '';

    public function __construct(
        private readonly UserContext $userContext,
    ) {}

    #[Endpoint('/todos')]
    #[Layout('main')]
    public function index(): Response
    {
        return $this->view('todos/index', [
            'todos' => $this->todos(),
        ]);
    }

    #[Action]
    public function addTodo(): void
    {
        $title = trim($this->newTodoTitle);
        if ($title === '') {
            return;
        }

        $todo = new Todo();
        $todo->user_id = $this->currentUserId();
        $todo->title = $title;
        $todo->description = $this->newTodoDescription ?: null;
        $todo->completed = false;
        $todo->save();

        $this->newTodoTitle = '';
        $this->newTodoDescription = '';
    }

    #[Action]
    public function toggleTodo(int $id): void
    {
        $todo = $this->findOwnedTodo($id);
        if ($todo) {
            $todo->completed = !$todo->completed;
            $todo->save();
        }
    }

    #[Action]
    public function deleteTodo(int $id): void
    {
        $this->findOwnedTodo($id)?->delete();
    }

    private function todos(): array
    {
        return Todo::query()
            ->where('user_id', $this->currentUserId())
            ->orderBy('id', 'DESC')
            ->get();
    }

    private function currentUserId(): int
    {
        return (int) $this->userContext->current()?->getId();
    }

    private function findOwnedTodo(int $id): ?Todo
    {
        return Todo::query()
            ->where('id', $id)
            ->where('user_id', $this->currentUserId())
            ->first();
    }
}

The same methods now return void instead of building a Response and redirecting — ForgeWire re-renders the view in place. No plain Response or flash/redirect needed for these actions.

Make the View Reactive

ForgeWire directives live right in the HTML. Wrap the app in a scope with scope('todo-app'), bind inputs with fw:model, and call actions with fw:click. The fw:target region is what gets re-rendered. Update app/resources/views/todos/index.php:

<h1 class="text-2xl font-bold mb-6">My Reactive Todos</h1>

<div <?= raw(scope('todo-app')) ?> class="bg-white rounded-lg shadow p-6">
    <div class="flex gap-4 mb-6">
        <input type="text" fw:model.defer="newTodoTitle"
            placeholder="What needs doing?"
            fw:keydown.enter="addTodo"
            class="flex-1 px-4 py-2 border border-gray-300 rounded-md">
        <input type="text" fw:model.defer="newTodoDescription"
            placeholder="Description (optional)"
            class="flex-1 px-4 py-2 border border-gray-300 rounded-md">
        <button fw:click="addTodo"
            class="px-6 py-2 bg-blue-600 text-white rounded-md">
            Add Instantly
        </button>
    </div>

    <div fw:target>
        <div class="space-y-3">
            <?php foreach ($todos as $todo): ?>
                <div class="flex items-center gap-4 p-4 border border-gray-200 rounded-md
                    <?= $todo->completed ? 'bg-gray-50' : 'bg-white' ?>">
                    <div class="flex-1">
                        <input type="checkbox" <?= $todo->completed ? 'checked' : '' ?>
                            fw:click="toggleTodo"
                            fw:param-id="<?= $todo->id ?>">
                        <span class="<?= $todo->completed ? 'line-through text-gray-500' : '' ?>">
                            <?= e($todo->title) ?>
                        </span>
                    </div>
                    <button fw:click="deleteTodo"
                            fw:param-id="<?= $todo->id ?>"
                            class="text-red-600">
                        &times;
                    </button>
                </div>
            <?php endforeach; ?>

            <?php if (empty($todos)): ?>
                <p class="text-gray-500 text-center py-8">No todos yet. Create your first one above!</p>
            <?php endif; ?>
        </div>
    </div>

    <div fw:loading class="text-blue-600 mt-4">Updating...</div>
</div>

The fw:param-id="<?= $todo->id ?>" passes the row's id into toggleTodo(int $id) and deleteTodo(int $id), matching the method parameter name.

Try It

Reload /todos and add a todo. It appears instantly with no page reload — yet the data is still saved on the server and scoped to the logged-in user.

What we did: we added a reactivity capability without rewriting our app. The controller keeps its data access, and the view keeps its markup — we just marked them as reactive.

Step 4: Add Events (ForgeEvents)

Now let's fire background work when todos are created or completed — say, to send a welcome or congratulatory message. ForgeEvents lets your controller dispatch an event, and a listener handles it later, off the request.

Install ForgeEvents

php forge.php package:install-module --module=forge-events

Create Events

An event is a plain readonly class annotated with #[Event], which describes its queue and retry behavior. Create two events in app/Events/:

<?php

declare(strict_types=1);

namespace App\Events;

use Modules\ForgeEvents\Attributes\Event;
use Modules\ForgeEvents\Enums\QueuePriority;

#[Event(
    queue: 'todos',
    maxRetries: 3,
    delay: '0s',
    priority: QueuePriority::NORMAL,
)]
final readonly class TodoCreatedEvent
{
    public function __construct(
        public int $todoId,
        public int $userId,
        public string $title,
    ) {}
}
<?php

declare(strict_types=1);

namespace App\Events;

use Modules\ForgeEvents\Attributes\Event;
use Modules\ForgeEvents\Enums\QueuePriority;

#[Event(
    queue: 'todos',
    maxRetries: 3,
    delay: '0s',
    priority: QueuePriority::HIGH,
)]
final readonly class TodoCompletedEvent
{
    public function __construct(
        public int $todoId,
        public int $userId,
        public string $title,
    ) {}
}

Create a Listener

A listener is a class with methods annotated #[EventListener]. Each method receives the typed event. Create app/Events/Listeners/TodoNotifier.php:

<?php

declare(strict_types=1);

namespace App\Events\Listeners;

use App\Events\TodoCompletedEvent;
use App\Events\TodoCreatedEvent;
use Modules\ForgeEvents\Attributes\EventListener;

final class TodoNotifier
{
    #[EventListener(TodoCreatedEvent::class)]
    public function onCreated(TodoCreatedEvent $event): void
    {
        // e.g. email, push notification, log
        error_log("Todo created: {$event->title} by user {$event->userId}");
    }

    #[EventListener(TodoCompletedEvent::class)]
    public function onCompleted(TodoCompletedEvent $event): void
    {
        error_log("Todo completed: {$event->title} by user {$event->userId}");
    }
}

Dispatch from the Controller

Inject the EventDispatcher and dispatch the events from the reactive actions in TodoController:

use App\Events\TodoCompletedEvent;
use App\Events\TodoCreatedEvent;
use Modules\ForgeEvents\Services\EventDispatcher;

    public function __construct(
        private readonly UserContext $userContext,
        private readonly EventDispatcher $dispatcher,
    ) {}

    #[Action]
    public function addTodo(): void
    {
        $title = trim($this->newTodoTitle);
        if ($title === '') {
            return;
        }

        $todo = new Todo();
        $todo->user_id = $this->currentUserId();
        $todo->title = $title;
        $todo->description = $this->newTodoDescription ?: null;
        $todo->completed = false;
        $todo->save();

        $this->dispatcher->dispatch(
            new TodoCreatedEvent(
                todoId: $todo->id,
                userId: $this->currentUserId(),
                title: $todo->title,
            )
        );

        $this->newTodoTitle = '';
        $this->newTodoDescription = '';
    }

    #[Action]
    public function toggleTodo(int $id): void
    {
        $todo = $this->findOwnedTodo($id);
        if ($todo) {
            $todo->completed = !$todo->completed;
            $todo->save();

            if ($todo->completed) {
                $this->dispatcher->dispatch(
                    new TodoCompletedEvent(
                        todoId: $todo->id,
                        userId: $this->currentUserId(),
                        title: $todo->title,
                    )
                );
            }
        }
    }

Run the Queue Worker

Events are queued and processed by a worker. Start it in a terminal alongside your server:

php forge.php queue:work --workers=2

Now when you create or complete a todo, the work happens in the background.

What we did: we added a background-processing capability. The app just dispatches events; the listener handles the "later" part.

Step 5: Add Tests (ForgeTesting)

Finally, let's lock in the behavior with automated tests. ForgeTesting issues real HTTP requests against your routes and asserts on the response and the database.

Install ForgeTesting

php forge.php package:install-module --module=forge-testing

Write a Todo Test

A test class extends Modules\ForgeTesting\TestCase and marks methods with #[Test]. The HttpTesting trait provides get(), post(), patch() and withCsrf(). Create app/tests/TodoTest.php:

<?php

declare(strict_types=1);

namespace App\Tests;

use Modules\ForgeTesting\Attributes\Group;
use Modules\ForgeTesting\Attributes\Test;
use Modules\ForgeTesting\TestCase;

#[Group('todos')]
final class TodoTest extends TestCase
{
    #[Test('A guest is redirected away from /todos')]
    public function guest_is_redirected_away_from_todos(): void
    {
        $response = $this->get('/todos');
        $this->assertHttpStatus(302, $response);
    }

    #[Test('A logged-in user can create a todo')]
    public function user_can_create_todo(): void
    {
        $this->registerAndLogin('tester@example.com');

        $response = $this->post('/todos', $this->withCsrf([
            'title' => 'Test Todo',
            'description' => 'Test Description',
        ]));

        $this->assertHttpStatus(302, $response);
        $this->assertDatabaseHas('todos', [
            'title' => 'Test Todo',
            'description' => 'Test Description',
        ]);
    }

    #[Test('A logged-in user can create several todos')]
    public function user_can_create_multiple_todos(): void
    {
        $this->registerAndLogin('tester2@example.com');

        $this->post('/todos', $this->withCsrf(['title' => 'First']));
        $this->post('/todos', $this->withCsrf(['title' => 'Second']));

        $this->assertDatabaseCount('todos', 2, []);
    }

    private function registerAndLogin(string $email): void
    {
        $this->post('/auth/register', $this->withCsrf([
            'identifier' => $email,
            'email' => $email,
            'password' => 'password123',
        ]));

        $this->post('/auth/login', $this->withCsrf([
            'identifier' => $email,
            'password' => 'password123',
        ]));
    }
}

Notice we don't fabricate a logged-in user — we walk through the real register and login endpoints, the same way a person would. That keeps the test honest and exercises the auth capability too.

Note: The HttpTesting trait (which powers get(), post() and withCsrf()) is momentarily disabled in some builds of TestCase while the test HTTP layer is finalized. If your TestCase doesn't expose these yet, enable the trait or follow the version's documented surface.

Run the Tests

# Run all app tests
php forge.php test

# Run only the todos group
php forge.php test --group=todos

What we did: a testing capability that drives the app end-to-end, so the todo behavior stays correct as you keep assembling.

Putting It All Together

Let's look at the app we assembled. Nothing here was handed to us as a finished "framework" — each part is a capability we plugged into the Kernel, step by step.

Final File Structure

app/
├── Controllers/
│   └── TodoController.php          # plain routes + reactive actions
├── Models/
│   └── Todo.php                    # todos row mapped to a plain object
├── Events/
│   ├── TodoCreatedEvent.php
│   ├── TodoCompletedEvent.php
│   └── Listeners/
│       └── TodoNotifier.php
├── Database/
│   └── Migrations/
│       ├── 2025_01_01_000000_CreateTodosTable.php
│       └── 2025_01_01_000001_AddUserIdToTodosTable.php
├── resources/
│   └── views/
│       ├── layouts/
│       │   └── main.php
│       └── todos/
│           └── index.php
└── tests/
    └── TodoTest.php

Running the Application

  1. Start the development server: php forge.php serve
  2. Visit http://localhost:8000
  3. Register at /auth/register and log in at /auth/login
  4. Open your todos at /todos — adding, completing, and deleting works reactively.
  5. Run the queue worker for background events: php forge.php queue:work

What We Built, and How

Each row is a capability you added in its own step — and the assembled result is a full application:

  • A plain todo app with migrations, a model, routes, and views (Step 1).
  • User accounts so every user owns their todos (Step 2).
  • Real-time updates with ForgeWire (Step 3).
  • Background events with ForgeEvents (Step 4).
  • Tests that drive the app end-to-end with ForgeTesting (Step 5).

Next Steps

The app is assembled. You can keep extending it, reaching for a capability whenever a feature asks for one:

  • Categories or tags for todos
  • Due dates and reminders
  • Search and filtering
  • Pagination for large lists
  • Notifications when todos are shared

Congratulations! You started with a tiny Kernel and ended with a complete application. You never adopted a monolithic framework — you assembled one out of the capabilities you actually needed, and you understood every piece because you added it yourself.