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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions config/di.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
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\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;
Expand Down Expand Up @@ -45,5 +47,6 @@
FailureMiddlewareDispatcher::class => [
'__construct()' => ['middlewareDefinitions' => $params['yiisoft/queue']['middlewares-fail']],
],
MessageSerializerInterface::class => JsonMessageSerializer::class,
MessageEncoderInterface::class => JsonMessageEncoder::class,
MessageSerializerInterface::class => MessageSerializer::class,
];
2 changes: 1 addition & 1 deletion docs/guide/en/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 7 additions & 6 deletions docs/guide/en/consuming-messages-from-external-systems.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\MessageSerializerInterface` implementation (by default `MessageSerializer`).
- `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`.

`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`).
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).

Comment thread
vjik marked this conversation as resolved.
## 1. Message type contract (most important part)

Expand All @@ -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\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`)
Expand Down
6 changes: 3 additions & 3 deletions docs/guide/en/messages-and-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,18 @@ 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

By default, `yiisoft/queue` serializes message payloads as JSON (`JsonMessageSerializer`). 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.
- **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. To replace the entire serialization strategy (not just the serialization format), bind `MessageSerializerInterface` to your own implementation.

## Migration note: Yii2 queue

Expand Down
68 changes: 0 additions & 68 deletions src/Message/JsonMessageSerializer.php

This file was deleted.

12 changes: 0 additions & 12 deletions src/Message/MessageSerializerInterface.php

This file was deleted.

33 changes: 33 additions & 0 deletions src/Message/Serializer/JsonMessageEncoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Queue\Message\Serializer;

use JsonException;

use const JSON_THROW_ON_ERROR;

/**
* Encodes and decodes queue messages using JSON format.
*/
final class JsonMessageEncoder implements MessageEncoderInterface
{
public function encode(array $data): string
{
try {
return json_encode($data, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new MessageSerializerException($e->getMessage(), previous: $e);
}
}

public function decode(string $value): mixed
{
try {
return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new MessageSerializerException($e->getMessage(), previous: $e);
}
}
}
31 changes: 31 additions & 0 deletions src/Message/Serializer/MessageEncoderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Queue\Message\Serializer;

/**
* Encodes and decodes a data array to and from a string.
*/
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 including array contents.
*
* @throws MessageSerializerException If encoding fails.
*/
public function encode(array $data): string;

/**
* Decodes a string representation back into a value.
*
* @param string $value Encoded string.
*
* @return mixed Decoded data.
*
* @throws MessageSerializerException If decoding fails.
*/
public function decode(string $value): mixed;
}
77 changes: 77 additions & 0 deletions src/Message/Serializer/MessageSerializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Queue\Message\Serializer;

use Yiisoft\Queue\Message\Envelope;
use Yiisoft\Queue\Message\GenericMessage;
use Yiisoft\Queue\Message\MessageInterface;

use function is_array;
use function is_string;

/**
* Serializes and unserializes queue messages, preserving the original message class in metadata.
Comment thread
samdark marked this conversation as resolved.
*
* 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
{
private const META_MESSAGE_CLASS = 'message-class';

public function __construct(
private readonly MessageEncoderInterface $encoder,
) {}

public function serialize(MessageInterface $message): string
{
$metadata = $message->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([
'type' => $message->getType(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type should be enough to get the message class from the mapping.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use one class with different types. For example:

$message1 = new GenericMessage(type: 'send-email', ...);
$message2 = new GenericMessage(type: 'process-order', ...);

@samdark samdark Jun 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is OK. Message payload will contain send-email, process-order and both will resolve to the same class via mapping (or to GenericMessage if either type is not there or there's no mapping for it).

'data' => $message->getData(),
'meta' => $metadata,
]);
}

public function unserialize(string $value): MessageInterface
{
$data = $this->encoder->decode($value);

if (!is_array($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 MessageSerializerException('Message type must be a string. Got ' . get_debug_type($type) . '.');
}

$metadata = $data['meta'] ?? [];
if (!is_array($metadata)) {
throw new MessageSerializerException('Metadata must be an array. Got ' . get_debug_type($metadata) . '.');
}

$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<MessageInterface> $class */

return $class::fromData($type, $data['data'] ?? null)->withMetadata($metadata);
}
}
12 changes: 12 additions & 0 deletions src/Message/Serializer/MessageSerializerException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Queue\Message\Serializer;

use RuntimeException;

/**
* Thrown when message serialization/unserialization fails.
*/
final class MessageSerializerException extends RuntimeException {}
31 changes: 31 additions & 0 deletions src/Message/Serializer/MessageSerializerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Queue\Message\Serializer;

use Yiisoft\Queue\Message\MessageInterface;

/**
* Serializes and unserializes queue messages to and from a string representation.
*/
interface MessageSerializerInterface
{
/**
* Serializes a message to a string.
*
* @param MessageInterface $message Message to serialize.
*
* @throws MessageSerializerException If serialization fails.
*/
public function serialize(MessageInterface $message): string;

/**
* Unserializes a message from a string.
*
* @param string $value Encoded message string.
*
* @throws MessageSerializerException If unserialization fails.
*/
public function unserialize(string $value): MessageInterface;
}
7 changes: 4 additions & 3 deletions tests/Benchmark/QueueBench.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +30,7 @@
final class QueueBench
{
private readonly QueueInterface $queue;
private readonly JsonMessageSerializer $serializer;
private readonly MessageSerializer $serializer;
Comment thread
vjik marked this conversation as resolved.
private readonly VoidAdapter $adapter;

public function __construct()
Expand All @@ -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(
Expand Down
Loading
Loading