An assembled-app recipe: build a working todo app incrementally, starting from a tiny Kernel and adding one capability at a time.
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.
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:
Note: This tutorial assumes you've installed Forge Kernel. If not, follow the Getting Started guide first.
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.
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.
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' => [],
];
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
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
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.
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
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.
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.
request
receives the current
Request
— $request->input('title')
reads form data.
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>
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.
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.
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,
],
];
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
{
}
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;
}
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.
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>
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.
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.
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
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.
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">
×
</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.
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.
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.
php forge.php package:install-module --module=forge-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,
) {}
}
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}");
}
}
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,
)
);
}
}
}
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.
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.
php forge.php package:install-module --module=forge-testing
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 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.
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.
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
php forge.php serve
http://localhost:8000
/auth/register
and log in at
/auth/login
/todos
— adding, completing, and deleting works
reactively.
php forge.php queue:work
Each row is a capability you added in its own step — and the assembled result is a full application:
The app is assembled. You can keep extending it, reaching for a capability whenever a feature asks for one:
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.