laravel

How to Use the Verbs Package with FilamentPHP in Laravel

Integrating powerful event sourcing in Laravel has never been easier, thanks to the Verbs package. If you're building admin panels or internal tools with FilamentPHP, combining it with Verbs opens up an elegant way to manage application state using event-driven logic.

In this article, I’ll walk you through building a simple Todo application using FilamentPHP and the Verbs package, based on the open-source repository: github.com/tisuchi/filament-with-verbs.

#πŸš€ Why Use Verbs with Filament?

  • Event Sourcing: Every change to your model is recorded as an event.
  • Auditability: You can replay or inspect changes to debug issues.
  • Elegant state management: Your domain logic lives inside well-structured Event and State classes.

#🧱 Installation

Start by creating a fresh Laravel project:

laravel new filament-verbs-app
cd filament-verbs-app

Install required packages:

composer require filament/filament
composer require thunk/verbs

Publish Verbs migrations and config:

php artisan vendor:publish --tag=verbs-migrations
php artisan vendor:publish --tag=verbs-config
php artisan migrate

Install Filament:

php artisan make:filament-user

#🧠 Creating the Todo Domain

We'll define a TodoState that represents our aggregate's current status.

#1. Create the State Class

php artisan make:state Todo

In app/States/TodoState.php:

namespace App\States;

use Thunk\Verbs\State;

class TodoState extends State
{
    public int $todoId;

    public string $title;

    public ?string $description;

    public bool $isCompleted;

    public int $userId;
}

#2. Define Events

TodoCreated Event:

php artisan make:event TodoCreated
namespace App\Events;

use App\Models\Todo;
use Thunk\Verbs\Event;
use App\States\TodoState;
use Thunk\Verbs\Attributes\Autodiscovery\StateId;

class TodoCreated extends Event
{
    #[StateId(TodoState::class)]
    public int $todoId;

    public string $title;

    public ?string $description;

    public bool $isCompleted;

    public int $userId;

    public function apply(TodoState $state)
    {
        $state->title = $this->title;
        $state->description = $this->description;
        $state->isCompleted = $this->isCompleted;
        $state->userId = $this->userId;
    }

    public function handle(TodoState $state)
    {
        return Todo::create([
            'id' => $this->todoId,
            'title' => $state->title,
            'description' => $state->description,
            'is_completed' => $state->isCompleted,
            'user_id' => $state->userId,
        ]);
    }
}

TodoUpdated Event:

namespace App\Events;

use App\Models\Todo;
use Thunk\Verbs\Event;
use App\States\TodoState;
use Thunk\Verbs\Attributes\Autodiscovery\StateId;

class TodoUpdated extends Event
{
    #[StateId(TodoState::class)]
    public int $todoId;

    public ?string $title;

    public ?string $description;

    public bool $isCompleted;

    public function apply(TodoState $state)
    {
        $state->title = $this->title;
        $state->description = $this->description;
        $state->isCompleted = $this->isCompleted;
    }

    public function handle(TodoState $state)
    {
        return Todo::findOrFail($this->todoId)->update([
            'title' => $state->title,
            'description' => $state->description,
            'is_completed' => $state->isCompleted,
        ]);
    }
}

#🎨 Creating the Filament Resource

php artisan make:filament-resource Todo

In TodoResource.php, override the create and update logic:

class TodoResource extends Resource
{
    protected static ?string $model = Todo::class;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                // Check details implementation there: https://github.com/tisuchi/filament-with-verbs/blob/main/app/Filament/Resources/TodoResource.php
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                // Check details implementation there: https://github.com/tisuchi/filament-with-verbs/blob/main/app/Filament/Resources/TodoResource.php
            ]);
    }
}

#Update CreateTodo page

namespace App\Filament\Resources\TodoResource\Pages;

+ use App\Events\TodoCreated;

class CreateTodo extends CreateRecord
{

+    // Override the create method to use events instead
+    protected function handleRecordCreation(array $data): Model
+    {
+        // Fire the TodoCreated event instead of creating the record through Filament
+        TodoCreated::commit(
+            todoId: snowflake_id(),
+            title: $data['title'],
+            description: $data['description'],
+            isCompleted: $data['is_completed'],
+            userId: Auth::id(),
+        );
+
+        // Find the most recently created Todo for the current user
+        // This is necessary because Filament expects a model to be returned
+        return \App\Models\Todo::where('user_id', Auth::id())
+            ->latest()
+            ->first();
+    }
}

#Update CreateTodo page

namespace App\Filament\Resources\TodoResource\Pages;

+ use App\Events\TodoUpdated;

class EditTodo extends EditRecord
{
+    protected function mutateFormDataBeforeSave(array $data): array
+    {
+        TodoUpdated::commit(
+            todoId: $this->record->id,
+            title: $data['title'],
+            description: $data['description'] ?? null,
+            isCompleted: $data['is_completed'] ?? false,
+        );
+
+        return \App\Models\Todo::where('user_id', Auth::id())
+            ->latest()
+            ->first()
+            ->toArray();
+    }
}

Check details here how to use admin section.

#πŸ” Replaying Events

For some reason, you may need to replay and rebuild the data from your event and states. To do so, you need to truncate your todos table.

⚠️⚠️⚠️ Please take a note that, you must not truncate 3 tables prefix with verb_ tables. If you mess with this tables, you will come out with some unusual results.

  1. Before truncate the database, confirm that I have this record in the todos table.

    Reconstructed Todos Table
  2. First, clear the current state by truncating the todos table (this simulates data loss in the domain table)

  3. Run the replay command to rebuild the state from events:

    php artisan verbs:replay
    
  4. Confirm that you want to run all events:

    Verbs Replay Confirmation
  5. Successful replay notification:

    Replay Success
  6. Verify that the todos table has been fully reconstructed with exactly the same data that was there before:

    Reconstructed Todos Table

This demonstrates the power of event sourcing - even if you lose your entire domain table, you can rebuild it completely from the stored events!

This will apply all events from the events table and reconstruct your state objects.

#πŸ§ͺ Try It Yourself

The full source code is available on GitHub: πŸ‘‰ github.com/tisuchi/filament-with-verbs

Run locally:

git clone https://github.com/tisuchi/filament-with-verbs.git
cd filament-with-verbs
cp .env.example .env
php artisan migrate --seed
php artisan serve

Login credentials:

#βœ… Final Thoughts

Verbs and Filament make a perfect pair for building robust, maintainable admin panels powered by event-sourcing. You can easily extend this foundation into audit trails, workflow engines, and more.

Have any questions? Drop by Laravel School and let’s keep learning together!

Happy coding! πŸš€