Skip to content

Commit 07beb41

Browse files
serializer: codec-independent helpers and Throwable normalization
Before this change `Serializer::serializable()`, `Serializer::serializeModels()`, and `Serializer::unserializeModels()` routed through `Serializer::__callStatic` to the configured codec. Since the v2 default moved to `json` (and then `avro`), neither of which implement those helper methods, the dispatch fell through to the `method_exists(...)` guard and silently returned `null`. That caused v2 failure normalization (`FailureFactory::normalizeProperties` / `restoreThrowable`) and v1 trace filtering (`WorkflowStub::fail`, `Activity::fail`) to drop property values and trace frames whenever the default codec did not inherit from `AbstractSerializer`. Changes: - Add `Serializer::serializable()`, `Serializer::serializeModels()`, and `Serializer::unserializeModels()` as first-class static methods backed by a local `ModelIdentifierHelper` that reuses Laravel's `SerializesAndRestoresModelIdentifiers` trait. These short-circuit before `__callStatic`, so they behave the same under every codec. - Pre-normalize Throwable/model arguments in `Serializer::__callStatic('serialize', ...)` and `Serializer::serializeWithCodec()` whenever the chosen codec is not an `AbstractSerializer` subclass. Legacy codecs still double-normalize via `AbstractSerializer::serialize()` (array in, array out), which is safe. - Replace the `$class::getInstance()` dispatch in `__callStatic` with `method_exists($class, $name) && $class::{$name}(...)`. `Avro` (registered by 1327f11) does not provide `getInstance()`, so the instance-based path was broken for the new default codec. - Update `WorkflowsConfigTest` to expect the current default `avro`. - Add `CodecIndependentHelpersTest` covering serializable / serializeModels / unserializeModels / direct Throwable round-trip / legacy Y+Base64 encode/decode across every registered codec. Closes #335.
1 parent 1327f11 commit 07beb41

3 files changed

Lines changed: 311 additions & 18 deletions

File tree

src/Serializers/Serializer.php

Lines changed: 177 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,103 @@
44

55
namespace Workflow\Serializers;
66

7+
use Illuminate\Queue\SerializesAndRestoresModelIdentifiers;
8+
use Throwable;
9+
710
final class Serializer
811
{
912
/**
10-
* Legacy magic dispatch — preserves the pre-codec-registry behavior.
13+
* Shared codec-independent normalization helpers live on this object so
14+
* that static calls like {@see Serializer::serializable()} can reach them
15+
* without coupling callers to the configured codec.
16+
*/
17+
private static ?ModelIdentifierHelper $helper = null;
18+
19+
/**
20+
* Legacy magic dispatch — preserves the pre-codec-registry behavior for
21+
* the codec-specific surface: {@see serialize()} / {@see unserialize()}.
22+
*
23+
* - serialize(): uses config('workflows.serializer') (default "json").
24+
* - unserialize(): sniffs the blob ("base64:" prefix → Base64, JSON-like →
25+
* Json, else Y).
1126
*
12-
* - serialize(): uses config('workflows.serializer') (default Y).
13-
* - unserialize(): sniffs the blob ("base64:" prefix → Base64, else Y).
27+
* Codec-independent helpers ({@see serializable()}, {@see serializeModels()},
28+
* {@see unserializeModels()}) are declared as first-class static methods
29+
* on this class and short-circuit before __callStatic so they produce the
30+
* same result regardless of the configured codec. That is important for
31+
* the JSON default: {@see Json} does not implement those helpers, and
32+
* silently returning null from them used to drop exception trace frames
33+
* and failure-property values during v2 failure normalization.
1434
*
1535
* New code should prefer {@see self::serializeWithCodec()} /
1636
* {@see self::unserializeWithCodec()} which make the codec choice explicit.
1737
*/
1838
public static function __callStatic(string $name, array $arguments)
1939
{
2040
if ($name === 'unserialize') {
21-
$instance = self::legacyUnserializeInstance((string) ($arguments[0] ?? ''));
41+
$class = self::legacyUnserializeClass((string) ($arguments[0] ?? ''));
2242
} else {
23-
$instance = self::defaultInstance();
43+
$class = self::defaultCodecClass();
2444
}
2545

26-
if (method_exists($instance, $name)) {
27-
return $instance->{$name}(...$arguments);
46+
if ($name === 'serialize' && ! is_subclass_of($class, AbstractSerializer::class)) {
47+
$arguments[0] = self::normalizeForCodec($arguments[0] ?? null);
48+
}
49+
50+
if (method_exists($class, $name)) {
51+
return $class::{$name}(...$arguments);
2852
}
2953
}
3054

31-
private static function defaultInstance(): SerializerInterface
55+
/**
56+
* Codec-independent replacement for the legacy AbstractSerializer helper:
57+
* is this value safe to pass to PHP's native serialize()?
58+
*
59+
* Used by exception-trace filtering and v2 failure property capture. Must
60+
* be safe to call regardless of the configured codec.
61+
*/
62+
public static function serializable(mixed $data): bool
3263
{
33-
$configured = function_exists('config') ? config('workflows.serializer') : null;
64+
try {
65+
serialize($data);
66+
return true;
67+
} catch (Throwable) {
68+
return false;
69+
}
70+
}
3471

35-
if (is_string($configured) && $configured !== '') {
36-
$class = CodecRegistry::resolve($configured);
37-
return $class::getInstance();
72+
/**
73+
* Recursively replace Eloquent models inside $data with their serialized
74+
* identifier representation, and convert Throwable instances into plain
75+
* arrays. Always applied by v1 and v2 failure normalization paths.
76+
*
77+
* Codec-independent: returns the same shape regardless of whether the
78+
* configured codec is "json", Y, or Base64.
79+
*/
80+
public static function serializeModels(mixed $data): mixed
81+
{
82+
if ($data instanceof Throwable) {
83+
return self::throwableToArray($data);
3884
}
3985

40-
return Json::getInstance();
86+
if (is_array($data)) {
87+
return self::helper()->serializeValue($data);
88+
}
89+
90+
return $data;
91+
}
92+
93+
/**
94+
* Inverse of {@see serializeModels()} for nested arrays. Scalars and
95+
* non-array values are returned unchanged.
96+
*/
97+
public static function unserializeModels(mixed $data): mixed
98+
{
99+
if (is_array($data)) {
100+
return self::helper()->unserializeValue($data);
101+
}
102+
103+
return $data;
41104
}
42105

43106
/**
@@ -48,6 +111,11 @@ private static function defaultInstance(): SerializerInterface
48111
public static function serializeWithCodec(?string $codec, $data): string
49112
{
50113
$class = CodecRegistry::resolve($codec);
114+
115+
if (! is_subclass_of($class, AbstractSerializer::class)) {
116+
$data = self::normalizeForCodec($data);
117+
}
118+
51119
return $class::serialize($data);
52120
}
53121

@@ -60,19 +128,74 @@ public static function unserializeWithCodec(?string $codec, string $data)
60128
return $class::unserialize($data);
61129
}
62130

63-
private static function legacyUnserializeInstance(string $blob): SerializerInterface
131+
/**
132+
* @return class-string<SerializerInterface>
133+
*/
134+
private static function defaultCodecClass(): string
135+
{
136+
$configured = function_exists('config') ? config('workflows.serializer') : null;
137+
138+
if (is_string($configured) && $configured !== '') {
139+
return CodecRegistry::resolve($configured);
140+
}
141+
142+
return CodecRegistry::resolve(null);
143+
}
144+
145+
/**
146+
* Pre-normalize $data before handing it to a codec that does not itself
147+
* apply model/Throwable normalization (for example {@see Json}). Legacy
148+
* codecs that extend {@see AbstractSerializer} already call serializeModels
149+
* internally and must not be double-normalized here.
150+
*/
151+
private static function normalizeForCodec(mixed $data): mixed
152+
{
153+
return self::serializeModels($data);
154+
}
155+
156+
/**
157+
* @return array{class: class-string<Throwable>, message: string, code: int|string, line: int, file: string, trace: list<array<string, mixed>>}
158+
*/
159+
private static function throwableToArray(Throwable $throwable): array
160+
{
161+
return [
162+
'class' => get_class($throwable),
163+
'message' => $throwable->getMessage(),
164+
'code' => $throwable->getCode(),
165+
'line' => $throwable->getLine(),
166+
'file' => $throwable->getFile(),
167+
'trace' => collect($throwable->getTrace())
168+
->filter(static fn ($trace) => self::serializable($trace))
169+
->values()
170+
->toArray(),
171+
];
172+
}
173+
174+
private static function helper(): ModelIdentifierHelper
175+
{
176+
if (self::$helper === null) {
177+
self::$helper = new ModelIdentifierHelper();
178+
}
179+
180+
return self::$helper;
181+
}
182+
183+
/**
184+
* @return class-string<SerializerInterface>
185+
*/
186+
private static function legacyUnserializeClass(string $blob): string
64187
{
65188
if (str_starts_with($blob, 'base64:')) {
66-
return Base64::getInstance();
189+
return Base64::class;
67190
}
68191

69192
// JSON blobs always start with "{", "[", a digit, quote, minus, "t"/"f"/"n".
70193
// PHP-serialized-closure blobs start with "O:".
71194
if ($blob !== '' && $blob[0] !== 'O' && self::looksLikeJson($blob)) {
72-
return Json::getInstance();
195+
return Json::class;
73196
}
74197

75-
return Y::getInstance();
198+
return Y::class;
76199
}
77200

78201
private static function looksLikeJson(string $blob): bool
@@ -87,3 +210,40 @@ private static function looksLikeJson(string $blob): bool
87210
return in_array($blob, ['true', 'false', 'null'], true);
88211
}
89212
}
213+
214+
/**
215+
* @internal
216+
*/
217+
final class ModelIdentifierHelper
218+
{
219+
use SerializesAndRestoresModelIdentifiers {
220+
getSerializedPropertyValue as public;
221+
getRestoredPropertyValue as public;
222+
}
223+
224+
public function serializeValue(mixed $value): mixed
225+
{
226+
if (is_array($value)) {
227+
foreach ($value as $key => $nested) {
228+
$value[$key] = $this->serializeValue($nested);
229+
}
230+
231+
return $value;
232+
}
233+
234+
return $this->getSerializedPropertyValue($value);
235+
}
236+
237+
public function unserializeValue(mixed $value): mixed
238+
{
239+
if (is_array($value)) {
240+
foreach ($value as $key => $nested) {
241+
$value[$key] = $this->unserializeValue($nested);
242+
}
243+
244+
return $value;
245+
}
246+
247+
return $this->getRestoredPropertyValue($value);
248+
}
249+
}

tests/Unit/Config/WorkflowsConfigTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public function testConfigIsLoaded(): void
2222
'stored_workflow_signal_model' => \Workflow\Models\StoredWorkflowSignal::class,
2323
'stored_workflow_timer_model' => \Workflow\Models\StoredWorkflowTimer::class,
2424
'workflow_relationships_table' => 'workflow_relationships',
25-
'serializer' => 'json',
25+
'serializer' => 'avro',
2626
'prune_age' => '1 month',
2727
'webhooks_route' => env('WORKFLOW_WEBHOOKS_ROUTE', 'webhooks'),
2828
];
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Unit\Serializers;
6+
7+
use Exception;
8+
use Tests\TestCase;
9+
use Workflow\Serializers\Base64;
10+
use Workflow\Serializers\Json;
11+
use Workflow\Serializers\Serializer;
12+
use Workflow\Serializers\Y;
13+
14+
final class CodecIndependentHelpersTest extends TestCase
15+
{
16+
/**
17+
* @dataProvider codecProvider
18+
*/
19+
public function testSerializableReturnsTrueForScalarsRegardlessOfCodec(string $codec): void
20+
{
21+
config(['workflows.serializer' => $codec]);
22+
23+
$this->assertTrue(Serializer::serializable('foo'));
24+
$this->assertTrue(Serializer::serializable(42));
25+
$this->assertTrue(Serializer::serializable(['a' => 1]));
26+
$this->assertTrue(Serializer::serializable(null));
27+
}
28+
29+
/**
30+
* @dataProvider codecProvider
31+
*/
32+
public function testSerializableReturnsFalseForClosureRegardlessOfCodec(string $codec): void
33+
{
34+
config(['workflows.serializer' => $codec]);
35+
36+
$this->assertFalse(Serializer::serializable(static fn (): string => 'closure'));
37+
}
38+
39+
/**
40+
* @dataProvider codecProvider
41+
*/
42+
public function testSerializeModelsPassesThroughPlainArraysRegardlessOfCodec(string $codec): void
43+
{
44+
config(['workflows.serializer' => $codec]);
45+
46+
$input = ['a' => 1, 'b' => ['nested' => true]];
47+
48+
$this->assertSame($input, Serializer::serializeModels($input));
49+
}
50+
51+
/**
52+
* @dataProvider codecProvider
53+
*/
54+
public function testSerializeModelsConvertsThrowableToArrayRegardlessOfCodec(string $codec): void
55+
{
56+
config(['workflows.serializer' => $codec]);
57+
58+
$throwable = new Exception('boom', 7);
59+
$data = Serializer::serializeModels($throwable);
60+
61+
$this->assertIsArray($data);
62+
$this->assertSame(Exception::class, $data['class']);
63+
$this->assertSame('boom', $data['message']);
64+
$this->assertSame(7, $data['code']);
65+
$this->assertArrayHasKey('trace', $data);
66+
$this->assertIsArray($data['trace']);
67+
}
68+
69+
/**
70+
* @dataProvider codecProvider
71+
*/
72+
public function testUnserializeModelsIsIdentityForPlainArraysRegardlessOfCodec(string $codec): void
73+
{
74+
config(['workflows.serializer' => $codec]);
75+
76+
$input = ['a' => 1, 'b' => ['c' => 'x']];
77+
78+
$this->assertSame($input, Serializer::unserializeModels($input));
79+
}
80+
81+
/**
82+
* @dataProvider languageNeutralCodecProvider
83+
*/
84+
public function testSerializeThrowableUnderLanguageNeutralCodecPreservesDiagnosticData(string $codec): void
85+
{
86+
if ($codec === 'avro' && ! class_exists(\Apache\Avro\Schema\AvroSchema::class)) {
87+
$this->markTestSkipped('apache/avro package is not installed in this environment.');
88+
}
89+
90+
config(['workflows.serializer' => $codec]);
91+
92+
$throwable = new Exception('boom', 9);
93+
$serialized = Serializer::serialize($throwable);
94+
$decoded = Serializer::unserialize($serialized);
95+
96+
$this->assertIsArray($decoded);
97+
$this->assertSame(Exception::class, $decoded['class']);
98+
$this->assertSame('boom', $decoded['message']);
99+
$this->assertSame(9, $decoded['code']);
100+
$this->assertArrayHasKey('trace', $decoded);
101+
$this->assertIsArray($decoded['trace']);
102+
}
103+
104+
public static function languageNeutralCodecProvider(): array
105+
{
106+
return [
107+
'json' => ['json'],
108+
'avro' => ['avro'],
109+
];
110+
}
111+
112+
public function testLegacyCodecsRoundTripBytesThroughEncodeDecode(): void
113+
{
114+
foreach ([Y::class, Base64::class] as $codec) {
115+
config(['workflows.serializer' => $codec]);
116+
117+
$bytes = "\x00\x01binary" . random_bytes(32);
118+
$roundTripped = Serializer::decode(Serializer::encode($bytes));
119+
120+
$this->assertSame($bytes, $roundTripped, "Codec {$codec} must round-trip raw bytes");
121+
}
122+
}
123+
124+
public static function codecProvider(): array
125+
{
126+
return [
127+
'json' => ['json'],
128+
'avro' => ['avro'],
129+
'Y' => [Y::class],
130+
'Base64' => [Base64::class],
131+
];
132+
}
133+
}

0 commit comments

Comments
 (0)