A step-by-step tutorial demonstrating how to combine multiple Forge Kernel features and modules to build a complete application.
In this tutorial, we'll build a todo application that demonstrates how to use multiple Forge Kernel modules together. This "glorified todo" app will showcase authentication, database operations, interactive components, events, and testing.
A todo application with the following features:
Note: This tutorial assumes you have Forge Kernel installed. If not, please refer to the Getting Started guide first.
Let's start by setting up our project and installing the required modules.
We'll need several modules for our todo application. Install them using ForgePackageManager:
# Install authentication module
php forge.php module:package-install --module=forge-auth
# Install database SQL module
php forge.php module:package-install --module=forge-database-sql
# Install SQL ORM module
php forge.php module:package-install --module=forge-sql-orm
# Install ForgeWire for interactive components
php forge.php module:package-install --module=forge-wire
# Install ForgeEvents for background processing
php forge.php module:package-install --module=forge-events
# Install ForgeTesting for testing
php forge.php module:package-install --module=forge-testing
After installing ForgeWire, you must register
its middleware in
config/middlewares.php to enable
the reactive kernel:
<?php
return [
'global' => [],
'web' => [
\App\Modules\ForgeWire\Middlewares\ForgeWireMiddleware::class,
],
'api' => []
];
Our application will have the following structure:
app/
├── Controllers/
│ └── TodoController.php
├── Models/
│ └── Todo.php
├── Events/
│ ├── TodoCreatedEvent.php
│ └── TodoCompletedEvent.php
├── Database/
│ └── Migrations/
│ └── CreateTodosTable.php
├── Repositories/
│ └── TodoRepository.php
└── tests/
└── TodoTest.php
Ensure your
.env
file is configured with database settings:
DB_DRIVER=sqlite
DB_DATABASE=storage/database/todos.sqlite
APP_DEBUG=true
APP_ENV=local
Let's start by creating our database schema and model for todos.
First, we'll create a migration for the todos
table. Create
app/Database/Migrations/2025_01_01_000000_CreateTodosTable.php:
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use App\Modules\ForgeAuth\Models\User;
use App\Modules\ForgeDatabaseSQL\DB\Attributes\BelongsTo;
use App\Modules\ForgeDatabaseSQL\DB\Attributes\Column;
use App\Modules\ForgeDatabaseSQL\DB\Attributes\Index;
use App\Modules\ForgeDatabaseSQL\DB\Attributes\Table;
use App\Modules\ForgeDatabaseSQL\DB\Attributes\Timestamps;
use App\Modules\ForgeDatabaseSQL\DB\Enums\ColumnType;
use App\Modules\ForgeDatabaseSQL\DB\Migrations\Migration;
#[Table(name: 'todos')]
#[BelongsTo(related: User::class)]
#[Index(columns: ['user_id'], name: 'idx_todos_user_id')]
#[Index(columns: ['completed'], name: 'idx_todos_completed')]
#[Timestamps]
class CreateTodosTable extends Migration
{
#[Column(name: 'id', type: ColumnType::INTEGER, primaryKey: true, autoIncrement: true)]
public readonly int $id;
#[Column(name: 'user_id', type: ColumnType::INTEGER, nullable: false)]
public readonly int $userId;
#[Column(name: 'title', type: ColumnType::STRING, nullable: false, length: 255)]
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;
}
Run the migration to create the todos table:
php forge.php db:migrate --type=app
Now let's create our Todo model. Create
app/Models/Todo.php:
<?php
declare(strict_types=1);
namespace App\Models;
use App\Modules\ForgeSqlOrm\ORM\Attributes\Column;
use App\Modules\ForgeSqlOrm\ORM\Attributes\ProtectedFields;
use App\Modules\ForgeSqlOrm\ORM\Attributes\Table;
use App\Modules\ForgeSqlOrm\ORM\Model;
use App\Modules\ForgeSqlOrm\Traits\HasMetaData;
use App\Modules\ForgeSqlOrm\Traits\HasTimeStamps;
#[Table("todos")]
#[ProtectedFields("id", "user_id", "created_at", "updated_at")]
class Todo extends Model
{
use HasTimeStamps;
use HasMetaData;
#[Column]
public int $user_id;
#[Column]
public string $title;
#[Column]
public ?string $description;
#[Column]
public bool $completed = false;
public function toggle(): void
{
$this->completed = !$this->completed;
$this->save();
}
public function markAsComplete(): void
{
$this->completed = true;
$this->save();
}
public function markAsIncomplete(): void
{
$this->completed = false;
$this->save();
}
}
Let's create a repository for todo operations.
Create
app/Repositories/TodoRepository.php:
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Dto\CreateTodoDTO;
use App\Models\Todo;
use App\Modules\ForgeSqlOrm\ORM\RecordRepository;
class TodoRepository extends RecordRepository
{
protected string $model = Todo::class;
public function create(CreateTodoDTO $dto, int $userId): Todo
{
return parent::create([
'user_id' => $userId,
'title' => $dto->title,
'description' => $dto->description,
'completed' => $dto->completed,
]);
}
public function findByUserId(int $userId): array
{
return $this->query()
->where('user_id', $userId)
->orderBy('created_at', 'DESC')
->get();
}
public function findIncompleteByUserId(int $userId): array
{
return $this->query()
->where('user_id', $userId)
->where('completed', false)
->orderBy('created_at', 'DESC')
->get();
}
public function findCompleteByUserId(int $userId): array
{
return $this->query()
->where('user_id', $userId)
->where('completed', true)
->orderBy('created_at', 'DESC')
->get();
}
}
The repository extends
RecordRepository, which provides a base
create()
method that accepts an array. We've added a
type-safe
create()
method that accepts a
CreateTodoDTO
and user ID, which internally calls the parent
method with the properly structured data. This
provides better type safety and ensures
consistent data structure.
We'll use ForgeAuth to handle user authentication. The module should already be installed and configured.
ForgeAuth provides authentication routes out of the box. Users can register and login at:
/auth/register
- User registration
/auth/login
- User login
/auth/logout
- User logout
In your controllers, you can get the current authenticated user using the ForgeAuthService:
use App\Modules\ForgeAuth\Services\ForgeAuthService;
public function __construct(
private readonly ForgeAuthService $auth,
) {}
public function index(): Response
{
$user = $this->auth->user();
if ($user === null) {
return Redirect::to('/auth/login');
}
// Use $user->id to get the user's ID
}
Use the AuthMiddleware to protect routes that require authentication. It's best practice to apply middleware at the class level when all methods require the same middleware:
use App\Modules\ForgeAuth\Middlewares\AuthMiddleware;
use App\Modules\ForgeRouter\Http\Attributes\Middleware;
#[Service]
#[Middleware("web")]
#[Middleware("App\Modules\ForgeAuth\Middlewares\AuthMiddleware")]
final class TodoController
{
// All methods in this controller require authentication
#[Route("/todos")]
public function index(): Response
{
// This route requires authentication
}
}
This approach is cleaner than repeating the middleware attribute on each method, and ensures all routes in the controller are protected.
Before creating our controller, let's create a DTO (Data Transfer Object) for creating todos. DTOs provide several benefits:
Create
app/Dto/CreateTodoDTO.php:
<?php
declare(strict_types=1);
namespace App\Dto;
final class CreateTodoDTO
{
public function __construct(
public string $title,
public ?string $description = null,
public bool $completed = false,
) {
}
public static function fromArray(array $data): self
{
return new self(
title: (string)($data['title'] ?? ''),
description: isset($data['description']) && $data['description'] !== ''
? (string)$data['description']
: null,
completed: isset($data['completed']) && (bool)$data['completed'],
);
}
public function toArray(): array
{
return [
'title' => $this->title,
'description' => $this->description,
'completed' => $this->completed,
];
}
}
This DTO defines the structure for creating a
todo. The
fromArray()
method safely converts request data into a typed
DTO instance, while
toArray()
converts it back to an array for database
operations.
Now let's create our TodoController with full CRUD operations. We'll use descriptive method names (not Laravel-style) and follow best practices by using DTOs and the repository pattern.
Create
app/Controllers/TodoController.php:
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Dto\CreateTodoDTO;
use App\Events\TodoCompletedEvent;
use App\Events\TodoCreatedEvent;
use App\Modules\ForgeAuth\Middlewares\AuthMiddleware;
use App\Modules\ForgeAuth\Services\ForgeAuthService;
use App\Modules\ForgeEvents\Services\EventDispatcher;
use App\Modules\ForgeWire\Attributes\Action;
use App\Modules\ForgeWire\Attributes\Reactive;
use App\Modules\ForgeWire\Attributes\State;
use App\Repositories\TodoRepository;
use Forge\Core\DI\Attributes\Service;
use Forge\Core\Helpers\Flash;
use Forge\Core\Helpers\Redirect;
use App\Modules\ForgeRouter\Http\Attributes\Middleware;
use App\Modules\ForgeRouter\Http\Request;
use App\Modules\ForgeRouter\Http\Response;
use App\Modules\ForgeRouter\Routing\Route;
use Forge\Traits\ControllerHelper;
use Forge\Traits\SecurityHelper;
#[Reactive]
#[Middleware("web")]
#[Middleware("App\Modules\ForgeAuth\Middlewares\AuthMiddleware")]
final class TodoController
{
use ControllerHelper;
use SecurityHelper;
#[State]
public string $newTodoTitle = '';
#[State]
public string $newTodoDescription = '';
public function __construct(
private readonly ForgeAuthService $auth,
private readonly TodoRepository $repository,
private readonly EventDispatcher $dispatcher,
) {}
#[Action]
public function addTodoReactive(): void
{
if (trim($this->newTodoTitle) === '') return;
$user = $this->auth->user();
$dto = new CreateTodoDTO(
title: $this->newTodoTitle,
description: $this->newTodoDescription ?: null
);
$todo = $this->repository->create($dto, $user->id);
$this->dispatcher->dispatch(
new TodoCreatedEvent(
todoId: $todo->id,
userId: $user->id,
title: $todo->title
)
);
$this->newTodoTitle = '';
$this->newTodoDescription = '';
}
#[Action]
public function toggleTodoReactive(int $id): void
{
$user = $this->auth->user();
$todo = $this->repository->find($id);
if ($todo && $todo->user_id === $user->id) {
$todo->toggle();
if ($todo->completed) {
$this->dispatcher->dispatch(
new TodoCompletedEvent(
todoId: $todo->id,
userId: $user->id,
title: $todo->title
)
);
}
}
}
#[Action]
public function deleteTodoReactive(int $id): void
{
$user = $this->auth->user();
$todo = $this->repository->find($id);
if ($todo && $todo->user_id === $user->id) {
$this->repository->delete($todo);
}
}
#[Route("/todos")]
public function index(): Response
{
$user = $this->auth->user();
$todos = $this->repository->findByUserId($user->id);
return $this->view("todos/index", [
"todos" => $todos,
"user" => $user,
"total" => $this->total,
"number1" => $this->number1,
"number2" => $this->number2,
]);
}
#[Route("/todos", "POST")]
public function createTodo(Request $request): Response
{
$user = $this->auth->user();
$todoData = $this->sanitize($request->postData);
$data = [
'user_id' => $user->id,
...$todoData
];
if (empty($data['title'])) {
Flash::set("error", "Title is required");
return Redirect::to("/todos");
}
$dto = CreateTodoDTO::fromArray($data);
$todo = $this->repository->create($dto, $user->id);
Flash::set("success", "Todo created successfully");
$this->dispatcher->dispatch(
new TodoCreatedEvent(
todoId: $todo->id,
userId: $user->id,
title: $todo->title
)
);
return Redirect::to("/todos");
}
#[Route("/todos/{id}", "PATCH")]
public function updateTodo(Request $request, string $id): Response
{
$user = $this->auth->user();
$todo = $this->repository->find((int)$id);
if ($todo === null || $todo->user_id !== $user->id) {
Flash::set("error", "Todo not found");
return Redirect::to("/todos");
}
$data = $this->sanitize($request->postData);
$updateData = [];
if (isset($data['title'])) {
$updateData['title'] = $data['title'];
}
if (isset($data['description'])) {
$updateData['description'] = $data['description'];
}
if (isset($data['completed'])) {
$updateData['completed'] = (bool)$data['completed'];
}
if (!empty($updateData)) {
$this->repository->update($todo, $updateData);
}
Flash::set("success", "Todo updated successfully");
return Redirect::to("/todos");
}
#[Route("/todos/{id}/toggle", "POST")]
public function toggle(string $id): Response
{
$user = $this->auth->user();
$todo = $this->repository->find((int)$id);
if ($todo === null || $todo->user_id !== $user->id) {
Flash::set("error", "Todo not found");
return Redirect::to("/todos");
}
$todo->toggle();
Flash::set("success", "Todo " . ($todo->completed ? "completed" : "marked as incomplete"));
if ($todo->completed) {
$this->dispatcher->dispatch(
new TodoCompletedEvent(
todoId: $todo->id,
userId: $user->id,
title: $todo->title
)
);
}
return Redirect::to("/todos");
}
#[Route("/todos/{id}", "DELETE")]
public function deleteTodo(string $id): Response
{
$user = $this->auth->user();
$todo = $this->repository->find((int)$id);
if ($todo === null || $todo->user_id !== $user->id) {
Flash::set("error", "Todo not found");
return Redirect::to("/todos");
}
$this->repository->delete($todo);
Flash::set("success", "Todo deleted successfully");
return Redirect::to("/todos");
}
}
The
#[Route]
attribute defines routes:
#[Route("/todos")]
- GET route
#[Route("/todos", "POST")]
- POST route
#[Route("/todos/{id}", "PATCH")]
- PATCH route with parameter
#[Route("/todos/{id}", "DELETE")]
- DELETE route
Let's create the views for our todo application.
First, create a layout file at
app/resources/views/layouts/todos.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title ?? 'Todos') ?></title>
<link rel="stylesheet" href="/assets/css/app.css">
<?= csrf_meta() ?>
<?= window_csrf_token() ?>
</head>
<body class="bg-gray-50">
<nav class="bg-white shadow-sm mb-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="/todos" class="text-xl font-bold text-gray-900">Todo App</a>
</div>
<div class="flex items-center space-x-4">
<span class="text-gray-600"><?= e($user->email ?? 'Guest') ?></span>
<a href="/auth/logout" class="text-blue-600 hover:text-blue-800">Logout</a>
</div>
</div>
</div>
</nav>
<main class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-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>
Create
app/resources/views/todos/index.php:
<?php layout('todos'); ?>
<div class="bg-white rounded-lg shadow p-6">
<h1 class="text-2xl font-bold mb-6">My Todos</h1>
<!-- Create Todo Form -->
<form method="POST" action="/todos" class="mb-6">
<?= csrf_input() ?>
<div class="flex gap-4">
<input
type="text"
name="title"
placeholder="Todo title..."
required
class="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<input
type="text"
name="description"
placeholder="Description (optional)"
class="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<button
type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Add Todo
</button>
</div>
</form>
<!-- Todos List -->
<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 opacity-75' : '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">
<?= csrf_input() ?>
<button
type="submit"
class="px-4 py-2 <?= $todo->completed ? 'bg-yellow-600' : 'bg-green-600' ?> text-white rounded-md hover:opacity-80"
>
<?= $todo->completed ? 'Undo' : 'Complete' ?>
</button>
</form>
<form method="POST" action="/todos/<?= $todo->id ?>" class="inline">
<?= csrf_input() ?>
<input type="hidden" name="_method" value="DELETE">
<button
type="submit"
class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
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 todo above!</p>
<?php endif; ?>
</div>
</div>
Now for the magic. We'll add ForgeWire to our
TodoController to make it reactive.
This means changes will happen instantly in the
browser without full page refreshes, yet all
logic remains securely on the server.
We don't need to create a new class. We simply
add the #[Reactive] attribute to
our existing TodoController and
mark the data we want to persist between updates
with #[State].
#[Reactive] // Enable reactivity
#[Service]
#[Middleware("web")]
#[Middleware("App\Modules\ForgeAuth\Middlewares\AuthMiddleware")]
final class TodoController
{
use ControllerHelper;
// These values will be preserved in the session between reactive updates
#[State]
public string $newTodoTitle = '';
#[State]
public string $newTodoDescription = '';
#[Action] // Can be called from the frontend via fw:click
public function addTodoReactive(): void
{
if (empty($this->newTodoTitle)) return;
$user = $this->auth->user();
$dto = new CreateTodoDTO(
title: $this->newTodoTitle,
description: $this->newTodoDescription ?: null
);
$this->repository->create($dto, $user->id);
// Clear inputs after success
$this->newTodoTitle = '';
$this->newTodoDescription = '';
}
}
Now we wrap the todo list in a reactive
container using fw_id() and bind
our inputs using fw:model. We also
use fw:target to optimize DOM
updates.
<!-- app/resources/views/todos/index.php -->
<div <?= fw_id('todo-app') ?> class="bg-white rounded-lg shadow p-6">
<h1 class="text-2xl font-bold mb-6">My Reactive Todos</h1>
<div class="flex gap-4 mb-8">
<input
type="text"
fw:model.defer="newTodoTitle"
placeholder="What needs doing?"
fw:keydown.enter="addTodoReactive"
class="flex-1 px-4 py-2 border rounded-md"
>
<button
fw:click="addTodoReactive"
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 rounded-md">
<div class="flex-1">
<input type="checkbox" <?= $todo->completed ? 'checked' : '' ?>
fw:click="toggleTodoReactive"
fw:param-id="<?= $todo->id ?>"
class="form-check-input"
>
<span class="<?= $todo->completed ? 'line-through text-gray-500' : '' ?>">
<?= e($todo->title) ?>
</span>
</div>
<button
fw:click="deleteTodoReactive"
fw:param-id="<?= $todo->id ?>"
class="text-red-600"
>
×
</button>
</div>
<?php endforeach; ?>
</div>
</div>
<div fw:loading class="text-blue-600 mt-4">
Updating...
</div>
</div>
For better organization, you can extract parts
of your UI into reusable components. Create
app/resources/components/todo-item.php:
<!-- app/resources/components/todo-item.php -->
<div class="flex items-center gap-4 p-4 border rounded-md">
<div class="flex-1">
<input type="checkbox" <?= $todo->completed ? 'checked' : '' ?>
fw:click="toggleTodoReactive"
fw:param-id="<?= $todo->id ?>"
>
<span class="<?= $todo->completed ? 'line-through text-gray-500' : '' ?>">
<?= e($todo->title) ?>
</span>
</div>
<button fw:click="deleteTodoReactive" fw:param-id="<?= $todo->id ?>">
×
</button>
</div>
Then in your index view:
<?php foreach ($todos as $todo): ?>
<?= component('todo-item', ['todo' => $todo]) ?>
<?php endforeach; ?>
Pro Tip: When using ForgeWire directives inside components, they automatically reference the parent reactive controller. This makes building complex, modular UIs incredibly simple.
Let's add event-driven functionality for background processing, like sending notifications when todos are created or completed.
Create
app/Events/TodoCreatedEvent.php:
<?php
declare(strict_types=1);
namespace App\Events;
use App\Modules\ForgeEvents\Attributes\Event;
use App\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,
) {}
}
Create
app/Events/TodoCompletedEvent.php:
<?php
declare(strict_types=1);
namespace App\Events;
use App\Modules\ForgeEvents\Attributes\Event;
use App\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
app/Services/TodoNotificationService.php:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\TodoCompletedEvent;
use App\Events\TodoCreatedEvent;
use App\Modules\ForgeEvents\Attributes\EventListener;
use Forge\Core\DI\Attributes\Service;
#[Service]
class TodoNotificationService
{
#[EventListener(TodoCreatedEvent::class)]
public function handleTodoCreated(TodoCreatedEvent $event): void
{
// Log or send notification
error_log("Todo created: {$event->title} by user {$event->userId}");
// In a real app, you might send an email, push notification, etc.
}
#[EventListener(TodoCompletedEvent::class)]
public function handleTodoCompleted(TodoCompletedEvent $event): void
{
// Log or send notification
error_log("Todo completed: {$event->title} by user {$event->userId}");
// In a real app, you might send a congratulatory message, etc.
}
}
Update your TodoController to dispatch events:
use App\Events\TodoCreatedEvent;
use App\Events\TodoCompletedEvent;
use App\Modules\ForgeEvents\Services\EventDispatcher;
public function __construct(
private readonly ForgeAuthService $auth,
private readonly TodoRepository $repository,
private readonly EventDispatcher $dispatcher,
) {}
public function store(Request $request): Response
{
// ... create todo ...
$this->dispatcher->dispatch(
new TodoCreatedEvent(
todoId: $todo->id,
userId: $user->id,
title: $todo->title,
)
);
// ... rest of method ...
}
public function toggle(string $id): Response
{
// ... toggle todo ...
if ($todo->completed) {
$this->dispatcher->dispatch(
new TodoCompletedEvent(
todoId: $todo->id,
userId: $user->id,
title: $todo->title,
)
);
}
// ... rest of method ...
}
Start the queue worker to process events:
php forge.php queue:work --workers=2
Let's write comprehensive tests for our todo application using ForgeTesting.
Create
app/tests/TodoTest.php:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Models\Todo;
use App\Modules\ForgeAuth\Models\User;
use App\Modules\ForgeAuth\Services\ForgeAuthService;
use App\Modules\ForgeTesting\Attributes\Group;
use App\Modules\ForgeTesting\Attributes\Test;
use App\Modules\ForgeTesting\TestCase;
#[Group("todos")]
final class TodoTest extends TestCase
{
#[Test("User can view todos page when authenticated")]
public function user_can_view_todos_when_authenticated(): void
{
$user = $this->createUser();
$this->actingAs($user);
$response = $this->get("/todos");
$this->assertHttpStatus(200, $response);
}
#[Test("User cannot view todos page when not authenticated")]
public function user_cannot_view_todos_when_not_authenticated(): void
{
$response = $this->get("/todos");
$this->assertHttpStatus(401, $response);
}
#[Test("User can create a todo")]
public function user_can_create_todo(): void
{
$user = $this->createUser();
$this->actingAs($user);
$response = $this->post("/todos", $this->withCsrf([
"title" => "Test Todo",
"description" => "Test Description",
]));
$this->assertHttpStatus(302, $response);
$this->assertDatabaseHas("todos", [
"title" => "Test Todo",
"user_id" => $user->id,
]);
}
#[Test("User can toggle todo completion")]
public function user_can_toggle_todo(): void
{
$user = $this->createUser();
$this->actingAs($user);
$todo = new Todo();
$todo->user_id = $user->id;
$todo->title = "Test Todo";
$todo->completed = false;
$todo->save();
$response = $this->post("/todos/{$todo->id}/toggle", $this->withCsrf([]));
$this->assertHttpStatus(302, $response);
$this->assertDatabaseHas("todos", [
"id" => $todo->id,
"completed" => true,
]);
}
#[Test("User can delete their own todo")]
public function user_can_delete_own_todo(): void
{
$user = $this->createUser();
$this->actingAs($user);
$todo = new Todo();
$todo->user_id = $user->id;
$todo->title = "Test Todo";
$todo->save();
$response = $this->delete("/todos/{$todo->id}", $this->withCsrf([]));
$this->assertHttpStatus(302, $response);
$this->assertDatabaseMissing("todos", [
"id" => $todo->id,
]);
}
#[Test("User cannot delete another user's todo")]
public function user_cannot_delete_other_user_todo(): void
{
$user1 = $this->createUser();
$user2 = $this->createUser();
$this->actingAs($user1);
$todo = new Todo();
$todo->user_id = $user2->id;
$todo->title = "Other User's Todo";
$todo->save();
$response = $this->delete("/todos/{$todo->id}", $this->withCsrf([]));
$this->assertHttpStatus(302, $response);
$this->assertDatabaseHas("todos", [
"id" => $todo->id,
]);
}
private function createUser(): User
{
$auth = $this->container->get(ForgeAuthService::class);
return $auth->register([
"email" => "test" . uniqid() . "@example.com",
"password" => "password123",
"identifier" => "testuser" . uniqid(),
]);
}
private function actingAs(User $user): void
{
$auth = $this->container->get(ForgeAuthService::class);
$auth->login([
"identifier" => $user->identifier,
"password" => "password123",
]);
}
}
Run your tests:
# Run all tests
php forge.php test
# Run only todo tests
php forge.php test --group=todos
# Run specific test
php forge.php test --filter=user_can_create_todo
Now that we've built all the components, let's see how everything works together.
app/
├── Controllers/
│ └── TodoController.php
├── Models/
│ └── Todo.php
├── Dto/
│ └── CreateTodoDTO.php
├── Events/
│ ├── TodoCreatedEvent.php
│ └── TodoCompletedEvent.php
├── Repositories/
│ └── TodoRepository.php
├── Services/
│ └── TodoNotificationService.php
├── Database/
│ └── Migrations/
│ └── 2025_01_01_000000_CreateTodosTable.php
├── resources/
│ └── views/
│ ├── layouts/
│ │ └── todos.php
│ └── todos/
│ └── index.php
└── tests/
└── TodoTest.php
php forge.php serve
http://localhost:8000
/auth/register
/auth/login
/todos
php forge.php queue:work
We've successfully created a todo application that demonstrates:
You can extend this application with:
Congratulations! You've built a complete todo application using multiple Forge Kernel modules. This demonstrates how the kernel's modular architecture allows you to combine different capabilities to build powerful applications.