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 engine:
<?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 logoutIn 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 Forge\Core\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 Forge\Core\Http\Attributes\Middleware;
use Forge\Core\Http\Request;
use Forge\Core\Http\Response;
use Forge\Core\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 routeLet'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 servehttp://localhost:8000/auth/register
/auth/login/todosphp forge.php queue:workWe'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.