Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5389f24
Inject row id into event data before deserializing
ChrisThompsonTLDR Feb 11, 2026
d9fc70e
Sync composer.json from upstream for Laravel 12 support
ChrisThompsonTLDR Feb 11, 2026
9d96f91
Add Laravel 12 to illuminate/contracts, keep internachi/modular
ChrisThompsonTLDR Feb 11, 2026
22b9129
Widen laravel/prompts for Laravel 12 compat
ChrisThompsonTLDR Feb 11, 2026
470ed03
Add test for VerbEvent deserialization when data lacks id
ChrisThompsonTLDR Feb 11, 2026
ad6402e
Fix second test description
ChrisThompsonTLDR Feb 11, 2026
021ad8b
Fix data column - use json_encode for insert
ChrisThompsonTLDR Feb 11, 2026
ff7dff2
Add serialize_event_id config option
ChrisThompsonTLDR Feb 11, 2026
8ece80b
Make Event id serialization configurable via serialize_event_id
ChrisThompsonTLDR Feb 11, 2026
612b9c4
Inject row id only when data lacks id (for backward compat)
ChrisThompsonTLDR Feb 11, 2026
e572554
Always use row id as source of truth when deserializing
ChrisThompsonTLDR Feb 11, 2026
2422696
Add tests for serialize_event_id config
ChrisThompsonTLDR Feb 11, 2026
9994e61
Inject id before deserializing when hydrating Event from fire args
ChrisThompsonTLDR Feb 11, 2026
2d8ac3a
Fix ReflectionUnionType::isBuiltin() error when deserializing propert…
ChrisThompsonTLDR Feb 11, 2026
e1181cd
Fix ReflectionUnionType::getName() error in EventStateRegistry::findA…
ChrisThompsonTLDR Feb 11, 2026
a309efd
Skip denormalization when value is already correct type (fix DateTime…
ChrisThompsonTLDR Feb 11, 2026
0365dd0
Enhance EventStateRegistry to support ReflectionIntersectionType and …
Feb 11, 2026
e0fd991
Merge upstream/main - resolve conflicts, use internachi/modularize
Feb 11, 2026
18b9ade
Remove EventStateRegistry and NormalizeToPropertiesAndClassName chang…
Feb 11, 2026
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
18 changes: 17 additions & 1 deletion config/verbs.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@
*/
'id_type' => env('VERBS_ID_TYPE', 'snowflake'),

/*
|--------------------------------------------------------------------------
| Serialize Event ID
|--------------------------------------------------------------------------
|
| By default, the Event's `id` is excluded from the serialized JSON stored
| in verb_events.data (it's redundant with the row's primary key). Some
| event types (e.g. SerializedByVerbs) require `id` for deserialization.
| VerbEvent injects the row id when loading in that case.
|
| Set to true to include `id` in stored data so events deserialize without
| injection. Useful for new installations or after replaying events.
|
*/
'serialize_event_id' => env('VERBS_SERIALIZE_EVENT_ID', false),

/*
|--------------------------------------------------------------------------
| Normalizers
Expand Down Expand Up @@ -118,7 +134,7 @@
|
| By default, Verbs will auto-commit events to the event store for you:
|
| - at the end of every request (before returning a response)
| - at the end of every request (before returning the response)
| - at the end of every console command
| - at the end of every queued job
|
Expand Down
10 changes: 8 additions & 2 deletions src/Models/VerbEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,14 @@ public function getTable()

public function event(): Event
{
$this->event ??= app(Serializer::class)->deserialize($this->type, $this->data);
$this->event->id = $this->id;
if ($this->event !== null) {
return $this->event;
}

// When serialize_event_id is false (default), stored data lacks id but some event types
// require it for deserialization. Always use row id as source of truth.
$data = array_merge($this->data ?? [], ['id' => $this->id]);
$this->event = app(Serializer::class)->deserialize($this->type, $data);

app(MetadataManager::class)->setEphemeral($this->event, 'created_at', $this->created_at);

Expand Down
8 changes: 8 additions & 0 deletions src/Support/PendingEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ public function shouldFire(): static

public function hydrate(array $data): static
{
// When firing with args, we deserialize from the passed data. Events require an id,
// but we don't have one yet when creating fresh. Inject it before deserializing.
$target = $this->event;
$targetClass = is_string($target) ? $target : $target::class;
if (is_subclass_of($targetClass, Event::class) && ! array_key_exists('id', $data)) {
$data = array_merge($data, ['id' => snowflake_id()]);
}

$this->event = app(Serializer::class)->deserialize($this->event, $data, call_constructor: true);

app(MetadataManager::class)->initialize($this->event);
Expand Down
3 changes: 2 additions & 1 deletion src/Support/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ protected function serializationContext(object $target): array
$context = [...$this->context];

if ($target instanceof Event) {
$context[AbstractNormalizer::IGNORED_ATTRIBUTES] = ['id'];
$ignored = config('verbs.serialize_event_id', false) ? [] : ['id'];
$context[AbstractNormalizer::IGNORED_ATTRIBUTES] = $ignored;
}

if ($target instanceof State) {
Expand Down
74 changes: 74 additions & 0 deletions tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

use Thunk\Verbs\Event;
use Thunk\Verbs\Models\VerbEvent;
use Thunk\Verbs\Support\Serializer;

/**
* When serialize_event_id is false (default), the Serializer excludes id from stored data.
* VerbEvent::event() injects the row id so Events requiring id (e.g. SerializedByVerbs) deserialize correctly.
*/
it('deserializes event correctly when stored data lacks id by injecting row id', function () {
$rowId = snowflake_id();
$eventType = VerbEventDeserializesWithoutIdInDataTestEvent::class;

VerbEvent::insert([
'id' => $rowId,
'type' => $eventType,
'data' => json_encode([]), // Simulates serialized payload without id (default)
'metadata' => '{}',
'created_at' => now(),
]);

$verbEvent = VerbEvent::find($rowId);
expect($verbEvent)->not->toBeNull();

$event = $verbEvent->event();

expect($event)
->toBeInstanceOf(Event::class)
->and($event->id)->toBe($rowId);
});

it('overwrites id in data with row id so row remains source of truth', function () {
$rowId = snowflake_id();
$staleIdInData = snowflake_id() + 1;
$eventType = VerbEventDeserializesWithoutIdInDataTestEvent::class;

VerbEvent::insert([
'id' => $rowId,
'type' => $eventType,
'data' => json_encode(['id' => $staleIdInData]),
'metadata' => '{}',
'created_at' => now(),
]);

$verbEvent = VerbEvent::find($rowId);
$event = $verbEvent->event();

expect($event->id)->toBe($rowId);
});

it('excludes event id from serialization when serialize_event_id is false', function () {
config(['verbs.serialize_event_id' => false]);

$event = new VerbEventDeserializesWithoutIdInDataTestEvent;
$event->id = snowflake_id();

$serialized = app(Serializer::class)->serialize($event);

expect($serialized)->not->toContain((string) $event->id);
});

it('includes event id in serialization when serialize_event_id is true', function () {
config(['verbs.serialize_event_id' => true]);

$event = new VerbEventDeserializesWithoutIdInDataTestEvent;
$event->id = $id = snowflake_id();

$serialized = app(Serializer::class)->serialize($event);

expect($serialized)->toContain((string) $id);
});

class VerbEventDeserializesWithoutIdInDataTestEvent extends Event {}