Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Verbs is a Laravel package that provides event sourcing capabilities. It focuses on developer experience, following
Laravel conventions, and minimizing boilerplate.

## Code Rules

- Never use `private` or `readonly` keywords
- Never use strict types
- Values are `snake_case`
- Anything callable is `camelCase` (even if it's a variable)
- Paths are `kebab-case` (URLs, files, etc)
- Only apply docblocks where they provide useful IDE/static analysis value

## Testing

The project uses Pest PHP for testing. Key testing patterns:

```php
// Use Verbs::fake() to prevent database writes during tests
Verbs::fake();

// Use Verbs::commitImmediately() for integration tests
Verbs::commitImmediately();

// Create test states using factories
CustomerState::factory()->id($id)->create();
```

## High-Level Architecture

### Core Concepts

1. **Events**: Immutable records of what happened in the system
- Located in `src/Events/`
- Must extend `Verbs\Event`
- Can implement `boot()`, `authorize()`, `validate()`, `apply()`, and `handle()` methods

2. **States**: Aggregate event data over time
- Located in `src/States/`
- Must extend `Verbs\State`
- Use `#[StateId]` attribute to specify which event property contains the state ID

3. **Storage**: Three-table structure
- `verb_events`: All events with metadata
- `verb_snapshots`: State snapshots for performance
- `verb_state_events`: Event-to-state mappings

### Key Directories

- `src/`: Main package source code
- `Attributes/`: PHP 8 attributes for configuration
- `Commands/`: Artisan commands
- `Contracts/`: Interfaces
- `Events/`: Base event classes and utilities
- `Facades/`: Laravel facades
- `Models/`: Eloquent models for storage
- `States/`: Base state classes
- `Support/`: Utilities and helpers
- `tests/`: Pest tests organized by feature
- `examples/`: Complete example implementations (Bank, Cart, etc.)

### Important Patterns

1. **Event Lifecycle**: boot -> authorize → validate → apply → handle
2. **Attribute Usage**: `#[StateId]`, `#[AppliesToState]`, `#[AppliesToSingletonState]`
3. **Serialization**: Custom normalizers in `src/Support/Normalization/`
4. **Replay Safety**: Use `#[Once]` annotations and `Verbs::unlessReplaying()` for side effects

## Development Guidelines

- Follow Laravel package conventions
- Use Pest for all new tests
- Run `composer format` before committing
- Ensure compatibility with PHP 8.1+ and Laravel 10.x, 11.x, 12.x
- Test against SQLite, MySQL, and PostgreSQL when modifying storage logic
4 changes: 2 additions & 2 deletions examples/Bank/tests/BankAccountTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Mail;
use Thunk\Verbs\Contracts\TracksState;
use Thunk\Verbs\Examples\Bank\Events\AccountOpened;
use Thunk\Verbs\Examples\Bank\Events\MoneyDeposited;
use Thunk\Verbs\Examples\Bank\Events\MoneyWithdrawn;
Expand All @@ -11,7 +12,6 @@
use Thunk\Verbs\Examples\Bank\Models\User;
use Thunk\Verbs\Examples\Bank\States\AccountState;
use Thunk\Verbs\Facades\Verbs;
use Thunk\Verbs\Lifecycle\StateManager;
use Thunk\Verbs\Models\VerbEvent;

test('a bank account can be opened and interacted with', function () {
Expand Down Expand Up @@ -99,7 +99,7 @@

// We'll also confirm that the state is correctly loaded without snapshots

app(StateManager::class)->reset(include_storage: true);
app(TracksState::class)->reset(include_storage: true);

$account_state = AccountState::load($account->id);
expect($account_state->balance_in_cents)->toBe(100_00);
Expand Down
4 changes: 2 additions & 2 deletions examples/Counter/tests/StateRehydrationTest.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php

use Thunk\Verbs\Contracts\TracksState;
use Thunk\Verbs\Examples\Counter\Events\IncrementCount;
use Thunk\Verbs\Facades\Verbs;
use Thunk\Verbs\Lifecycle\StateManager;
use Thunk\Verbs\Models\VerbEvent;
use Thunk\Verbs\Models\VerbSnapshot;

Expand Down Expand Up @@ -31,7 +31,7 @@

expect(VerbEvent::query()->count())->toBe(1);

app(StateManager::class)->reset(include_storage: true);
app(TracksState::class)->reset(include_storage: true);

$state = IncrementCount::fire()->state();

Expand Down
4 changes: 2 additions & 2 deletions src/Attributes/Autodiscovery/AppliesToChildState.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

use Attribute;
use InvalidArgumentException;
use Thunk\Verbs\Contracts\TracksState;
use Thunk\Verbs\Event;
use Thunk\Verbs\Lifecycle\StateManager;
use Thunk\Verbs\State;

#[Attribute(Attribute::TARGET_CLASS)]
Expand All @@ -31,7 +31,7 @@ public function dependencies(): array
return [$this->parent_type];
}

public function discoverState(Event $event, StateManager $manager): State
public function discoverState(Event $event, TracksState $manager): State
{
$parent = $this->discovered->first(fn (State $state) => $state instanceof $this->parent_type);

Expand Down
4 changes: 2 additions & 2 deletions src/Attributes/Autodiscovery/AppliesToState.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Thunk\Verbs\Contracts\TracksState;
use Thunk\Verbs\Event;
use Thunk\Verbs\Lifecycle\StateManager;
use Thunk\Verbs\SingletonState;
use Thunk\Verbs\State;

Expand All @@ -25,7 +25,7 @@ public function __construct(
}
}

public function discoverState(Event $event, StateManager $manager): State|array
public function discoverState(Event $event, TracksState $manager): State|array
{
if (is_subclass_of($this->state_type, SingletonState::class)) {
return $this->state_type::singleton();
Expand Down
4 changes: 2 additions & 2 deletions src/Attributes/Autodiscovery/StateDiscoveryAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use ReflectionProperty;
use Thunk\Verbs\Contracts\TracksState;
use Thunk\Verbs\Event;
use Thunk\Verbs\Lifecycle\StateManager;
use Thunk\Verbs\State;

abstract class StateDiscoveryAttribute
Expand All @@ -18,7 +18,7 @@ abstract class StateDiscoveryAttribute
/** @var Collection<string, State> */
protected Collection $discovered;

abstract public function discoverState(Event $event, StateManager $manager): State|array;
abstract public function discoverState(Event $event, TracksState $manager): State|array;

public function setProperty(ReflectionProperty $property): static
{
Expand Down
4 changes: 2 additions & 2 deletions src/Attributes/Autodiscovery/StateId.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
use Attribute;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use Thunk\Verbs\Contracts\TracksState;
use Thunk\Verbs\Event;
use Thunk\Verbs\Lifecycle\StateManager;
use Thunk\Verbs\State;

#[Attribute(Attribute::TARGET_PROPERTY)]
Expand All @@ -23,7 +23,7 @@ public function __construct(
}
}

public function discoverState(Event $event, StateManager $manager): State|array
public function discoverState(Event $event, TracksState $manager): State|array
{
$id = $this->property->getValue($event);
$property_name = $this->property->getName();
Expand Down
40 changes: 40 additions & 0 deletions src/Contracts/TracksState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Thunk\Verbs\Contracts;

use Glhd\Bits\Bits;
use Ramsey\Uuid\UuidInterface;
use Symfony\Component\Uid\AbstractUid;
use Thunk\Verbs\State;
use Thunk\Verbs\Support\StateCollection;

interface TracksState
{
public function register(State $state): State;

/**
* @template TState instanceof State
*
* @param class-string<TState> $type
* @return TState|StateCollection<int,TState>
*/
public function load(Bits|UuidInterface|AbstractUid|iterable|int|string $id, string $type): StateCollection|State;

/**
* @template TState of State
*
* @param class-string<State> $type
* @return TState
*/
public function make(Bits|UuidInterface|AbstractUid|int|string $id, string $type): State;

/**
* @template TState instanceof State
*
* @param class-string<TState> $type
* @return TState
*/
public function singleton(string $type): State;

public function prune(): static;
}
29 changes: 16 additions & 13 deletions src/Lifecycle/Broker.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Thunk\Verbs\CommitsImmediately;
use Thunk\Verbs\Contracts\BrokersEvents;
use Thunk\Verbs\Contracts\StoresEvents;
use Thunk\Verbs\Contracts\TracksState;
use Thunk\Verbs\Event;
use Thunk\Verbs\Exceptions\EventNotValid;
use Thunk\Verbs\Lifecycle\Queue as EventQueue;
Expand All @@ -19,7 +20,7 @@ public function __construct(
protected Dispatcher $dispatcher,
protected MetadataManager $metadata,
protected EventQueue $queue,
protected StateManager $states,
protected TracksState $states,
) {}

public function fireIfValid(Event $event): ?Event
Expand All @@ -37,19 +38,19 @@ public function fire(Event $event): ?Event
return null;
}

// NOTE: Any changes to how the dispatcher is called here
// should also be applied to the `replay` method

$this->dispatcher->boot($event);

Guards::for($event)->check();

$this->dispatcher->apply($event);
Lifecycle::run(
event: $event,
phases: new Phases(
Phase::Boot,
Phase::Authorize,
Phase::Validate,
Phase::Apply,
Phase::Fired,
)
);

$this->queue->queue($event);

$this->dispatcher->fired($event);

if ($this->commit_immediately || $event instanceof CommitsImmediately) {
$this->commit();
}
Expand Down Expand Up @@ -94,8 +95,10 @@ public function replay(?callable $beforeEach = null, ?callable $afterEach = null
$beforeEach($event);
}

$this->dispatcher->apply($event);
$this->dispatcher->replay($event);
Lifecycle::run(
event: $event,
phases: new Phases(Phase::Apply, Phase::Replay)
);

if ($afterEach) {
$afterEach($event);
Expand Down
54 changes: 54 additions & 0 deletions src/Lifecycle/Lifecycle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Thunk\Verbs\Lifecycle;

use Illuminate\Container\Container;
use Thunk\Verbs\Event;

class Lifecycle
{
public static function run(Event $event, Phases $phases): Event
{
$dispatcher = Container::getInstance()->make(Dispatcher::class);

return (new static($dispatcher, $event, $phases))->handle();
}

public function __construct(
public Dispatcher $dispatcher,
public Event $event,
public Phases $phases,
) {}

public function handle(): Event
{
if ($this->phases->has(Phase::Boot)) {
$this->dispatcher->boot($this->event);
}

$guards = null;
if ($this->phases->has(Phase::Authorize)) {
$guards ??= Guards::for($this->event);
$guards->authorize();
}

if ($this->phases->has(Phase::Validate)) {
$guards ??= Guards::for($this->event);
$guards->validate();
}

if ($this->phases->has(Phase::Apply)) {
$this->dispatcher->apply($this->event);
}

if ($this->phases->has(Phase::Handle)) {
$this->dispatcher->handle($this->event);
}

if ($this->phases->has(Phase::Fired)) {
$this->dispatcher->fired($this->event);
}

return $this->event;
}
}
24 changes: 24 additions & 0 deletions src/Lifecycle/Phases.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Thunk\Verbs\Lifecycle;

class Phases
{
/** @var Phase[] */
public array $phases;

public static function all(): static
{
return new static(...Phase::cases());
}

public function __construct(Phase ...$phases)
{
$this->phases = $phases;
}

public function has(Phase $phase): bool
{
return in_array($phase, $this->phases);
}
}
4 changes: 2 additions & 2 deletions src/SingletonState.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use BadMethodCallException;
use RuntimeException;
use Thunk\Verbs\Lifecycle\StateManager;
use Thunk\Verbs\Contracts\TracksState;
use Thunk\Verbs\Support\StateCollection;

abstract class SingletonState extends State
Expand Down Expand Up @@ -36,7 +36,7 @@ public static function loadByKey($from): static|StateCollection

public static function singleton(): static
{
return app(StateManager::class)->singleton(static::class);
return app(TracksState::class)->singleton(static::class);
}

public function resolveRouteBinding($value, $field = null)
Expand Down
Loading
Loading