From bbb333c0fb63f52ed3c59222d5eca002752c1802 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 11 Jun 2026 10:39:54 +0300 Subject: [PATCH 01/18] Introduce `MessageEncoderInterface` --- config/di.php | 6 +- docs/guide/en/best-practices.md | 2 +- ...onsuming-messages-from-external-systems.md | 13 +- docs/guide/en/messages-and-handlers.md | 4 +- src/Message/JsonMessageSerializer.php | 68 ------ src/Message/MessageSerializerInterface.php | 12 -- src/Message/Serializer/JsonMessageEncoder.php | 63 ++++++ .../Serializer/MessageEncoderException.php | 12 ++ .../Serializer/MessageEncoderInterface.php | 43 ++++ src/Message/Serializer/MessageSerializer.php | 71 +++++++ tests/Benchmark/QueueBench.php | 11 +- tests/Benchmark/Support/VoidAdapter.php | 4 +- .../Message/JsonMessageSerializerTest.php | 195 ------------------ .../Serializer/JsonMessageSerializerTest.php | 64 ++++++ .../Serializer/MessageSerializerTest.php | 129 ++++++++++++ 15 files changed, 403 insertions(+), 294 deletions(-) delete mode 100644 src/Message/JsonMessageSerializer.php delete mode 100644 src/Message/MessageSerializerInterface.php create mode 100644 src/Message/Serializer/JsonMessageEncoder.php create mode 100644 src/Message/Serializer/MessageEncoderException.php create mode 100644 src/Message/Serializer/MessageEncoderInterface.php create mode 100644 src/Message/Serializer/MessageSerializer.php delete mode 100644 tests/Unit/Message/JsonMessageSerializerTest.php create mode 100644 tests/Unit/Message/Serializer/JsonMessageSerializerTest.php create mode 100644 tests/Unit/Message/Serializer/MessageSerializerTest.php diff --git a/config/di.php b/config/di.php index 06de09e4..b7762f9a 100644 --- a/config/di.php +++ b/config/di.php @@ -6,8 +6,8 @@ use Yiisoft\Queue\Cli\LoopInterface; use Yiisoft\Queue\Cli\SignalLoop; use Yiisoft\Queue\Cli\SimpleLoop; -use Yiisoft\Queue\Message\JsonMessageSerializer; -use Yiisoft\Queue\Message\MessageSerializerInterface; +use Yiisoft\Queue\Message\Serializer\JsonMessageEncoder; +use Yiisoft\Queue\Message\Serializer\MessageEncoderInterface; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactoryInterface; @@ -45,5 +45,5 @@ FailureMiddlewareDispatcher::class => [ '__construct()' => ['middlewareDefinitions' => $params['yiisoft/queue']['middlewares-fail']], ], - MessageSerializerInterface::class => JsonMessageSerializer::class, + MessageEncoderInterface::class => JsonMessageEncoder::class, ]; diff --git a/docs/guide/en/best-practices.md b/docs/guide/en/best-practices.md index 92263c9f..5f7c864f 100644 --- a/docs/guide/en/best-practices.md +++ b/docs/guide/en/best-practices.md @@ -190,7 +190,7 @@ new Message(SendEmailHandler::class, [ #### Why -- Message data must be JSON-serializable when using the default `JsonMessageSerializer`. +- Message data must be JSON-serializable when using the default `JsonMessageEncoder`. - Resources (file handles, database connections, sockets) cannot be serialized. - Closures and anonymous functions cannot be serialized. - Objects with circular references or without proper serialization support will fail. diff --git a/docs/guide/en/consuming-messages-from-external-systems.md b/docs/guide/en/consuming-messages-from-external-systems.md index 65eba2e8..9d964ba3 100644 --- a/docs/guide/en/consuming-messages-from-external-systems.md +++ b/docs/guide/en/consuming-messages-from-external-systems.md @@ -5,12 +5,13 @@ This guide explains how to publish messages to a queue backend (RabbitMQ, Kafka, The key idea is simple: - The queue adapter reads a *raw payload* (usually a string) from the broker. -- The adapter passes that payload to a `Yiisoft\Queue\Message\MessageSerializerInterface` implementation. -- By default, `yiisoft/queue` config binds `MessageSerializerInterface` to `Yiisoft\Queue\Message\JsonMessageSerializer`. +- The adapter passes that payload to a `Yiisoft\Queue\Message\Serializer\MessageSerializer`. +- `MessageSerializer` delegates wire encoding to a `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` implementation. +- By default, `yiisoft/queue` config binds `MessageEncoderInterface` to `Yiisoft\Queue\Message\Serializer\JsonMessageEncoder`. -`JsonMessageSerializer` is only the default implementation. You can replace it with your own serializer by rebinding `Yiisoft\Queue\Message\MessageSerializerInterface` in your DI configuration. +`JsonMessageEncoder` is only the default implementation. You can replace it with your own encoder by rebinding `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` in your DI configuration. -So, external systems should produce the **same payload format** that your consumer-side serializer expects (JSON described below is for the default `JsonMessageSerializer`). +So, external systems should produce the **same payload format** that your consumer-side encoder expects (JSON described below is for the default `JsonMessageEncoder`). ## 1. Message type contract (most important part) @@ -32,9 +33,9 @@ return [ External producer then always publishes `"type": "file-download"`. -## 2. JSON payload format (JsonMessageSerializer) +## 2. JSON payload format (JsonMessageEncoder) -`Yiisoft\Queue\Message\JsonMessageSerializer` expects the message body to be a JSON object with these keys: +`Yiisoft\Queue\Message\Serializer\JsonMessageEncoder` expects the message body to be a JSON object with these keys: - `type` (string, required) - `data` (any JSON value, optional; defaults to `null`) diff --git a/docs/guide/en/messages-and-handlers.md b/docs/guide/en/messages-and-handlers.md index 2c2592ee..330b6f09 100644 --- a/docs/guide/en/messages-and-handlers.md +++ b/docs/guide/en/messages-and-handlers.md @@ -129,14 +129,14 @@ Because the payload is just data, any language can produce or consume it. A Pyth ## Why JSON is the default serialization -By default, `yiisoft/queue` serializes message payloads as JSON (`JsonMessageSerializer`). JSON was chosen intentionally: +By default, `yiisoft/queue` serializes message payloads as JSON (`JsonMessageEncoder`). JSON was chosen intentionally: - **Human-readable** — you can inspect a message in a broker dashboard without any tools. - **Language-agnostic** — every language and runtime can produce and parse JSON. - **Fast and lightweight** — no class metadata, no object graphs, no PHP-specific format. - **Forces payload discipline** — if your data cannot be expressed as a JSON-encodable value (strings, numbers, booleans, null, arrays, and objects), it is a sign the payload carries too much. Keep payloads simple: IDs, strings, primitive values. -You can replace `JsonMessageSerializer` with your own implementation by rebinding `MessageSerializerInterface` in DI, but the default works for the vast majority of use cases. +You can replace `JsonMessageEncoder` with your own implementation by rebinding `MessageEncoderInterface` in DI, but the default works for the vast majority of use cases. ## Migration note: Yii2 queue diff --git a/src/Message/JsonMessageSerializer.php b/src/Message/JsonMessageSerializer.php deleted file mode 100644 index 84417f1f..00000000 --- a/src/Message/JsonMessageSerializer.php +++ /dev/null @@ -1,68 +0,0 @@ - $message->getType(), - 'data' => $message->getData(), - 'meta' => $message->getMetadata(), - ]; - if (!isset($payload['meta']['message-class'])) { - $payload['meta']['message-class'] = $message instanceof Envelope - ? $message->getMessage()::class - : $message::class; - } - - return json_encode($payload, JSON_THROW_ON_ERROR); - } - - /** - * @throws JsonException - * @throws InvalidArgumentException - */ - public function unserialize(string $value): MessageInterface - { - $payload = json_decode($value, true, 512, JSON_THROW_ON_ERROR); - if (!is_array($payload)) { - throw new InvalidArgumentException('Payload must be array. Got ' . get_debug_type($payload) . '.'); - } - - $type = $payload['type'] ?? null; - if (!isset($type) || !is_string($type)) { - throw new InvalidArgumentException('Message type must be a string. Got ' . get_debug_type($type) . '.'); - } - - $meta = $payload['meta'] ?? []; - if (!is_array($meta)) { - throw new InvalidArgumentException('Metadata must be an array. Got ' . get_debug_type($meta) . '.'); - } - - $class = $meta['message-class'] ?? GenericMessage::class; - // Don't check subclasses when it's a default class: that's faster - if ($class !== GenericMessage::class && !is_subclass_of($class, MessageInterface::class)) { - $class = GenericMessage::class; - } - - /** - * @var class-string $class - */ - return $class::fromData($type, $payload['data'] ?? null)->withMetadata($meta); - } -} diff --git a/src/Message/MessageSerializerInterface.php b/src/Message/MessageSerializerInterface.php deleted file mode 100644 index b034590c..00000000 --- a/src/Message/MessageSerializerInterface.php +++ /dev/null @@ -1,12 +0,0 @@ - $type, + 'data' => $data, + 'meta' => $metadata, + ], + JSON_THROW_ON_ERROR, + ); + } catch (JsonException $e) { + throw new MessageEncoderException($e->getMessage(), previous: $e); + } + } + + public function decode(string $value): array + { + try { + $payload = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new MessageEncoderException($e->getMessage(), previous: $e); + } + + if (!is_array($payload)) { + throw new MessageEncoderException('Payload must be array. Got ' . get_debug_type($payload) . '.'); + } + + $type = $payload['type'] ?? null; + if (!isset($type) || !is_string($type)) { + throw new MessageEncoderException('Message type must be a string. Got ' . get_debug_type($type) . '.'); + } + + $meta = $payload['meta'] ?? []; + if (!is_array($meta)) { + throw new MessageEncoderException('Metadata must be an array. Got ' . get_debug_type($meta) . '.'); + } + + return [ + $type, + $payload['data'] ?? null, + $meta, + ]; + } +} diff --git a/src/Message/Serializer/MessageEncoderException.php b/src/Message/Serializer/MessageEncoderException.php new file mode 100644 index 00000000..22f30e0e --- /dev/null +++ b/src/Message/Serializer/MessageEncoderException.php @@ -0,0 +1,12 @@ +getMetadata(); + + if (!isset($metadata[self::META_MESSAGE_CLASS])) { + $metadata[self::META_MESSAGE_CLASS] = $message instanceof Envelope + ? $message->getMessage()::class + : $message::class; + } + + return $this->encoder->encode($message->getType(), $message->getData(), $metadata); + } + + /** + * Unserializes a message from a string. + * + * @param string $value Encoded message string. + * + * @throws MessageEncoderException If decoding fails. + */ + public function unserialize(string $value): MessageInterface + { + [$type, $data, $metadata] = $this->encoder->decode($value); + + $class = $metadata[self::META_MESSAGE_CLASS] ?? GenericMessage::class; + + // Don't check subclasses when it's a default class: that's faster + if ($class !== GenericMessage::class + && (!is_string($class) || !is_subclass_of($class, MessageInterface::class)) + ) { + $class = GenericMessage::class; + } + /** @var class-string $class */ + + return $class::fromData($type, $data)->withMetadata($metadata); + } +} diff --git a/tests/Benchmark/QueueBench.php b/tests/Benchmark/QueueBench.php index b03dd8a5..7faca4e9 100644 --- a/tests/Benchmark/QueueBench.php +++ b/tests/Benchmark/QueueBench.php @@ -10,8 +10,9 @@ use Yiisoft\Injector\Injector; use Yiisoft\Queue\Cli\SimpleLoop; use Yiisoft\Queue\Message\IdEnvelope; -use Yiisoft\Queue\Message\JsonMessageSerializer; use Yiisoft\Queue\Message\GenericMessage; +use Yiisoft\Queue\Message\Serializer\JsonMessageEncoder; +use Yiisoft\Queue\Message\Serializer\MessageSerializer; use Yiisoft\Queue\Middleware\CallableFactory; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; @@ -29,7 +30,7 @@ final class QueueBench { private readonly QueueInterface $queue; - private readonly JsonMessageSerializer $serializer; + private readonly MessageSerializer $serializer; private readonly VoidAdapter $adapter; public function __construct() @@ -52,7 +53,7 @@ public function __construct() ), $callableFactory, ); - $this->serializer = new JsonMessageSerializer(); + $this->serializer = new MessageSerializer(new JsonMessageEncoder()); $this->adapter = new VoidAdapter($this->serializer); $this->queue = new Queue( @@ -86,9 +87,9 @@ public function benchPush(array $params): void public function provideConsume(): Generator { - yield 'simple mapping' => ['message' => $this->serializer->serialize(new GenericMessage('foo', 'bar'))]; + yield 'simple mapping' => ['message' => $this->serializer->encode(new GenericMessage('foo', 'bar'))]; yield 'with envelopes mapping' => [ - 'message' => $this->serializer->serialize( + 'message' => $this->serializer->encode( new FailureEnvelope( new IdEnvelope( new GenericMessage('foo', 'bar'), diff --git a/tests/Benchmark/Support/VoidAdapter.php b/tests/Benchmark/Support/VoidAdapter.php index 5f903b9f..363a2489 100644 --- a/tests/Benchmark/Support/VoidAdapter.php +++ b/tests/Benchmark/Support/VoidAdapter.php @@ -7,10 +7,10 @@ use InvalidArgumentException; use RuntimeException; use Yiisoft\Queue\Adapter\AdapterInterface; +use Yiisoft\Queue\Message\Serializer\MessageSerializer; use Yiisoft\Queue\MessageStatus; use Yiisoft\Queue\Message\IdEnvelope; use Yiisoft\Queue\Message\MessageInterface; -use Yiisoft\Queue\Message\MessageSerializerInterface; final class VoidAdapter implements AdapterInterface { @@ -19,7 +19,7 @@ final class VoidAdapter implements AdapterInterface */ public string $message; - public function __construct(private readonly MessageSerializerInterface $serializer) {} + public function __construct(private readonly MessageSerializer $serializer) {} public function runExisting(callable $handlerCallback): void { diff --git a/tests/Unit/Message/JsonMessageSerializerTest.php b/tests/Unit/Message/JsonMessageSerializerTest.php deleted file mode 100644 index 50a84084..00000000 --- a/tests/Unit/Message/JsonMessageSerializerTest.php +++ /dev/null @@ -1,195 +0,0 @@ - $type, 'data' => 'test']; - $serializer = $this->createSerializer(); - - $this->expectExceptionMessage(sprintf('Message type must be a string. Got %s.', get_debug_type($type))); - $this->expectException(InvalidArgumentException::class); - $serializer->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); - } - - public static function dataUnsupportedTypeFormat(): iterable - { - yield 'number' => [1]; - yield 'boolean' => [true]; - yield 'null' => [null]; - yield 'array' => [[]]; - } - - public function testDefaultMessageClassFallbackWrongClass(): void - { - $serializer = $this->createSerializer(); - $payload = [ - 'type' => 'handler', - 'data' => 'test', - 'meta' => [ - 'message-class' => 'NonExistentClass', - ], - ]; - - $message = $serializer->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); - $this->assertInstanceOf(GenericMessage::class, $message); - } - - public function testDefaultMessageClassFallbackClassNotSet(): void - { - $serializer = $this->createSerializer(); - $payload = [ - 'type' => 'handler', - 'data' => 'test', - 'meta' => [], - ]; - $message = $serializer->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); - $this->assertInstanceOf(GenericMessage::class, $message); - } - - #[DataProvider('dataUnsupportedPayloadFormat')] - public function testPayloadFormat(mixed $payload): void - { - $serializer = $this->createSerializer(); - - $this->expectExceptionMessage(sprintf('Payload must be array. Got %s.', get_debug_type($payload))); - $this->expectException(InvalidArgumentException::class); - $serializer->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); - } - - public static function dataUnsupportedPayloadFormat(): iterable - { - yield 'string' => ['']; - yield 'number' => [1]; - yield 'boolean' => [true]; - yield 'null' => [null]; - } - - #[DataProvider('dataUnsupportedMetadataFormat')] - public function testMetadataFormat(mixed $meta): void - { - $payload = ['type' => 'handler', 'data' => 'test', 'meta' => $meta]; - $serializer = $this->createSerializer(); - - $this->expectExceptionMessage(sprintf('Metadata must be an array. Got %s.', get_debug_type($meta))); - $this->expectException(InvalidArgumentException::class); - $serializer->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); - } - - public static function dataUnsupportedMetadataFormat(): iterable - { - yield 'string' => ['']; - yield 'number' => [1]; - yield 'boolean' => [true]; - } - - public function testUnserializeFromData(): void - { - $payload = ['type' => 'handler', 'data' => 'test']; - $serializer = $this->createSerializer(); - - $message = $serializer->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); - - $this->assertEquals($payload['data'], $message->getData()); - $this->assertEquals([], $message->getMetadata()); - } - - public function testUnserializeWithMetadata(): void - { - $payload = ['type' => 'handler', 'data' => 'test', 'meta' => ['int' => 1, 'str' => 'string', 'bool' => true]]; - $serializer = $this->createSerializer(); - - $message = $serializer->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); - - $this->assertEquals($payload['data'], $message->getData()); - $this->assertEquals(['int' => 1, 'str' => 'string', 'bool' => true], $message->getMetadata()); - } - - public function testSerialize(): void - { - $message = new GenericMessage('handler', 'test'); - - $serializer = $this->createSerializer(); - - $json = $serializer->serialize($message); - - $this->assertEquals( - '{"type":"handler","data":"test","meta":{"message-class":"Yiisoft\\\\Queue\\\\Message\\\\GenericMessage"}}', - $json, - ); - } - - public function testSerializeEnvelopeStack(): void - { - $message = new GenericMessage('handler', 'test'); - $message = new IdEnvelope($message, 'test-id'); - - $serializer = $this->createSerializer(); - - $json = $serializer->serialize($message); - - $this->assertEquals( - sprintf( - '{"type":"handler","data":"test","meta":{"%s":"test-id","message-class":"%s"}}', - IdEnvelope::META_ID, - str_replace('\\', '\\\\', GenericMessage::class), - ), - $json, - ); - - $message = $serializer->unserialize($json); - - $this->assertInstanceOf(GenericMessage::class, $message); - $this->assertEquals([ - IdEnvelope::META_ID => 'test-id', - 'message-class' => GenericMessage::class, - ], $message->getMetadata()); - } - - public function testRestoreOriginalMessageClass(): void - { - $message = new TestMessage(); - $serializer = $this->createSerializer(); - $serializer->unserialize($serializer->serialize($message)); - - $this->assertInstanceOf(TestMessage::class, $message); - } - - public function testRestoreOriginalMessageClassWithEnvelope(): void - { - $message = new IdEnvelope(new TestMessage(), 1); - $serializer = $this->createSerializer(); - $serializer->unserialize($serializer->serialize($message)); - - $this->assertInstanceOf(IdEnvelope::class, $message); - $this->assertInstanceOf(TestMessage::class, $message->getMessage()); - } - - private function createSerializer(): JsonMessageSerializer - { - return new JsonMessageSerializer(); - } -} diff --git a/tests/Unit/Message/Serializer/JsonMessageSerializerTest.php b/tests/Unit/Message/Serializer/JsonMessageSerializerTest.php new file mode 100644 index 00000000..849faba5 --- /dev/null +++ b/tests/Unit/Message/Serializer/JsonMessageSerializerTest.php @@ -0,0 +1,64 @@ +expectException(MessageEncoderException::class); + $this->expectExceptionMessage(sprintf('Payload must be array. Got %s.', get_debug_type($raw))); + $encoder->decode($value); + } + + #[TestWith([1])] + #[TestWith([true])] + #[TestWith([null])] + #[TestWith([[]])] + public function testUnsupportedType(mixed $type): void + { + $encoder = new JsonMessageEncoder(); + $value = json_encode( + ['type' => $type, 'data' => 'test', 'meta' => []], + JSON_THROW_ON_ERROR, + ); + + $this->expectException(MessageEncoderException::class); + $this->expectExceptionMessage(sprintf('Message type must be a string. Got %s.', get_debug_type($type))); + $encoder->decode($value); + } + + #[TestWith([''])] + #[TestWith([1])] + #[TestWith([true])] + public function testUnsupportedMetadata(mixed $metadata): void + { + $encoder = new JsonMessageEncoder(); + $value = json_encode( + ['type' => 'test', 'data' => 'test', 'meta' => $metadata], + JSON_THROW_ON_ERROR, + ); + + $this->expectException(MessageEncoderException::class); + $this->expectExceptionMessage(sprintf('Metadata must be an array. Got %s.', get_debug_type($metadata))); + $encoder->decode($value); + } +} diff --git a/tests/Unit/Message/Serializer/MessageSerializerTest.php b/tests/Unit/Message/Serializer/MessageSerializerTest.php new file mode 100644 index 00000000..d17548f5 --- /dev/null +++ b/tests/Unit/Message/Serializer/MessageSerializerTest.php @@ -0,0 +1,129 @@ + 'handler', + 'data' => 'test', + 'meta' => [ + 'message-class' => 'NonExistentClass', + ], + ]; + + $message = $this->createSerializer()->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); + + $this->assertInstanceOf(GenericMessage::class, $message); + } + + public function testDefaultMessageClassFallbackClassNotSet(): void + { + $payload = [ + 'type' => 'handler', + 'data' => 'test', + 'meta' => [], + ]; + + $message = $this->createSerializer()->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); + + $this->assertInstanceOf(GenericMessage::class, $message); + } + + public function testUnserializeFromData(): void + { + $payload = ['type' => 'handler', 'data' => 'test']; + + $message = $this->createSerializer()->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); + + $this->assertEquals($payload['data'], $message->getData()); + $this->assertEquals([], $message->getMetadata()); + } + + public function testUnserializeWithMetadata(): void + { + $payload = ['type' => 'handler', 'data' => 'test', 'meta' => ['int' => 1, 'str' => 'string', 'bool' => true]]; + + $message = $this->createSerializer()->unserialize(json_encode($payload, JSON_THROW_ON_ERROR)); + + $this->assertEquals($payload['data'], $message->getData()); + $this->assertEquals(['int' => 1, 'str' => 'string', 'bool' => true], $message->getMetadata()); + } + + public function testSerialize(): void + { + $message = new GenericMessage('handler', 'test'); + + $json = $this->createSerializer()->serialize($message); + + $this->assertEquals( + '{"type":"handler","data":"test","meta":{"message-class":"Yiisoft\\\\Queue\\\\Message\\\\GenericMessage"}}', + $json, + ); + } + + public function testSerializeEnvelopeStack(): void + { + $message = new IdEnvelope(new GenericMessage('handler', 'test'), 'test-id'); + $serializer = $this->createSerializer(); + + $json = $serializer->serialize($message); + + $this->assertEquals( + sprintf( + '{"type":"handler","data":"test","meta":{"%s":"test-id","message-class":"%s"}}', + IdEnvelope::META_ID, + str_replace('\\', '\\\\', GenericMessage::class), + ), + $json, + ); + + $restored = $serializer->unserialize($json); + + $this->assertInstanceOf(GenericMessage::class, $restored); + $this->assertEquals([ + IdEnvelope::META_ID => 'test-id', + 'message-class' => GenericMessage::class, + ], $restored->getMetadata()); + } + + public function testRestoreOriginalMessageClass(): void + { + $message = new TestMessage(); + $serializer = $this->createSerializer(); + + $restored = $serializer->unserialize($serializer->serialize($message)); + + $this->assertInstanceOf(TestMessage::class, $restored); + } + + public function testRestoreOriginalMessageClassWithEnvelope(): void + { + $message = new IdEnvelope(new TestMessage(), 1); + $serializer = $this->createSerializer(); + + $restored = $serializer->unserialize($serializer->serialize($message)); + + $this->assertInstanceOf(TestMessage::class, $restored); + } + + private function createSerializer(): MessageSerializer + { + return new MessageSerializer(new JsonMessageEncoder()); + } +} From 7758062e119badc128bbd06e379b4b95f9b7735d Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 11 Jun 2026 10:42:19 +0300 Subject: [PATCH 02/18] fix cs --- src/Message/Serializer/MessageSerializer.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Message/Serializer/MessageSerializer.php b/src/Message/Serializer/MessageSerializer.php index 26cc9b50..1782c2c8 100644 --- a/src/Message/Serializer/MessageSerializer.php +++ b/src/Message/Serializer/MessageSerializer.php @@ -22,8 +22,7 @@ final class MessageSerializer public function __construct( private readonly MessageEncoderInterface $encoder, - ) { - } + ) {} /** * Serializes a message to a string. From 2eef14dfc71eeb01b3828975ed60b1a15ab57205 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 11 Jun 2026 10:46:22 +0300 Subject: [PATCH 03/18] fix bench --- tests/Benchmark/QueueBench.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Benchmark/QueueBench.php b/tests/Benchmark/QueueBench.php index 7faca4e9..873f773d 100644 --- a/tests/Benchmark/QueueBench.php +++ b/tests/Benchmark/QueueBench.php @@ -87,9 +87,9 @@ public function benchPush(array $params): void public function provideConsume(): Generator { - yield 'simple mapping' => ['message' => $this->serializer->encode(new GenericMessage('foo', 'bar'))]; + yield 'simple mapping' => ['message' => $this->serializer->serialize(new GenericMessage('foo', 'bar'))]; yield 'with envelopes mapping' => [ - 'message' => $this->serializer->encode( + 'message' => $this->serializer->serialize( new FailureEnvelope( new IdEnvelope( new GenericMessage('foo', 'bar'), From c2f5bbb2dbb23616ac66ef0a08d65577d27cfa3e Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 11 Jun 2026 10:47:44 +0300 Subject: [PATCH 04/18] Rename test --- ...JsonMessageSerializerTest.php => JsonMessageEncoderTest.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/Unit/Message/Serializer/{JsonMessageSerializerTest.php => JsonMessageEncoderTest.php} (97%) diff --git a/tests/Unit/Message/Serializer/JsonMessageSerializerTest.php b/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php similarity index 97% rename from tests/Unit/Message/Serializer/JsonMessageSerializerTest.php rename to tests/Unit/Message/Serializer/JsonMessageEncoderTest.php index 849faba5..bca1443b 100644 --- a/tests/Unit/Message/Serializer/JsonMessageSerializerTest.php +++ b/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php @@ -13,7 +13,7 @@ use const JSON_THROW_ON_ERROR; -final class JsonMessageSerializerTest extends TestCase +final class JsonMessageEncoderTest extends TestCase { #[TestWith([''])] #[TestWith([1])] From 579a38998c56623fe33a78c690dcb6975e89ee3b Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 12:12:04 +0300 Subject: [PATCH 05/18] Simplify encoder --- src/Message/Serializer/JsonMessageEncoder.php | 36 ++---------- .../Serializer/MessageEncoderInterface.php | 28 +++------ src/Message/Serializer/MessageSerializer.php | 25 +++++++- .../Serializer/JsonMessageEncoderTest.php | 57 ++++++------------- .../Serializer/MessageSerializerTest.php | 44 ++++++++++++++ 5 files changed, 96 insertions(+), 94 deletions(-) diff --git a/src/Message/Serializer/JsonMessageEncoder.php b/src/Message/Serializer/JsonMessageEncoder.php index 08773b63..8bd42528 100644 --- a/src/Message/Serializer/JsonMessageEncoder.php +++ b/src/Message/Serializer/JsonMessageEncoder.php @@ -7,7 +7,6 @@ use JsonException; use function is_array; -use function is_string; use const JSON_THROW_ON_ERROR; @@ -16,48 +15,21 @@ */ final class JsonMessageEncoder implements MessageEncoderInterface { - public function encode(string $type, bool|int|float|string|array|null $data, array $metadata): string + public function encode(array $data): string { try { - return json_encode( - [ - 'type' => $type, - 'data' => $data, - 'meta' => $metadata, - ], - JSON_THROW_ON_ERROR, - ); + return json_encode($data, JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new MessageEncoderException($e->getMessage(), previous: $e); } } - public function decode(string $value): array + public function decode(string $value): mixed { try { - $payload = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { throw new MessageEncoderException($e->getMessage(), previous: $e); } - - if (!is_array($payload)) { - throw new MessageEncoderException('Payload must be array. Got ' . get_debug_type($payload) . '.'); - } - - $type = $payload['type'] ?? null; - if (!isset($type) || !is_string($type)) { - throw new MessageEncoderException('Message type must be a string. Got ' . get_debug_type($type) . '.'); - } - - $meta = $payload['meta'] ?? []; - if (!is_array($meta)) { - throw new MessageEncoderException('Metadata must be an array. Got ' . get_debug_type($meta) . '.'); - } - - return [ - $type, - $payload['data'] ?? null, - $meta, - ]; } } diff --git a/src/Message/Serializer/MessageEncoderInterface.php b/src/Message/Serializer/MessageEncoderInterface.php index 4fe55443..f63db200 100644 --- a/src/Message/Serializer/MessageEncoderInterface.php +++ b/src/Message/Serializer/MessageEncoderInterface.php @@ -4,40 +4,28 @@ namespace Yiisoft\Queue\Message\Serializer; -use Yiisoft\Queue\Message\MessageInterface; - /** - * Encodes and decodes raw message parts (type, data, metadata) to and from a string. - * - * @psalm-import-type MessageData from MessageInterface - * @psalm-import-type MessageMetadata from MessageInterface + * Encodes and decodes a data array to and from a string. */ interface MessageEncoderInterface { /** - * Encodes a message into a string representation. + * Encodes a data array into a string representation. * - * @param string $type Message type. - * @param bool|int|float|string|array|null $data Message payload data. - * @param array $metadata Message metadata. + * @param array $data Data to encode. Contains only scalars, nulls, and arrays — no objects or resources. * * @throws MessageEncoderException If encoding fails. - * - * @psalm-param MessageData $data - * @psalm-param MessageMetadata $metadata */ - public function encode(string $type, bool|int|float|string|array|null $data, array $metadata): string; + public function encode(array $data): string; /** - * Decodes a string representation back into message parts. + * Decodes a string representation back into a value. * - * @param string $value Encoded message string. + * @param string $value Encoded string. * - * @return array Tuple of type, data, and metadata. + * @return mixed Decoded data. * * @throws MessageEncoderException If decoding fails. - * - * @psalm-return list{string, MessageData, MessageMetadata} */ - public function decode(string $value): array; + public function decode(string $value): mixed; } diff --git a/src/Message/Serializer/MessageSerializer.php b/src/Message/Serializer/MessageSerializer.php index 1782c2c8..3681da2d 100644 --- a/src/Message/Serializer/MessageSerializer.php +++ b/src/Message/Serializer/MessageSerializer.php @@ -8,6 +8,7 @@ use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Message\MessageInterface; +use function is_array; use function is_string; /** @@ -41,7 +42,11 @@ public function serialize(MessageInterface $message): string : $message::class; } - return $this->encoder->encode($message->getType(), $message->getData(), $metadata); + return $this->encoder->encode([ + 'type' => $message->getType(), + 'data' => $message->getData(), + 'meta' => $metadata, + ]); } /** @@ -53,7 +58,21 @@ public function serialize(MessageInterface $message): string */ public function unserialize(string $value): MessageInterface { - [$type, $data, $metadata] = $this->encoder->decode($value); + $data = $this->encoder->decode($value); + + if (!is_array($data)) { + throw new MessageEncoderException('Decoded data must be array. Got ' . get_debug_type($data) . '.'); + } + + $type = $data['type'] ?? null; + if (!isset($type) || !is_string($type)) { + throw new MessageEncoderException('Message type must be a string. Got ' . get_debug_type($type) . '.'); + } + + $metadata = $data['meta'] ?? []; + if (!is_array($metadata)) { + throw new MessageEncoderException('Metadata must be an array. Got ' . get_debug_type($metadata) . '.'); + } $class = $metadata[self::META_MESSAGE_CLASS] ?? GenericMessage::class; @@ -65,6 +84,6 @@ public function unserialize(string $value): MessageInterface } /** @var class-string $class */ - return $class::fromData($type, $data)->withMetadata($metadata); + return $class::fromData($type, $data['data'] ?? null)->withMetadata($metadata); } } diff --git a/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php b/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php index bca1443b..f594776e 100644 --- a/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php +++ b/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php @@ -9,56 +9,35 @@ use Yiisoft\Queue\Message\Serializer\JsonMessageEncoder; use Yiisoft\Queue\Message\Serializer\MessageEncoderException; -use function sprintf; - use const JSON_THROW_ON_ERROR; final class JsonMessageEncoderTest extends TestCase { - #[TestWith([''])] - #[TestWith([1])] - #[TestWith([true])] - #[TestWith([null])] - public function testUnsupportedValue(mixed $raw): void + #[TestWith([[], '[]'])] + #[TestWith([['type' => 'test', 'data' => 'value', 'meta' => []], '{"type":"test","data":"value","meta":[]}'])] + #[TestWith([['num' => 42, 'flag' => true, 'nothing' => null], '{"num":42,"flag":true,"nothing":null}'])] + #[TestWith([['nested' => ['a' => 1, 'b' => 'str']], '{"nested":{"a":1,"b":"str"}}'])] + public function testEncode(array $data, string $expected): void { - $encoder = new JsonMessageEncoder(); - $value = json_encode($raw, JSON_THROW_ON_ERROR); - - $this->expectException(MessageEncoderException::class); - $this->expectExceptionMessage(sprintf('Payload must be array. Got %s.', get_debug_type($raw))); - $encoder->decode($value); + $this->assertSame($expected, (new JsonMessageEncoder())->encode($data)); } - #[TestWith([1])] - #[TestWith([true])] - #[TestWith([null])] - #[TestWith([[]])] - public function testUnsupportedType(mixed $type): void + #[TestWith(['[]', []])] + #[TestWith(['{"type":"test","data":"value","meta":[]}', ['type' => 'test', 'data' => 'value', 'meta' => []]])] + #[TestWith(['{"num":42,"flag":true,"nothing":null}', ['num' => 42, 'flag' => true, 'nothing' => null]])] + #[TestWith(['{"nested":{"a":1,"b":"str"}}', ['nested' => ['a' => 1, 'b' => 'str']]])] + #[TestWith(['"string"', 'string'])] + #[TestWith(['42', 42])] + #[TestWith(['true', true])] + #[TestWith(['null', null])] + public function testDecode(string $json, mixed $expected): void { - $encoder = new JsonMessageEncoder(); - $value = json_encode( - ['type' => $type, 'data' => 'test', 'meta' => []], - JSON_THROW_ON_ERROR, - ); - - $this->expectException(MessageEncoderException::class); - $this->expectExceptionMessage(sprintf('Message type must be a string. Got %s.', get_debug_type($type))); - $encoder->decode($value); + $this->assertSame($expected, (new JsonMessageEncoder())->decode($json)); } - #[TestWith([''])] - #[TestWith([1])] - #[TestWith([true])] - public function testUnsupportedMetadata(mixed $metadata): void + public function testDecodeInvalidJson(): void { - $encoder = new JsonMessageEncoder(); - $value = json_encode( - ['type' => 'test', 'data' => 'test', 'meta' => $metadata], - JSON_THROW_ON_ERROR, - ); - $this->expectException(MessageEncoderException::class); - $this->expectExceptionMessage(sprintf('Metadata must be an array. Got %s.', get_debug_type($metadata))); - $encoder->decode($value); + (new JsonMessageEncoder())->decode('{invalid}'); } } diff --git a/tests/Unit/Message/Serializer/MessageSerializerTest.php b/tests/Unit/Message/Serializer/MessageSerializerTest.php index d17548f5..e56df927 100644 --- a/tests/Unit/Message/Serializer/MessageSerializerTest.php +++ b/tests/Unit/Message/Serializer/MessageSerializerTest.php @@ -4,9 +4,11 @@ namespace Yiisoft\Queue\Tests\Unit\Message\Serializer; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Yiisoft\Queue\Message\IdEnvelope; use Yiisoft\Queue\Message\Serializer\JsonMessageEncoder; +use Yiisoft\Queue\Message\Serializer\MessageEncoderException; use Yiisoft\Queue\Message\Serializer\MessageSerializer; use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Tests\Unit\Support\TestMessage; @@ -17,6 +19,48 @@ final class MessageSerializerTest extends TestCase { + #[TestWith(['"string"'])] + #[TestWith(['42'])] + #[TestWith(['true'])] + #[TestWith(['null'])] + public function testNonArrayPayload(string $json): void + { + $this->expectException(MessageEncoderException::class); + $this->expectExceptionMessage('Decoded data must be array.'); + $this->createSerializer()->unserialize($json); + } + + #[TestWith([1])] + #[TestWith([true])] + #[TestWith([null])] + #[TestWith([[]])] + public function testUnsupportedType(mixed $type): void + { + $value = json_encode( + ['type' => $type, 'data' => 'test', 'meta' => []], + JSON_THROW_ON_ERROR, + ); + + $this->expectException(MessageEncoderException::class); + $this->expectExceptionMessage(sprintf('Message type must be a string. Got %s.', get_debug_type($type))); + $this->createSerializer()->unserialize($value); + } + + #[TestWith([''])] + #[TestWith([1])] + #[TestWith([true])] + public function testUnsupportedMetadata(mixed $metadata): void + { + $value = json_encode( + ['type' => 'test', 'data' => 'test', 'meta' => $metadata], + JSON_THROW_ON_ERROR, + ); + + $this->expectException(MessageEncoderException::class); + $this->expectExceptionMessage(sprintf('Metadata must be an array. Got %s.', get_debug_type($metadata))); + $this->createSerializer()->unserialize($value); + } + public function testDefaultMessageClassFallbackWrongClass(): void { $payload = [ From 5bf7f5c35bb32eedbccb69a2888f28dae785d84c Mon Sep 17 00:00:00 2001 From: vjik <525501+vjik@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:13:25 +0000 Subject: [PATCH 06/18] Apply PHP CS Fixer and Rector changes (CI) --- src/Message/Serializer/JsonMessageEncoder.php | 2 -- tests/Unit/Message/Serializer/JsonMessageEncoderTest.php | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/Message/Serializer/JsonMessageEncoder.php b/src/Message/Serializer/JsonMessageEncoder.php index 8bd42528..4a854036 100644 --- a/src/Message/Serializer/JsonMessageEncoder.php +++ b/src/Message/Serializer/JsonMessageEncoder.php @@ -6,8 +6,6 @@ use JsonException; -use function is_array; - use const JSON_THROW_ON_ERROR; /** diff --git a/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php b/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php index f594776e..6ab4c2d6 100644 --- a/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php +++ b/tests/Unit/Message/Serializer/JsonMessageEncoderTest.php @@ -9,8 +9,6 @@ use Yiisoft\Queue\Message\Serializer\JsonMessageEncoder; use Yiisoft\Queue\Message\Serializer\MessageEncoderException; -use const JSON_THROW_ON_ERROR; - final class JsonMessageEncoderTest extends TestCase { #[TestWith([[], '[]'])] From 3116307214a829b24b666ce9416ffff4ee565dd8 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 13:10:42 +0300 Subject: [PATCH 07/18] improve test --- .../Message/Serializer/MessageSerializerTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Unit/Message/Serializer/MessageSerializerTest.php b/tests/Unit/Message/Serializer/MessageSerializerTest.php index e56df927..4e0a2b5a 100644 --- a/tests/Unit/Message/Serializer/MessageSerializerTest.php +++ b/tests/Unit/Message/Serializer/MessageSerializerTest.php @@ -19,14 +19,14 @@ final class MessageSerializerTest extends TestCase { - #[TestWith(['"string"'])] - #[TestWith(['42'])] - #[TestWith(['true'])] - #[TestWith(['null'])] - public function testNonArrayPayload(string $json): void + #[TestWith(['"string"', 'string'])] + #[TestWith(['42', 'int'])] + #[TestWith(['true', 'bool'])] + #[TestWith(['null', 'null'])] + public function testNonArrayPayload(string $json, string $type): void { $this->expectException(MessageEncoderException::class); - $this->expectExceptionMessage('Decoded data must be array.'); + $this->expectExceptionMessage(sprintf('Decoded data must be array. Got %s.', $type)); $this->createSerializer()->unserialize($json); } From d511a6ef52c783b69f29b10788433afd5e074663 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 13:11:09 +0300 Subject: [PATCH 08/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/guide/en/messages-and-handlers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/en/messages-and-handlers.md b/docs/guide/en/messages-and-handlers.md index 330b6f09..1cbf600b 100644 --- a/docs/guide/en/messages-and-handlers.md +++ b/docs/guide/en/messages-and-handlers.md @@ -129,7 +129,7 @@ Because the payload is just data, any language can produce or consume it. A Pyth ## Why JSON is the default serialization -By default, `yiisoft/queue` serializes message payloads as JSON (`JsonMessageEncoder`). JSON was chosen intentionally: +By default, `yiisoft/queue` serializes messages using `MessageSerializer` with JSON (`JsonMessageEncoder`). JSON was chosen intentionally: - **Human-readable** — you can inspect a message in a broker dashboard without any tools. - **Language-agnostic** — every language and runtime can produce and parse JSON. From ec5099d409f9119bc5a6287c64124d31a50fa921 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 13:11:33 +0300 Subject: [PATCH 09/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Message/Serializer/MessageEncoderException.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Message/Serializer/MessageEncoderException.php b/src/Message/Serializer/MessageEncoderException.php index 22f30e0e..879f01b8 100644 --- a/src/Message/Serializer/MessageEncoderException.php +++ b/src/Message/Serializer/MessageEncoderException.php @@ -7,6 +7,6 @@ use RuntimeException; /** - * Thrown when a {@see MessageEncoderInterface} implementation fails to encode or decode a message. + * Thrown when message encoding/decoding fails, or when a decoded message has an invalid format. */ final class MessageEncoderException extends RuntimeException {} From 892c0aa13d67305e1a427d133603e3bc381c15fa Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 13:11:59 +0300 Subject: [PATCH 10/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Message/Serializer/MessageSerializer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Message/Serializer/MessageSerializer.php b/src/Message/Serializer/MessageSerializer.php index 3681da2d..869e633a 100644 --- a/src/Message/Serializer/MessageSerializer.php +++ b/src/Message/Serializer/MessageSerializer.php @@ -54,7 +54,7 @@ public function serialize(MessageInterface $message): string * * @param string $value Encoded message string. * - * @throws MessageEncoderException If decoding fails. + * @throws MessageEncoderException If decoding fails or the decoded payload has an invalid format. */ public function unserialize(string $value): MessageInterface { From 74285e004965db4d4377e1e8db06da134990c757 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 14:00:15 +0300 Subject: [PATCH 11/18] Add `MessageSerializerInterface` --- config/di.php | 3 ++ ...onsuming-messages-from-external-systems.md | 6 ++-- docs/guide/en/messages-and-handlers.md | 2 +- src/Message/Serializer/JsonMessageEncoder.php | 4 +-- .../Serializer/MessageEncoderException.php | 12 ------- .../Serializer/MessageEncoderInterface.php | 4 +-- src/Message/Serializer/MessageSerializer.php | 22 +++---------- .../Serializer/MessageSerializerException.php | 12 +++++++ .../Serializer/MessageSerializerInterface.php | 31 +++++++++++++++++++ .../Serializer/JsonMessageEncoderTest.php | 4 +-- .../Serializer/MessageSerializerTest.php | 8 ++--- 11 files changed, 64 insertions(+), 44 deletions(-) delete mode 100644 src/Message/Serializer/MessageEncoderException.php create mode 100644 src/Message/Serializer/MessageSerializerException.php create mode 100644 src/Message/Serializer/MessageSerializerInterface.php diff --git a/config/di.php b/config/di.php index b7762f9a..5747ab56 100644 --- a/config/di.php +++ b/config/di.php @@ -8,6 +8,8 @@ use Yiisoft\Queue\Cli\SimpleLoop; use Yiisoft\Queue\Message\Serializer\JsonMessageEncoder; use Yiisoft\Queue\Message\Serializer\MessageEncoderInterface; +use Yiisoft\Queue\Message\Serializer\MessageSerializer; +use Yiisoft\Queue\Message\Serializer\MessageSerializerInterface; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareDispatcher; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactory; use Yiisoft\Queue\Middleware\Consume\ConsumeMiddlewareFactoryInterface; @@ -46,4 +48,5 @@ '__construct()' => ['middlewareDefinitions' => $params['yiisoft/queue']['middlewares-fail']], ], MessageEncoderInterface::class => JsonMessageEncoder::class, + MessageSerializerInterface::class => MessageSerializer::class, ]; diff --git a/docs/guide/en/consuming-messages-from-external-systems.md b/docs/guide/en/consuming-messages-from-external-systems.md index 9d964ba3..41ab0dd7 100644 --- a/docs/guide/en/consuming-messages-from-external-systems.md +++ b/docs/guide/en/consuming-messages-from-external-systems.md @@ -5,13 +5,13 @@ This guide explains how to publish messages to a queue backend (RabbitMQ, Kafka, The key idea is simple: - The queue adapter reads a *raw payload* (usually a string) from the broker. -- The adapter passes that payload to a `Yiisoft\Queue\Message\Serializer\MessageSerializer`. +- The adapter passes that payload to a `Yiisoft\Queue\Message\Serializer\MessageSerializerInterface` implementation (by default `MessageSerializer`). - `MessageSerializer` delegates wire encoding to a `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` implementation. - By default, `yiisoft/queue` config binds `MessageEncoderInterface` to `Yiisoft\Queue\Message\Serializer\JsonMessageEncoder`. `JsonMessageEncoder` is only the default implementation. You can replace it with your own encoder by rebinding `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` in your DI configuration. -So, external systems should produce the **same payload format** that your consumer-side encoder expects (JSON described below is for the default `JsonMessageEncoder`). +So, external systems should produce the **same payload format** that `MessageSerializer` expects. The payload **shape** (`type`, `data`, `meta` keys) is defined by `MessageSerializer` regardless of the encoder; the encoder only converts between the wire representation and the internal array (JSON ↔ array by default). ## 1. Message type contract (most important part) @@ -35,7 +35,7 @@ External producer then always publishes `"type": "file-download"`. ## 2. JSON payload format (JsonMessageEncoder) -`Yiisoft\Queue\Message\Serializer\JsonMessageEncoder` expects the message body to be a JSON object with these keys: +`Yiisoft\Queue\Message\Serializer\MessageSerializer` expects the decoded payload to be an object with these keys (with the default `JsonMessageEncoder`, the wire format is JSON): - `type` (string, required) - `data` (any JSON value, optional; defaults to `null`) diff --git a/docs/guide/en/messages-and-handlers.md b/docs/guide/en/messages-and-handlers.md index 1cbf600b..22625516 100644 --- a/docs/guide/en/messages-and-handlers.md +++ b/docs/guide/en/messages-and-handlers.md @@ -136,7 +136,7 @@ By default, `yiisoft/queue` serializes messages using `MessageSerializer` with J - **Fast and lightweight** — no class metadata, no object graphs, no PHP-specific format. - **Forces payload discipline** — if your data cannot be expressed as a JSON-encodable value (strings, numbers, booleans, null, arrays, and objects), it is a sign the payload carries too much. Keep payloads simple: IDs, strings, primitive values. -You can replace `JsonMessageEncoder` with your own implementation by rebinding `MessageEncoderInterface` in DI, but the default works for the vast majority of use cases. +You can replace `JsonMessageEncoder` with your own implementation by rebinding `MessageEncoderInterface` in DI, but the default works for the vast majority of use cases. To replace the entire serialization strategy (not just the wire format), bind `MessageSerializerInterface` to your own implementation. ## Migration note: Yii2 queue diff --git a/src/Message/Serializer/JsonMessageEncoder.php b/src/Message/Serializer/JsonMessageEncoder.php index 4a854036..42c975bf 100644 --- a/src/Message/Serializer/JsonMessageEncoder.php +++ b/src/Message/Serializer/JsonMessageEncoder.php @@ -18,7 +18,7 @@ public function encode(array $data): string try { return json_encode($data, JSON_THROW_ON_ERROR); } catch (JsonException $e) { - throw new MessageEncoderException($e->getMessage(), previous: $e); + throw new MessageSerializerException($e->getMessage(), previous: $e); } } @@ -27,7 +27,7 @@ public function decode(string $value): mixed try { return json_decode($value, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { - throw new MessageEncoderException($e->getMessage(), previous: $e); + throw new MessageSerializerException($e->getMessage(), previous: $e); } } } diff --git a/src/Message/Serializer/MessageEncoderException.php b/src/Message/Serializer/MessageEncoderException.php deleted file mode 100644 index 879f01b8..00000000 --- a/src/Message/Serializer/MessageEncoderException.php +++ /dev/null @@ -1,12 +0,0 @@ -getMetadata(); @@ -49,29 +42,22 @@ public function serialize(MessageInterface $message): string ]); } - /** - * Unserializes a message from a string. - * - * @param string $value Encoded message string. - * - * @throws MessageEncoderException If decoding fails or the decoded payload has an invalid format. - */ public function unserialize(string $value): MessageInterface { $data = $this->encoder->decode($value); if (!is_array($data)) { - throw new MessageEncoderException('Decoded data must be array. Got ' . get_debug_type($data) . '.'); + throw new MessageSerializerException('Decoded data must be array. Got ' . get_debug_type($data) . '.'); } $type = $data['type'] ?? null; if (!isset($type) || !is_string($type)) { - throw new MessageEncoderException('Message type must be a string. Got ' . get_debug_type($type) . '.'); + throw new MessageSerializerException('Message type must be a string. Got ' . get_debug_type($type) . '.'); } $metadata = $data['meta'] ?? []; if (!is_array($metadata)) { - throw new MessageEncoderException('Metadata must be an array. Got ' . get_debug_type($metadata) . '.'); + throw new MessageSerializerException('Metadata must be an array. Got ' . get_debug_type($metadata) . '.'); } $class = $metadata[self::META_MESSAGE_CLASS] ?? GenericMessage::class; diff --git a/src/Message/Serializer/MessageSerializerException.php b/src/Message/Serializer/MessageSerializerException.php new file mode 100644 index 00000000..0d41b382 --- /dev/null +++ b/src/Message/Serializer/MessageSerializerException.php @@ -0,0 +1,12 @@ +expectException(MessageEncoderException::class); + $this->expectException(MessageSerializerException::class); (new JsonMessageEncoder())->decode('{invalid}'); } } diff --git a/tests/Unit/Message/Serializer/MessageSerializerTest.php b/tests/Unit/Message/Serializer/MessageSerializerTest.php index 4e0a2b5a..53c40a08 100644 --- a/tests/Unit/Message/Serializer/MessageSerializerTest.php +++ b/tests/Unit/Message/Serializer/MessageSerializerTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Queue\Message\IdEnvelope; use Yiisoft\Queue\Message\Serializer\JsonMessageEncoder; -use Yiisoft\Queue\Message\Serializer\MessageEncoderException; +use Yiisoft\Queue\Message\Serializer\MessageSerializerException; use Yiisoft\Queue\Message\Serializer\MessageSerializer; use Yiisoft\Queue\Message\GenericMessage; use Yiisoft\Queue\Tests\Unit\Support\TestMessage; @@ -25,7 +25,7 @@ final class MessageSerializerTest extends TestCase #[TestWith(['null', 'null'])] public function testNonArrayPayload(string $json, string $type): void { - $this->expectException(MessageEncoderException::class); + $this->expectException(MessageSerializerException::class); $this->expectExceptionMessage(sprintf('Decoded data must be array. Got %s.', $type)); $this->createSerializer()->unserialize($json); } @@ -41,7 +41,7 @@ public function testUnsupportedType(mixed $type): void JSON_THROW_ON_ERROR, ); - $this->expectException(MessageEncoderException::class); + $this->expectException(MessageSerializerException::class); $this->expectExceptionMessage(sprintf('Message type must be a string. Got %s.', get_debug_type($type))); $this->createSerializer()->unserialize($value); } @@ -56,7 +56,7 @@ public function testUnsupportedMetadata(mixed $metadata): void JSON_THROW_ON_ERROR, ); - $this->expectException(MessageEncoderException::class); + $this->expectException(MessageSerializerException::class); $this->expectExceptionMessage(sprintf('Metadata must be an array. Got %s.', get_debug_type($metadata))); $this->createSerializer()->unserialize($value); } From 5e656f692ce0a0f6bb30761b7571d05b1980da41 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 14:03:44 +0300 Subject: [PATCH 12/18] fix --- src/Message/Serializer/MessageSerializerInterface.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Message/Serializer/MessageSerializerInterface.php b/src/Message/Serializer/MessageSerializerInterface.php index c6515b09..75a38502 100644 --- a/src/Message/Serializer/MessageSerializerInterface.php +++ b/src/Message/Serializer/MessageSerializerInterface.php @@ -16,7 +16,7 @@ interface MessageSerializerInterface * * @param MessageInterface $message Message to serialize. * - * @throws MessageSerializerException If encoding fails. + * @throws MessageSerializerException If serialization fails. */ public function serialize(MessageInterface $message): string; @@ -25,7 +25,7 @@ public function serialize(MessageInterface $message): string; * * @param string $value Encoded message string. * - * @throws MessageSerializerException If decoding fails or the decoded payload has an invalid format. + * @throws MessageSerializerException If unserialization fails. */ public function unserialize(string $value): MessageInterface; } From baeb4e7419d0586a6ef5c2491a04905f03c7a848 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 15:34:48 +0300 Subject: [PATCH 13/18] Update src/Message/Serializer/MessageEncoderInterface.php Co-authored-by: Alexander Makarov --- src/Message/Serializer/MessageEncoderInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Message/Serializer/MessageEncoderInterface.php b/src/Message/Serializer/MessageEncoderInterface.php index f2690df1..5ee6b51a 100644 --- a/src/Message/Serializer/MessageEncoderInterface.php +++ b/src/Message/Serializer/MessageEncoderInterface.php @@ -12,7 +12,7 @@ interface MessageEncoderInterface /** * Encodes a data array into a string representation. * - * @param array $data Data to encode. Contains only scalars, nulls, and arrays — no objects or resources. + * @param array $data Data to encode. Contains only scalars, nulls, and arrays — no objects or resources including array contents. * * @throws MessageSerializerException If encoding fails. */ From 89356dfd3b9c55fb570883643a05b1dbad3e193f Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 16:02:29 +0300 Subject: [PATCH 14/18] fix --- docs/guide/en/consuming-messages-from-external-systems.md | 6 +++--- docs/guide/en/messages-and-handlers.md | 4 ++-- src/Message/Serializer/MessageSerializer.php | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/guide/en/consuming-messages-from-external-systems.md b/docs/guide/en/consuming-messages-from-external-systems.md index 41ab0dd7..2a3d2578 100644 --- a/docs/guide/en/consuming-messages-from-external-systems.md +++ b/docs/guide/en/consuming-messages-from-external-systems.md @@ -6,12 +6,12 @@ The key idea is simple: - The queue adapter reads a *raw payload* (usually a string) from the broker. - The adapter passes that payload to a `Yiisoft\Queue\Message\Serializer\MessageSerializerInterface` implementation (by default `MessageSerializer`). -- `MessageSerializer` delegates wire encoding to a `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` implementation. +- `MessageSerializer` delegates encoding to a `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` implementation. - By default, `yiisoft/queue` config binds `MessageEncoderInterface` to `Yiisoft\Queue\Message\Serializer\JsonMessageEncoder`. `JsonMessageEncoder` is only the default implementation. You can replace it with your own encoder by rebinding `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` in your DI configuration. -So, external systems should produce the **same payload format** that `MessageSerializer` expects. The payload **shape** (`type`, `data`, `meta` keys) is defined by `MessageSerializer` regardless of the encoder; the encoder only converts between the wire representation and the internal array (JSON ↔ array by default). +So, external systems should produce the **same payload format** that `MessageSerializer` expects. The payload **shape** (`type`, `data`, `meta` keys) is defined by `MessageSerializer` regardless of the encoder; the encoder only converts between the raw string and an associative array (JSON ↔ array by default). ## 1. Message type contract (most important part) @@ -35,7 +35,7 @@ External producer then always publishes `"type": "file-download"`. ## 2. JSON payload format (JsonMessageEncoder) -`Yiisoft\Queue\Message\Serializer\MessageSerializer` expects the decoded payload to be an object with these keys (with the default `JsonMessageEncoder`, the wire format is JSON): +`Yiisoft\Queue\Message\Serializer\MessageSerializer` expects the decoded payload to be an object with these keys (with the default `JsonMessageEncoder`, the message is a JSON string): - `type` (string, required) - `data` (any JSON value, optional; defaults to `null`) diff --git a/docs/guide/en/messages-and-handlers.md b/docs/guide/en/messages-and-handlers.md index 22625516..06fb88b4 100644 --- a/docs/guide/en/messages-and-handlers.md +++ b/docs/guide/en/messages-and-handlers.md @@ -125,7 +125,7 @@ When the producer and consumer live in different applications (or even different ### Cross-language interoperability -Because the payload is just data, any language can produce or consume it. A Python service or a Node.js microservice can push a `{"type":"send-email","data":{…}}` JSON object and `yiisoft/queue` will process it correctly. No PHP class names appear in the wire format. +Because the payload is just data, any language can produce or consume it. A Python service or a Node.js microservice can push a `{"type":"send-email","data":{…}}` JSON object and `yiisoft/queue` will process it correctly. No PHP class names appear in the serialized payload. ## Why JSON is the default serialization @@ -136,7 +136,7 @@ By default, `yiisoft/queue` serializes messages using `MessageSerializer` with J - **Fast and lightweight** — no class metadata, no object graphs, no PHP-specific format. - **Forces payload discipline** — if your data cannot be expressed as a JSON-encodable value (strings, numbers, booleans, null, arrays, and objects), it is a sign the payload carries too much. Keep payloads simple: IDs, strings, primitive values. -You can replace `JsonMessageEncoder` with your own implementation by rebinding `MessageEncoderInterface` in DI, but the default works for the vast majority of use cases. To replace the entire serialization strategy (not just the wire format), bind `MessageSerializerInterface` to your own implementation. +You can replace `JsonMessageEncoder` with your own implementation by rebinding `MessageEncoderInterface` in DI, but the default works for the vast majority of use cases. To replace the entire serialization strategy (not just the serialization format), bind `MessageSerializerInterface` to your own implementation. ## Migration note: Yii2 queue diff --git a/src/Message/Serializer/MessageSerializer.php b/src/Message/Serializer/MessageSerializer.php index 96474f67..d37f91f5 100644 --- a/src/Message/Serializer/MessageSerializer.php +++ b/src/Message/Serializer/MessageSerializer.php @@ -14,7 +14,7 @@ /** * Serializes and unserializes queue messages, preserving the original message class in metadata. * - * Delegates the wire format to {@see MessageEncoderInterface}. When unserializing, restores the original message class + * Delegates encoding to {@see MessageEncoderInterface}. When unserializing, restores the original message class * from metadata, falling back to {@see GenericMessage} if the class is missing or invalid. */ final class MessageSerializer implements MessageSerializerInterface From 58a0368724eb0ca86a7154d09abc95242900785e Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 16:07:20 +0300 Subject: [PATCH 15/18] fix --- src/Message/Serializer/MessageSerializer.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Message/Serializer/MessageSerializer.php b/src/Message/Serializer/MessageSerializer.php index d37f91f5..81cab490 100644 --- a/src/Message/Serializer/MessageSerializer.php +++ b/src/Message/Serializer/MessageSerializer.php @@ -14,8 +14,10 @@ /** * Serializes and unserializes queue messages, preserving the original message class in metadata. * - * Delegates encoding to {@see MessageEncoderInterface}. When unserializing, restores the original message class - * from metadata, falling back to {@see GenericMessage} if the class is missing or invalid. + * When serializing, assembles an array with `type`, `data`, and `meta` keys and passes it as a single array to + * {@see MessageEncoderInterface}, which encodes it to a string. When unserializing, decodes the string back to an + * array and reconstructs the original message class from the `meta` key, falling back to {@see GenericMessage} + * if the class is missing or invalid. */ final class MessageSerializer implements MessageSerializerInterface { From 218ea7590ab523c2f41aafd481d60d7419a09a10 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 12 Jun 2026 16:12:37 +0300 Subject: [PATCH 16/18] fix --- docs/guide/en/consuming-messages-from-external-systems.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/en/consuming-messages-from-external-systems.md b/docs/guide/en/consuming-messages-from-external-systems.md index 2a3d2578..06af8173 100644 --- a/docs/guide/en/consuming-messages-from-external-systems.md +++ b/docs/guide/en/consuming-messages-from-external-systems.md @@ -6,7 +6,7 @@ The key idea is simple: - The queue adapter reads a *raw payload* (usually a string) from the broker. - The adapter passes that payload to a `Yiisoft\Queue\Message\Serializer\MessageSerializerInterface` implementation (by default `MessageSerializer`). -- `MessageSerializer` delegates encoding to a `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` implementation. +- `MessageSerializer` assembles the message into an array and delegates encoding it to/from a string to a `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` implementation. - By default, `yiisoft/queue` config binds `MessageEncoderInterface` to `Yiisoft\Queue\Message\Serializer\JsonMessageEncoder`. `JsonMessageEncoder` is only the default implementation. You can replace it with your own encoder by rebinding `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` in your DI configuration. From 8bbfbb119f4c2294b3413c18c793267104c8551a Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sat, 13 Jun 2026 08:44:16 +0300 Subject: [PATCH 17/18] Update docs/guide/en/consuming-messages-from-external-systems.md Co-authored-by: Alexander Makarov --- docs/guide/en/consuming-messages-from-external-systems.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/en/consuming-messages-from-external-systems.md b/docs/guide/en/consuming-messages-from-external-systems.md index 06af8173..07b14bf9 100644 --- a/docs/guide/en/consuming-messages-from-external-systems.md +++ b/docs/guide/en/consuming-messages-from-external-systems.md @@ -6,7 +6,7 @@ The key idea is simple: - The queue adapter reads a *raw payload* (usually a string) from the broker. - The adapter passes that payload to a `Yiisoft\Queue\Message\Serializer\MessageSerializerInterface` implementation (by default `MessageSerializer`). -- `MessageSerializer` assembles the message into an array and delegates encoding it to/from a string to a `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` implementation. +- `MessageSerializer` deserializes the payload into an array. It delegates decoding of payload format to `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` implementation. - By default, `yiisoft/queue` config binds `MessageEncoderInterface` to `Yiisoft\Queue\Message\Serializer\JsonMessageEncoder`. `JsonMessageEncoder` is only the default implementation. You can replace it with your own encoder by rebinding `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` in your DI configuration. From 721d2ca760d4a82eaa5c21c75966c09eed30d2ad Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Sat, 13 Jun 2026 08:44:29 +0300 Subject: [PATCH 18/18] Update docs/guide/en/consuming-messages-from-external-systems.md Co-authored-by: Alexander Makarov --- docs/guide/en/consuming-messages-from-external-systems.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/en/consuming-messages-from-external-systems.md b/docs/guide/en/consuming-messages-from-external-systems.md index 07b14bf9..27e67599 100644 --- a/docs/guide/en/consuming-messages-from-external-systems.md +++ b/docs/guide/en/consuming-messages-from-external-systems.md @@ -11,7 +11,7 @@ The key idea is simple: `JsonMessageEncoder` is only the default implementation. You can replace it with your own encoder by rebinding `Yiisoft\Queue\Message\Serializer\MessageEncoderInterface` in your DI configuration. -So, external systems should produce the **same payload format** that `MessageSerializer` expects. The payload **shape** (`type`, `data`, `meta` keys) is defined by `MessageSerializer` regardless of the encoder; the encoder only converts between the raw string and an associative array (JSON ↔ array by default). +External systems should produce the **same payload format** that `MessageSerializer` expects. The payload **shape** (`type`, `data`, `meta` keys) is defined by `MessageSerializer` regardless of the encoder; the encoder only converts between the raw string and an associative array (JSON ↔ array by default). ## 1. Message type contract (most important part)