hirethunk/verbs
Verbs is an event sourcing package for Laravel artisans that removes boilerplate and jargon, letting you model your domain by what happens—verbs, not nouns. Persist events, rebuild state, and ship faster with a clean, expressive API designed to make event sourcing feel natural in PHP.
Install and first look
composer require hirethunk/verbs
php artisan verbs:install # publishes config/verbs.php + the event-store migration
php artisan migrate
Verbs keeps its own tables (an append-only event log plus state→event links) alongside your app tables. Commit the published migration into your repo, because you'll almost certainly want to add indexes on state_id, event type, and timestamps later.
Then read the docs (verbs.thunk.dev) in this order: States → Events → Firing & committing → Replay. Skip Wormhole, affordances, and modularization until you have a real domain to model — they only make sense after you've felt the basic loop.
First use case: one aggregate, two events
Start with a single state class and the smallest possible event pair. Don't try to event-source the whole app on day one.
use Thunk\Verbs\State;
use Thunk\Verbs\Event;
class CartState extends State
{
public array $items = [];
public function addItem(string $sku, int $qty): void
{
ItemAdded::fire(cart_id: $this->id, sku: $sku, qty: $qty);
}
}
class ItemAdded extends Event
{
public function __construct(
public string $cart_id,
public string $sku,
public int $qty,
) {}
public function apply(CartState $state): void
{
$state->items[] = ['sku' => $this->sku, 'qty' => $this->qty];
}
}
Day-to-day usage is then a two-liner in a controller or Livewire action:
$cart = CartState::load($cart_id);
$cart->addItem($sku, $qty);
The important mental model: you never write to the state directly. State is a projection built by replaying events; apply() is the only place properties change. Event::fire(...) uses reflection against the constructor, so named arguments are the idiomatic and test-friendly call style.
Thin transport, fat state. Controllers, jobs, and Livewire components should only: load a state, call a method on it, return a response. All invariants live inside state methods that throw before firing, and inside apply(). This keeps the event log meaningful and makes every write path replayable.
One event, many states. A single event can apply() to multiple state classes simply by type-hinting them (apply(OrderState $state), apply(OrderReportState $report)). Use this for denormalized reporting/read-model states instead of adding query-heavy accessors to your core aggregate — and use SingletonState for global counters and dashboards.
Side effects go in handlers, not apply(). Anything with an external effect (mail, HTTP, broadcasting, file writes) belongs in a handler that Verbs skips during replay. apply() must be pure and idempotent — if it sends an email, every replay will send it again. Note fire() is a no-op during replay by design, so side-effect handlers should be structured to be safe under replays and retries.
Validation and affordances for UI. Verbs exposes whether an event can be fired (isAllowed() / isValid() on the pending event, plus affordances on states). Use that to drive button visibility in Blade/Livewire/Filament rather than duplicating business rules in the view layer — this is the single biggest day-to-day win over hand-rolled state machines.
Commit timing. Fired events are buffered for the request and committed at the end (that's why there's explicit Livewire "commit just before render" support). If the very next request depends on the data — redirect-after-post, dispatching a job that reads the event store — use the immediate-commit path rather than relying on the request lifecycle. In tests, commit explicitly before asserting against the database.
Metadata as your audit trail. Enable global event metadata (request id, user id, tenant id, source) in config. It's the cheapest way to answer "who did this and from where" months later, and it travels with every event without polluting domain constructors.
Testing. The package ships event-store fakes and assertion helpers — assert that the event was fired with the right payload rather than asserting on state columns, and assert state projections in separate tests that replay a known event sequence. This mirrors production failure modes (bad payloads) far better than snapshot tests.
Replay and dev tooling. php artisan verbs:replay rebuilds state from the log; replay uses the historical now() for each event, so time-dependent logic behaves as it did originally. Keep a seeded local dataset so replay is fast to iterate on. In development, Wormhole lets you jump the event store forward/backward without polluting real data — good for demoing "what does the order look like after refund?" flows.
Event::fire() has not hit the database yet. Do not query the events table (or a listener that reads it) in the same request and expect to see the row.$state->foo = 'bar' in a controller works until the first replay, then silently diverges. Treat state properties as read-only from the app's perspective. If you need mutation, fire an event.id_type early. Snowflake IDs are time-ordered (great for index locality and ORDER BY), but they're 64-bit integers: cast carefully in Eloquent, and serialize as strings in JSON/JS to avoid precision loss. UUIDs are fine too (state restoration with UUIDs is supported) but lose the ordering benefit. Switching after data exists is painful.now(), but any Carbon::now(), Str::random(), or container lookup inside your own code will return "real" values and produce divergent state. Inject clocks or derive values from event properties.apply() hints a concrete State subclass — auto-discovery depends on it.modular → modularize, dispatcher/hook fixes in 0.9.0). Pin the version, read the changelog before every bump, and budget time to read vendor/hirethunk/verbs — with a small dependent base, you are effectively an early adopter and the source is the most reliable documentation for edge cases.How can I help you explore Laravel packages today?