Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Verbs Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Deferred writes surprise people. Within a request, 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.
  • Never mutate state from outside an event. $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.
  • Don't put Eloquent models in event payloads. Verbs deliberately prevents model serialization. Use scalar IDs, arrays, or small DTOs; anything ambiguous will bite you at deserialization time, long after the event was written.
  • Event payloads are a public contract. Renaming an event class or a constructor property breaks replay of historical rows. Prefer adding a new event version over mutating an old one; state aliases exist to help when you do rename state classes, but plan for it.
  • Choose 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.
  • Replay safety has edges. Verbs gives you historical 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.
  • Be explicit with state type-hints. Recent versions fail loudly on undeclared state type-hints (older ones silently skipped), so make sure every apply() hints a concrete State subclass — auto-discovery depends on it.
  • JSON column quirks. Events and metadata are stored as JSON; MySQL JSON ordering/indexing differences caused real bugs in early versions (fixed in 0.0.8.1). Use a recent patch release, and pin your DB version in CI. Postgres has been supported since 0.0.7.
  • Reconstruction cost grows with events per state. Loading a state replays all its events. Keep aggregates short-lived (an order, a cart, a ticket) and push long-lived analytics into dedicated reporting states or read models rather than one giant state.
  • Pre-1.0 churn is real. The 0.x line has shipped breaking changes and renames (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.
  • Debugging starting points: inspect the raw event rows to confirm what was actually written; check that the event's ID properties match the state you expect to load; check commit timing; then check whether an exception was swallowed by the deferred commit at response time.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox