Building a Todo Application

A step-by-step tutorial demonstrating how to combine multiple Forge Kernel features and modules to build a complete application.

Introduction

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.

What We're Building

A todo application with the following features:

  • User authentication (registration and login)
  • CRUD operations for todos (Create, Read, Update, Delete)
  • Interactive todo list with real-time updates (ForgeWire)
  • Background event processing for notifications
  • Comprehensive test coverage
  • User-specific todos (each user sees only their todos)

Modules We'll Use

  • ForgeAuth: User authentication and session management
  • ForgeDatabaseSql: Database migrations and schema management
  • ForgeSqlOrm: Object-Relational Mapping for models
  • ForgeWire: Interactive components with real-time updates
  • ForgeEvents: Event-driven architecture and background processing
  • ForgeTesting: Comprehensive testing framework

Prerequisites

  • PHP 8.3 or higher
  • Forge Kernel installed and configured
  • Basic understanding of PHP and object-oriented programming
  • Familiarity with MVC architecture (helpful but not required)

Note: This tutorial assumes you have Forge Kernel installed. If not, please refer to the Getting Started guide first.

Project Setup

Let's start by setting up our project and installing the required modules.

Installing 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

Configuring Middleware

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' => []
];

Project Structure

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

Environment Configuration

Ensure your .env file is configured with database settings:

DB_DRIVER=sqlite
DB_DATABASE=storage/database/todos.sqlite
APP_DEBUG=true
APP_ENV=local

Database & Models

Let's start by creating our database schema and model for todos.

Creating the Migration

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;
}

Running the Migration

Run the migration to create the todos table:

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

Creating the Todo Model

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();
    }
}

Creating a Repository

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.

Authentication

We'll use ForgeAuth to handle user authentication. The module should already be installed and configured.

Using ForgeAuth

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

Getting the Current User

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
}

Protecting Routes

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.

Data Transfer Objects (DTOs)

Before creating our controller, let's create a DTO (Data Transfer Object) for creating todos. DTOs provide several benefits:

  • Type Safety: DTOs enforce type checking and ensure data integrity
  • Validation: Centralized validation logic for input data
  • Documentation: Clear contract of what data is expected
  • Maintainability: Changes to data structure are isolated to the DTO
  • Security: Prevents mass assignment vulnerabilities by explicitly defining allowed fields

Creating CreateTodoDTO

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.

Controllers & Routes

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.

Creating TodoController

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");
    }
}

Route Attributes

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

Views & Layouts

Let's create the views for our todo application.

Creating the Layout

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>

Creating the Todos Index View

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>

ForgeWire: Real-Time Reactivity

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.

1. Making the Controller Reactive

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 = '';
    }
}

2. Updating the View with Directives

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"
                    >
                        &times;
                    </button>
                </div>
            <?php endforeach; ?>
        </div>
    </div>

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

3. Using Components with ForgeWire

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 ?>">
        &times;
    </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.

Events

Let's add event-driven functionality for background processing, like sending notifications when todos are created or completed.

Creating Events

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,
    ) {}
}

Creating Event Listeners

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

Dispatching Events

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

Running Queue Workers

Start the queue worker to process events:

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

Testing

Let's write comprehensive tests for our todo application using ForgeTesting.

Creating Todo Tests

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",
        ]);
    }
}

Running Tests

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

Putting It All Together

Now that we've built all the components, let's see how everything works together.

Final File Structure

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

Running the Application

  1. Start the development server: php forge.php serve
  2. Visit http://localhost:8000
  3. Register a new user at /auth/register
  4. Login at /auth/login
  5. Access your todos at /todos
  6. Start the queue worker: php forge.php queue:work

What We've Built

We've successfully created a todo application that demonstrates:

  • Database migrations and models using ForgeDatabaseSql and ForgeSqlOrm
  • User authentication with ForgeAuth
  • CRUD operations with controllers and routes
  • Views and layouts with proper CSRF protection
  • Interactive components with ForgeWire
  • Event-driven architecture with ForgeEvents
  • Comprehensive testing with ForgeTesting

Next Steps

You can extend this application with:

  • Categories or tags for todos
  • Due dates and reminders
  • Todo sharing between users
  • Search and filtering
  • Pagination for large todo lists
  • Real-time notifications
  • Export todos to CSV/JSON

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.