From 5389f24190d1d30c709fc90078ffffd115485b34 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 04:24:15 -0700 Subject: [PATCH 01/18] Inject row id into event data before deserializing Serializer excludes Event::id during serialization (IGNORED_ATTRIBUTES) but SerializedByVerbs events require it for deserialization. Merge the verb_events row id into the data so the event reconstructs with the correct id instead of failing or generating a new one. --- src/Models/VerbEvent.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Models/VerbEvent.php b/src/Models/VerbEvent.php index 63e20e6d..2c98acb5 100644 --- a/src/Models/VerbEvent.php +++ b/src/Models/VerbEvent.php @@ -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; + } + + // Serializer excludes Event::id during serialization, but SerializedByVerbs events + // require it for deserialization. Inject the row id so the event reconstructs correctly. + $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); From d9fc70e993cdf8663e6a48d9276d97e5d20e26c0 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 04:26:33 -0700 Subject: [PATCH 02/18] Sync composer.json from upstream for Laravel 12 support --- composer.json | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 01493b4c..3b5fca96 100644 --- a/composer.json +++ b/composer.json @@ -19,26 +19,27 @@ "require": { "php": ">=8.1", "glhd/bits": ">=0.3.0", - "illuminate/contracts": "^10.34|^11.0", - "internachi/modular": "^2.0", - "laravel/prompts": "^0.1.15", + "illuminate/contracts": "^10.34|^11.0|^12.0", + "internachi/modularize": "^1.1.0", + "laravel/prompts": "^0.1.15|^0.2|^0.3", "spatie/laravel-package-tools": "^1.14.0", "symfony/property-access": "^6.2|^7.0", "symfony/serializer": "^6.3|^7.0" }, "require-dev": { - "brick/money": "^0.8.1", + "brick/money": "^0.8.1|^0.10", + "internachi/modular": "^2.3", "laravel/pint": "^1.13", "mockery/mockery": "^1.6", "nunomaduro/collision": "^7.10|^8.1", - "orchestra/testbench": "^8.14|^9.1.0", - "orchestra/testbench-core": "^8.14|^9.1.4", - "pestphp/pest": "^2.24", - "pestphp/pest-plugin-arch": "^2.4", - "pestphp/pest-plugin-laravel": "^2.2", - "pestphp/pest-plugin-watch": "^2.0", - "phpunit/phpunit": "^10.5", - "projektgopher/whisky": "^0.5.1" + "orchestra/testbench": "^8.14|^9.1.0|^10.0", + "orchestra/testbench-core": "^8.14|^9.1.4|^10.0", + "pestphp/pest": "^2.24|^3.7", + "pestphp/pest-plugin-arch": "^2.4|^3.0", + "pestphp/pest-plugin-laravel": "^2.2|^3.1", + "pestphp/pest-plugin-watch": "^2.0|^3.0", + "phpunit/phpunit": "^10.5|^11.5.3", + "projektgopher/whisky": "^0.5.1|^0.7" }, "autoload": { "psr-4": { @@ -49,6 +50,9 @@ "autoload-dev": { "psr-4": { "Thunk\\Verbs\\Tests\\": "tests/", + "Thunk\\Verbs\\Examples\\Cart\\": "examples/Cart/src/", + "Thunk\\Verbs\\Examples\\Cart\\Tests\\": "examples/Cart/tests/", + "Thunk\\Verbs\\Examples\\Cart\\Database\\Factories\\": "examples/Cart/database/factories/", "Thunk\\Verbs\\Examples\\Counter\\": "examples/Counter/src/", "Thunk\\Verbs\\Examples\\Counter\\Tests\\": "examples/Counter/tests/", "Thunk\\Verbs\\Examples\\Counter\\Database\\Factories\\": "examples/Counter/database/factories/", From 9d96f91efd68bcf844a52b1a13a0e4445487ff97 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 04:27:57 -0700 Subject: [PATCH 03/18] Add Laravel 12 to illuminate/contracts, keep internachi/modular --- composer.json | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 3b5fca96..c4148f3a 100644 --- a/composer.json +++ b/composer.json @@ -20,26 +20,25 @@ "php": ">=8.1", "glhd/bits": ">=0.3.0", "illuminate/contracts": "^10.34|^11.0|^12.0", - "internachi/modularize": "^1.1.0", - "laravel/prompts": "^0.1.15|^0.2|^0.3", + "internachi/modular": "^2.0", + "laravel/prompts": "^0.1.15", "spatie/laravel-package-tools": "^1.14.0", "symfony/property-access": "^6.2|^7.0", "symfony/serializer": "^6.3|^7.0" }, "require-dev": { - "brick/money": "^0.8.1|^0.10", - "internachi/modular": "^2.3", + "brick/money": "^0.8.1", "laravel/pint": "^1.13", "mockery/mockery": "^1.6", "nunomaduro/collision": "^7.10|^8.1", - "orchestra/testbench": "^8.14|^9.1.0|^10.0", - "orchestra/testbench-core": "^8.14|^9.1.4|^10.0", - "pestphp/pest": "^2.24|^3.7", - "pestphp/pest-plugin-arch": "^2.4|^3.0", - "pestphp/pest-plugin-laravel": "^2.2|^3.1", - "pestphp/pest-plugin-watch": "^2.0|^3.0", - "phpunit/phpunit": "^10.5|^11.5.3", - "projektgopher/whisky": "^0.5.1|^0.7" + "orchestra/testbench": "^8.14|^9.1.0", + "orchestra/testbench-core": "^8.14|^9.1.4", + "pestphp/pest": "^2.24", + "pestphp/pest-plugin-arch": "^2.4", + "pestphp/pest-plugin-laravel": "^2.2", + "pestphp/pest-plugin-watch": "^2.0", + "phpunit/phpunit": "^10.5", + "projektgopher/whisky": "^0.5.1" }, "autoload": { "psr-4": { @@ -50,9 +49,6 @@ "autoload-dev": { "psr-4": { "Thunk\\Verbs\\Tests\\": "tests/", - "Thunk\\Verbs\\Examples\\Cart\\": "examples/Cart/src/", - "Thunk\\Verbs\\Examples\\Cart\\Tests\\": "examples/Cart/tests/", - "Thunk\\Verbs\\Examples\\Cart\\Database\\Factories\\": "examples/Cart/database/factories/", "Thunk\\Verbs\\Examples\\Counter\\": "examples/Counter/src/", "Thunk\\Verbs\\Examples\\Counter\\Tests\\": "examples/Counter/tests/", "Thunk\\Verbs\\Examples\\Counter\\Database\\Factories\\": "examples/Counter/database/factories/", From 22b9129063a839eda46b774129c939f8e174cca9 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 04:28:21 -0700 Subject: [PATCH 04/18] Widen laravel/prompts for Laravel 12 compat --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c4148f3a..e2bc3a0d 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "glhd/bits": ">=0.3.0", "illuminate/contracts": "^10.34|^11.0|^12.0", "internachi/modular": "^2.0", - "laravel/prompts": "^0.1.15", + "laravel/prompts": "^0.1.15|^0.2|^0.3", "spatie/laravel-package-tools": "^1.14.0", "symfony/property-access": "^6.2|^7.0", "symfony/serializer": "^6.3|^7.0" From 470ed03a615fe7e2188b0657bb92a9619f12372d Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 04:32:53 -0700 Subject: [PATCH 05/18] Add test for VerbEvent deserialization when data lacks id --- ...rbEventDeserializesWithoutIdInDataTest.php | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php diff --git a/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php new file mode 100644 index 00000000..fc75af3c --- /dev/null +++ b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php @@ -0,0 +1,54 @@ + $rowId, + 'type' => $eventType, + 'data' => [], // Simulates serialized payload without id (Serializer excludes it) + '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('uses existing id in data when present without overwriting with row id', function () { + $rowId = snowflake_id(); + $existingId = snowflake_id() + 1; // Different from row + $eventType = VerbEventDeserializesWithoutIdInDataTestEvent::class; + + VerbEvent::insert([ + 'id' => $rowId, + 'type' => $eventType, + 'data' => ['id' => $existingId], // Data already has id + 'metadata' => '{}', + 'created_at' => now(), + ]); + + $verbEvent = VerbEvent::find($rowId); + $event = $verbEvent->event(); + + // array_merge puts our 'id' second, so row id overwrites. The fix uses array_merge($data, ['id' => $this->id]) + // so we intentionally overwrite any id in data with the row id - the row id is the source of truth. + expect($event->id)->toBe($rowId); +}); + +class VerbEventDeserializesWithoutIdInDataTestEvent extends Event {} From ad6402efd294ada3d34b1e6edd5b42ced62ac2d7 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 04:33:13 -0700 Subject: [PATCH 06/18] Fix second test description --- tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php index fc75af3c..74c852ea 100644 --- a/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php +++ b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php @@ -30,15 +30,15 @@ ->and($event->id)->toBe($rowId); }); -it('uses existing id in data when present without overwriting with row id', function () { +it('overwrites id in data with row id so row remains source of truth', function () { $rowId = snowflake_id(); - $existingId = snowflake_id() + 1; // Different from row + $staleIdInData = snowflake_id() + 1; $eventType = VerbEventDeserializesWithoutIdInDataTestEvent::class; VerbEvent::insert([ 'id' => $rowId, 'type' => $eventType, - 'data' => ['id' => $existingId], // Data already has id + 'data' => ['id' => $staleIdInData], 'metadata' => '{}', 'created_at' => now(), ]); @@ -46,8 +46,6 @@ $verbEvent = VerbEvent::find($rowId); $event = $verbEvent->event(); - // array_merge puts our 'id' second, so row id overwrites. The fix uses array_merge($data, ['id' => $this->id]) - // so we intentionally overwrite any id in data with the row id - the row id is the source of truth. expect($event->id)->toBe($rowId); }); From 021ad8b064f4e78bdbe1bafaf039322341512be3 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 04:33:52 -0700 Subject: [PATCH 07/18] Fix data column - use json_encode for insert --- tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php index 74c852ea..d2f5ecf4 100644 --- a/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php +++ b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php @@ -15,7 +15,7 @@ VerbEvent::insert([ 'id' => $rowId, 'type' => $eventType, - 'data' => [], // Simulates serialized payload without id (Serializer excludes it) + 'data' => json_encode([]), // Simulates serialized payload without id (Serializer excludes it) 'metadata' => '{}', 'created_at' => now(), ]); @@ -38,7 +38,7 @@ VerbEvent::insert([ 'id' => $rowId, 'type' => $eventType, - 'data' => ['id' => $staleIdInData], + 'data' => json_encode(['id' => $staleIdInData]), 'metadata' => '{}', 'created_at' => now(), ]); From ff7dff20ad41a7d12205f3b390752bfad36d26bb Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 06:01:39 -0700 Subject: [PATCH 08/18] Add serialize_event_id config option --- config/verbs.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/config/verbs.php b/config/verbs.php index 8095ad2e..25c59296 100644 --- a/config/verbs.php +++ b/config/verbs.php @@ -28,6 +28,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 @@ -116,7 +132,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 | From 8ece80b9866b7fc629dd46b183c689bb2e821577 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 06:01:51 -0700 Subject: [PATCH 09/18] Make Event id serialization configurable via serialize_event_id --- src/Support/Serializer.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Support/Serializer.php b/src/Support/Serializer.php index d8fa8709..661712ef 100644 --- a/src/Support/Serializer.php +++ b/src/Support/Serializer.php @@ -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) { From 612b9c41b915fa65d76fbadd67a44e0d6f641481 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 06:02:03 -0700 Subject: [PATCH 10/18] Inject row id only when data lacks id (for backward compat) --- src/Models/VerbEvent.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Models/VerbEvent.php b/src/Models/VerbEvent.php index 2c98acb5..daa119b1 100644 --- a/src/Models/VerbEvent.php +++ b/src/Models/VerbEvent.php @@ -44,9 +44,12 @@ public function event(): Event return $this->event; } - // Serializer excludes Event::id during serialization, but SerializedByVerbs events - // require it for deserialization. Inject the row id so the event reconstructs correctly. - $data = array_merge($this->data ?? [], ['id' => $this->id]); + // When serialize_event_id is false, stored data lacks id but some event types require it. + // Inject the row id when missing so those events deserialize correctly (backward compat). + $data = $this->data ?? []; + if (! array_key_exists('id', $data)) { + $data['id'] = $this->id; + } $this->event = app(Serializer::class)->deserialize($this->type, $data); app(MetadataManager::class)->setEphemeral($this->event, 'created_at', $this->created_at); From e5725545c573f943b28c433e9dd8d4a038ab26f1 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 06:02:26 -0700 Subject: [PATCH 11/18] Always use row id as source of truth when deserializing --- src/Models/VerbEvent.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Models/VerbEvent.php b/src/Models/VerbEvent.php index daa119b1..dc97facc 100644 --- a/src/Models/VerbEvent.php +++ b/src/Models/VerbEvent.php @@ -44,12 +44,9 @@ public function event(): Event return $this->event; } - // When serialize_event_id is false, stored data lacks id but some event types require it. - // Inject the row id when missing so those events deserialize correctly (backward compat). - $data = $this->data ?? []; - if (! array_key_exists('id', $data)) { - $data['id'] = $this->id; - } + // 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); From 24226965cf87b44505bd69c384ea8ecb0585658e Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 06:02:44 -0700 Subject: [PATCH 12/18] Add tests for serialize_event_id config --- ...rbEventDeserializesWithoutIdInDataTest.php | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php index d2f5ecf4..7f54e670 100644 --- a/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php +++ b/tests/Unit/VerbEventDeserializesWithoutIdInDataTest.php @@ -2,11 +2,11 @@ use Thunk\Verbs\Event; use Thunk\Verbs\Models\VerbEvent; +use Thunk\Verbs\Support\Serializer; /** - * When the Serializer stores Events, it excludes the `id` attribute (see Serializer::serializationContext). - * Stored data therefore lacks `id`. VerbEvent::event() must inject the row id before deserializing - * so that Events requiring `id` (e.g. SerializedByVerbs) can be reconstructed correctly. + * 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(); @@ -15,7 +15,7 @@ VerbEvent::insert([ 'id' => $rowId, 'type' => $eventType, - 'data' => json_encode([]), // Simulates serialized payload without id (Serializer excludes it) + 'data' => json_encode([]), // Simulates serialized payload without id (default) 'metadata' => '{}', 'created_at' => now(), ]); @@ -49,4 +49,26 @@ 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 {} From 9994e61dc22c53fa0597b7eb08a87c1d5d023c6c Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 06:20:35 -0700 Subject: [PATCH 13/18] Inject id before deserializing when hydrating Event from fire args Fixes FsmTransitioned deserialization error when firing with named args - Events require an id but we don't have one yet when creating fresh. --- src/Support/PendingEvent.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Support/PendingEvent.php b/src/Support/PendingEvent.php index 15bab288..d7ee5550 100644 --- a/src/Support/PendingEvent.php +++ b/src/Support/PendingEvent.php @@ -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); From 2d8ac3ae2fa4c00cb0c304744a95b75259934e81 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 06:26:47 -0700 Subject: [PATCH 14/18] Fix ReflectionUnionType::isBuiltin() error when deserializing properties with union types ReflectionUnionType (e.g. FsmStateEnum|string|null) does not have isBuiltin() or getName() - only ReflectionNamedType does. Guard with instanceof before calling. --- .../Normalization/NormalizeToPropertiesAndClassName.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php index 1fc8f58f..e1b5cecf 100644 --- a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php +++ b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php @@ -6,6 +6,7 @@ use Illuminate\Support\Collection; use InvalidArgumentException; use ReflectionClass; +use ReflectionNamedType; use ReflectionProperty; use RuntimeException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -53,8 +54,9 @@ class_basename(static::class) foreach (Arr::except($data, ['fqcn']) as $key => $value) { $property = $reflect->getProperty($key); - if ($property->hasType() && ! $property->getType()->isBuiltin() && $value !== null) { - $value = $denormalizer->denormalize($value, $property->getType()->getName()); + $type = $property->getType(); + if ($type instanceof ReflectionNamedType && ! $type->isBuiltin() && $value !== null) { + $value = $denormalizer->denormalize($value, $type->getName()); } $property->setValue($instance, $value); From e1181cd1d6fbfcfb96e90397fdf76cc93ffc8b93 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 06:34:48 -0700 Subject: [PATCH 15/18] Fix ReflectionUnionType::getName() error in EventStateRegistry::findAllProperties ReflectionUnionType does not have getName() - only ReflectionNamedType does. Guard with instanceof before calling; union-typed properties cannot be State. --- src/Support/EventStateRegistry.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 3e3eabf4..3cd74aa3 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use ReflectionAttribute; use ReflectionClass; +use ReflectionNamedType; use ReflectionProperty; use Thunk\Verbs\Attributes\Autodiscovery\StateDiscoveryAttribute; use Thunk\Verbs\Event; @@ -121,9 +122,9 @@ protected function findAllProperties(Event $target): Collection return collect($reflect->getProperties(ReflectionProperty::IS_PUBLIC)) ->filter(function (ReflectionProperty $property) use ($target) { $propertyType = $property->getType(); - $propertyTypeName = $propertyType?->getName(); + $propertyTypeName = $propertyType instanceof ReflectionNamedType ? $propertyType->getName() : null; - if ($propertyType->allowsNull() && $property->getValue($target) === null) { + if ($propertyType && $propertyType->allowsNull() && $property->getValue($target) === null) { return false; } From a309efdfb58f4dbe1700d29d2271d20f632b1b23 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Wed, 11 Feb 2026 07:00:23 -0700 Subject: [PATCH 16/18] Skip denormalization when value is already correct type (fix DateTimeInterface/Carbon) --- .../Normalization/NormalizeToPropertiesAndClassName.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php index e1b5cecf..95e39ed5 100644 --- a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php +++ b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php @@ -56,7 +56,11 @@ class_basename(static::class) $type = $property->getType(); if ($type instanceof ReflectionNamedType && ! $type->isBuiltin() && $value !== null) { - $value = $denormalizer->denormalize($value, $type->getName()); + $typeName = $type->getName(); + // Skip denormalization when value is already the correct type (e.g. Carbon for DateTimeInterface) + if (! (is_object($value) && $value instanceof $typeName)) { + $value = $denormalizer->denormalize($value, $typeName); + } } $property->setValue($instance, $value); From 0365dd002917a2565ad20ae4593a0bb678baef48 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 11 Feb 2026 09:11:04 -0700 Subject: [PATCH 17/18] Enhance EventStateRegistry to support ReflectionIntersectionType and ReflectionUnionType Updated the EventStateRegistry class to handle ReflectionIntersectionType and ReflectionUnionType correctly. Added necessary guards to prevent errors when dealing with union-typed properties in the findAllProperties method. This improves the robustness of state discovery for events. --- src/Support/EventStateRegistry.php | 302 ++++++++++++++++------------- 1 file changed, 165 insertions(+), 137 deletions(-) diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index 3cd74aa3..f05a4028 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -1,137 +1,165 @@ -push(...$this->getProperties($event)); - - foreach ($this->getAttributes($event) as $attribute) { - // If there are state dependencies that the attribute relies on that we haven't already - // loaded, then we'll have to defer it until all other dependencies are loaded. Otherwise, - // we can load the state with what we already have. - if (! $discovered->keys()->has($attribute->dependencies())) { - $deferred->push($attribute); - } else { - $this->discoverAndPushState($attribute, $event, $discovered); - } - } - - // Once we've loaded everything else, try to discover any deferred attributes - $deferred->each(fn (StateDiscoveryAttribute $attr) => $this->discoverAndPushState($attr, $event, $discovered)); - - return $discovered; - } - - /** @return Collection */ - protected function discoverAndPushState(StateDiscoveryAttribute $attribute, Event $target, StateCollection $discovered): Collection - { - $states = Arr::wrap( - $attribute - ->setDiscoveredState($discovered) - ->discoverState($target, $this->manager) - ); - - $discovered->push(...$states); - - if (count($states) > 0 && $alias = $attribute->getAlias()) { - if (count($states) > 1) { - throw new InvalidArgumentException('You cannot provide an alias for an array of states.'); - } - - $discovered->alias($alias, $states[0]); - } - - return $discovered; - } - - /** @return Collection */ - protected function getAttributes(Event $target): Collection - { - return $this->discovered_attributes[$target::class] ??= $this->findAllAttributes($target); - } - - /** @return Collection */ - protected function findAllAttributes(Event $target): Collection - { - $reflect = new ReflectionClass($target); - - return $this->findClassAttributes($reflect)->merge($this->findPropertyAttributes($reflect)); - } - - /** @return Collection */ - protected function findClassAttributes(ReflectionClass $reflect): Collection - { - return collect($reflect->getAttributes()) - ->filter($this->isStateDiscoveryAttribute(...)) - ->map(fn (ReflectionAttribute $attribute) => $attribute->newInstance()); - } - - /** @return Collection */ - protected function findPropertyAttributes(ReflectionClass $reflect): Collection - { - return collect($reflect->getProperties(ReflectionProperty::IS_PUBLIC)) - ->flatMap(fn (ReflectionProperty $property) => collect($property->getAttributes()) - ->filter($this->isStateDiscoveryAttribute(...)) - ->map(fn (ReflectionAttribute $attribute) => $attribute->newInstance()) - ->map(fn (StateDiscoveryAttribute $attribute) => $attribute->setProperty($property))); - } - - protected function isStateDiscoveryAttribute(ReflectionAttribute $attribute): bool - { - return is_a($attribute->getName(), StateDiscoveryAttribute::class, true); - } - - /** @return Collection */ - protected function getProperties(Event $target): Collection - { - return $this->discovered_properties[$target::class][$target->id] ??= $this->findAllProperties($target); - } - - /** @return Collection */ - protected function findAllProperties(Event $target): Collection - { - $reflect = new ReflectionClass($target); - - return collect($reflect->getProperties(ReflectionProperty::IS_PUBLIC)) - ->filter(function (ReflectionProperty $property) use ($target) { - $propertyType = $property->getType(); - $propertyTypeName = $propertyType instanceof ReflectionNamedType ? $propertyType->getName() : null; - - if ($propertyType && $propertyType->allowsNull() && $property->getValue($target) === null) { - return false; - } - - return $propertyTypeName - && (is_subclass_of($propertyTypeName, State::class) || $propertyTypeName === State::class || $propertyTypeName === StateCollection::class); - }) - ->map(fn (ReflectionProperty $property) => $property->getValue($target)) - ->flatten(); - } -} +push(...$this->getProperties($event)); + + foreach ($this->getAttributes($event) as $attribute) { + // If there are state dependencies that the attribute relies on that we haven't already + // loaded, then we'll have to defer it until all other dependencies are loaded. Otherwise, + // we can load the state with what we already have. + if (! $discovered->keys()->has($attribute->dependencies())) { + $deferred->push($attribute); + } else { + $this->discoverAndPushState($attribute, $event, $discovered); + } + } + + // Once we've loaded everything else, try to discover any deferred attributes + $deferred->each(fn (StateDiscoveryAttribute $attr) => $this->discoverAndPushState($attr, $event, $discovered)); + + return $discovered; + } + + /** @return Collection */ + protected function discoverAndPushState(StateDiscoveryAttribute $attribute, Event $target, StateCollection $discovered): Collection + { + $states = Arr::wrap( + $attribute + ->setDiscoveredState($discovered) + ->discoverState($target, $this->manager) + ); + + $discovered->push(...$states); + + if (count($states) > 0 && $alias = $attribute->getAlias()) { + if (count($states) > 1) { + throw new InvalidArgumentException('You cannot provide an alias for an array of states.'); + } + + $discovered->alias($alias, $states[0]); + } + + return $discovered; + } + + /** @return Collection */ + protected function getAttributes(Event $target): Collection + { + return $this->discovered_attributes[$target::class] ??= $this->findAllAttributes($target); + } + + /** @return Collection */ + protected function findAllAttributes(Event $target): Collection + { + $reflect = new ReflectionClass($target); + + return $this->findClassAttributes($reflect)->merge($this->findPropertyAttributes($reflect)); + } + + /** @return Collection */ + protected function findClassAttributes(ReflectionClass $reflect): Collection + { + return collect($reflect->getAttributes()) + ->filter($this->isStateDiscoveryAttribute(...)) + ->map(fn (ReflectionAttribute $attribute) => $attribute->newInstance()); + } + + /** @return Collection */ + protected function findPropertyAttributes(ReflectionClass $reflect): Collection + { + return collect($reflect->getProperties(ReflectionProperty::IS_PUBLIC)) + ->flatMap(fn (ReflectionProperty $property) => collect($property->getAttributes()) + ->filter($this->isStateDiscoveryAttribute(...)) + ->map(fn (ReflectionAttribute $attribute) => $attribute->newInstance()) + ->map(fn (StateDiscoveryAttribute $attribute) => $attribute->setProperty($property))); + } + + protected function isStateDiscoveryAttribute(ReflectionAttribute $attribute): bool + { + return is_a($attribute->getName(), StateDiscoveryAttribute::class, true); + } + + /** @return Collection */ + protected function getProperties(Event $target): Collection + { + return $this->discovered_properties[$target::class][$target->id] ??= $this->findAllProperties($target); + } + + /** @return Collection */ + protected function findAllProperties(Event $target): Collection + { + $reflect = new ReflectionClass($target); + + return collect($reflect->getProperties(ReflectionProperty::IS_PUBLIC)) + ->filter(function (ReflectionProperty $property) use ($target) { + $property_type = $property->getType(); + + if ( + $property_type + && $property_type instanceof ReflectionNamedType + && $property_type->allowsNull() + && $property->getValue($target) === null + ) { + return false; + } + + if ($property_type === null) { + return false; + } + + $all_property_types = match ($property_type::class) { + ReflectionUnionType::class, ReflectionIntersectionType::class => $property_type->getTypes(), + default => [$property_type], + }; + + foreach ($all_property_types as $type) { + $name = $type?->getName(); + if ($name && $this->isStateClass($name)) { + return true; + } + } + + return false; + }) + ->map(fn (ReflectionProperty $property) => $property->getValue($target)) + ->flatten(); + } + + protected function isStateClass(string $name): bool + { + return is_subclass_of($name, State::class) + || $name === State::class + || $name === StateCollection::class; + } +} From 18b9ade1182cd6ef9d7f6280151d0bc2ea296780 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 11 Feb 2026 09:22:11 -0700 Subject: [PATCH 18/18] Remove EventStateRegistry and NormalizeToPropertiesAndClassName changes - not needed for event ID fix Co-authored-by: Cursor --- src/Support/EventStateRegistry.php | 9 ++------- .../NormalizeToPropertiesAndClassName.php | 10 ++-------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/Support/EventStateRegistry.php b/src/Support/EventStateRegistry.php index aa262d3a..1a956589 100644 --- a/src/Support/EventStateRegistry.php +++ b/src/Support/EventStateRegistry.php @@ -57,7 +57,7 @@ protected function discoverAndPushState(StateDiscoveryAttribute $attribute, Even $states = Arr::wrap( $attribute ->setDiscoveredState($discovered) - ->discoverState($target, $this->manager) + ->discoverState($target, $this->manager), ); $discovered->push(...$states); @@ -126,18 +126,13 @@ protected function findAllProperties(Event $target): Collection $property_type = $property->getType(); if ( - $property_type - && $property_type instanceof ReflectionNamedType + $property_type instanceof ReflectionNamedType && $property_type->allowsNull() && $property->getValue($target) === null ) { return false; } - if ($property_type === null) { - return false; - } - $all_property_types = match ($property_type::class) { ReflectionUnionType::class, ReflectionIntersectionType::class => $property_type->getTypes(), default => [$property_type], diff --git a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php index 95e39ed5..1fc8f58f 100644 --- a/src/Support/Normalization/NormalizeToPropertiesAndClassName.php +++ b/src/Support/Normalization/NormalizeToPropertiesAndClassName.php @@ -6,7 +6,6 @@ use Illuminate\Support\Collection; use InvalidArgumentException; use ReflectionClass; -use ReflectionNamedType; use ReflectionProperty; use RuntimeException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -54,13 +53,8 @@ class_basename(static::class) foreach (Arr::except($data, ['fqcn']) as $key => $value) { $property = $reflect->getProperty($key); - $type = $property->getType(); - if ($type instanceof ReflectionNamedType && ! $type->isBuiltin() && $value !== null) { - $typeName = $type->getName(); - // Skip denormalization when value is already the correct type (e.g. Carbon for DateTimeInterface) - if (! (is_object($value) && $value instanceof $typeName)) { - $value = $denormalizer->denormalize($value, $typeName); - } + if ($property->hasType() && ! $property->getType()->isBuiltin() && $value !== null) { + $value = $denormalizer->denormalize($value, $property->getType()->getName()); } $property->setValue($instance, $value);