Every unit of work in a queue is called a message. A message has two independent parts:
- Message — a lightweight, serializable payload that describes what needs to be done and carries the data needed to do it. The message knows nothing about how it will be processed.
- Handler — a piece of code that receives the message and performs the actual work.
This separation is intentional and important. Understanding it will save you from confusion when configuring producers, consumers, and cross-application queues.
A producer creates messages and pushes them onto the queue. A consumer (worker) pulls messages from the queue and invokes the matching handler.
flowchart LR
subgraph Producer["Producer side"]
Message["new SendEmailMessage(...)\n(payload only)"]
end
subgraph Consumer["Consumer side"]
Worker["Worker resolves handler"]
Handler["Handler handles\n(logic only)"]
end
Message --> Worker --> Handler
The producer only needs to know the message type and its data. It does not need to know anything about how the message will be processed, or even in which application.
The consumer only needs to know how to handle a message by type. It does not need to know where the message came from.
This means the producer and consumer can be:
- The same class in the same application (most common case).
- Different classes in the same application.
- Completely different applications, possibly written in different languages.
A message carries just enough data to perform the work. Usually data has some parameters but not the full context to process. Getting full context is better to be moved to the handler unless processing is done in another application that doesn't have access to data storage. Defining a dedicated class for each message type makes your code self-documenting and type-safe:
use Yiisoft\Queue\Message\Message;
final class SendEmailMessage extends Message
{
public const TYPE = 'send-email';
public function __construct(
public readonly string $to,
public readonly string $subject,
public readonly string $body,
) {}
public static function fromData(string $type, mixed $data): static
{
if ($type !== self::TYPE) {
throw new \InvalidArgumentException("Expected type \"" . self::TYPE . "\", got \"$type\".");
}
if (!is_array($data)
|| !is_string($data['to'] ?? null)
|| !is_string($data['subject'] ?? null)
|| !is_string($data['body'] ?? null)
) {
throw new \InvalidArgumentException('Invalid data for ' . self::class . '.');
}
return new self($data['to'], $data['subject'], $data['body']);
}
public function getType(): string
{
return self::TYPE;
}
public function getData(): array
{
return ['to' => $this->to, 'subject' => $this->subject, 'body' => $this->body];
}
}Usage:
new SendEmailMessage('user@example.com', 'Welcome', 'Thank you for registering.');The message has:
- A message type — a string used by the worker to look up the correct handler.
- A data payload — typed properties serialized to JSON via
getData(). Must be JSON-encodable.
The message has no business logic, no dependencies. It is a value object — a typed data wrapper.
The handler receives the message and acts on it:
final class SendEmailHandler implements \Yiisoft\Queue\Message\MessageHandlerInterface
{
public function __construct(private Mailer $mailer) {}
public function handle(\Yiisoft\Queue\Message\MessageInterface $message): void
{
assert($message instanceof SendEmailMessage);
$this->mailer->send($message->to, $message->subject, $message->body);
}
}The handler can have any dependencies injected through the DI container. The message payload remains plain data.
When payload and logic are one object, renaming a class or changing its constructor breaks all messages that are already sitting in the queue. With separated payload you can evolve the handler independently: rename it, replace it, or run multiple handler versions side by side, as long as the message type and data contract stay compatible.
When the producer and consumer live in different applications (or even different repos), the producer cannot import the consumer's handler classes. With the separated model the producer only sends a type + data; the consumer maps that type to a local handler class. No shared class dependencies are needed.
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.
By default, yiisoft/queue serializes message payloads as JSON (JsonMessageSerializer). 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.
In yii2-queue, a job was a single PHP object that contained both the payload and the execution logic in one class (via the JobInterface::execute() method):
// Yii2 style — payload and logic in one class
class SendEmailJob implements JobInterface
{
public string $to;
public string $subject;
public function execute($queue): void
{
Yii::$app->mailer->send($this->to, $this->subject);
}
}This looked convenient at first but created real problems:
- The handler class had to be available on both the producer and the consumer. In a microservice setup this forced sharing a PHP class (and its dependency tree) across applications.
- PHP class names were baked into the serialized payload. Renaming a class without a migration was risky. Versioning required workarounds.
- The message carried behavior, making it impossible to produce or consume messages from non-PHP services without custom serializers.
- Testing was harder — you had to mock application services inside the message class.
yiisoft/queue solves all of these by keeping the message as pure data and the handler as pure logic. The two evolve independently and can live in separate codebases.
- Message handler — zero-config FQCN handlers for single-application use.
- Message handler: advanced setup — handler definitions by message type, callable definitions, and cross-application mapping.
- Consuming messages from external systems — producing valid JSON payloads from non-PHP services.