A few months ago I was tracing a bug in a system that uses RabbitMQ to pass events between a Laravel producer and a consumer worker. The consumer was silently writing null to a database column that should never be null. No exception. No dead-letter queue entry. Just null, over and over, for hours before anyone noticed.
The cause was a one-line change on the producer side. Someone renamed a payload field from user_id to userId. The producer had been updated. The consumer had not. RabbitMQ delivered every message without complaint.
This is the core problem with async messaging: the transport does not validate your payload. It delivers whatever you give it.
The transport layer does not know your schema
Every message broker I have worked with — RabbitMQ, AWS SQS, Kafka, Redis Streams — has the same behaviour at the payload level. You publish bytes. The broker routes those bytes. The consumer reads those bytes. At no point does the broker know or care what those bytes mean.
This is by design. Brokers are infrastructure. Schema governance is an application concern. But that division of responsibility has a practical consequence: there is nothing in the transport layer to prevent a producer from publishing a payload that a consumer cannot process.
In a monolith, function signatures enforce this contract. If you change the shape of data a function receives, the compiler or the test suite tells you. In a distributed system with asynchronous messaging, that safety net disappears. The producer and consumer are separate deployments. They share a queue, not a codebase.
What happens without contracts
Without a contract layer, teams typically end up in one of two places.
The first is implicit validation on the consumer side. Each consumer checks isset($data['user_id']) and similar guards before using payload fields. This works until the producer changes the field name, removes a field, or changes a type. The consumer does not know the payload changed. The missing field returns null or a default. The bug is silent.
The second is shared documentation — a Confluence page or a README listing the expected payload shape for each event. This works until the documentation drifts from the code, which it always does. Teams trust the documentation, not the producer. The producer changes. The documentation does not.
Both patterns produce the same outcome: schema mismatches that surface as data corruption in production rather than as validation errors at the boundary.
Contracts belong in the application layer
The fix is to move schema enforcement into the application, on both sides of the message boundary.
Before a producer publishes a message, it should validate the payload against the contract. If the payload is invalid, the publish fails loudly.
Before a consumer processes a message, it should validate the payload against the same contract. If the payload is invalid, it fails loudly — goes to the dead-letter queue, raises an alert, something visible.
The same contract class should govern both sides. One source of truth. If you change the contract, both sides know.
This is what Laravel Message Contracts provides.
How it works
Each message type is a PHP class that extends MessageContract. The class declares three things: a contract identifier, a version integer, and a set of Laravel validation rules.
class UserRegisteredV1Message extends MessageContract
{
public static function contract(): string { return 'app.user.registered'; }
public static function version(): int { return 1; }
public static function rules(): array
{
return [
'user_id' => ['required', 'integer'],
'email' => ['required', 'email'],
'name' => ['required', 'string'],
];
}
}
On the producer, you call ::message($data). The package validates the payload against the rules before wrapping it in a standard envelope and serialising it.
$message = UserRegisteredV1Message::message([
'user_id' => 123,
'email' => 'user@example.com',
'name' => 'Alice',
]);
$broker->publish('user.events', $message->toJson());
The wire format is always the same envelope:
{
"contract": "app.user.registered",
"version": 1,
"payload": { "user_id": 123, "email": "user@example.com", "name": "Alice" }
}
On the consumer, you resolve the contract from the envelope and validate:
$message = Message::fromJson($raw);
$message->validateOrFail();
$userId = $message->payload('user_id');
If the producer had renamed user_id to userId without updating the contract class, validateOrFail() would throw. The consumer would send the message to the dead-letter queue. The bug would be visible immediately, not hours later.
Versioning without breaking consumers
In any real system, message shapes evolve. Users get new fields. Events gain metadata. Changing a contract and deploying producer and consumer simultaneously is rarely practical in a rolling deploy.
The package handles this with explicit versioning by class. UserRegisteredV1Message and UserRegisteredV2Message are both registered. Producers emit the version they support. Consumers declare the version they consume. During migration, the producer can emit V2 while some consumers still run V1. Once all consumers have upgraded, V1 can be retired.
This is more explicit than a semver field in a config file. The version is in the class name. It is impossible to accidentally use the wrong version; the type system catches it.
Breaking change detection in CI
One of the Artisan commands — contracts:check-breaking — serialises the current contract rules and compares them against a stored snapshot. It reports added required fields, removed fields, and type changes.
php artisan contracts:check-breaking
# app.user.registered (V1)
# BREAKING: Field 'email' removed (was required)
# WARNING: Field 'avatar_url' added (optional — safe)
# Exit code: 1
I run this in CI before every deploy. Breaking changes fail the build. This is the place where the bug I described at the start of this post would have been caught — at git push, not in production.
Documentation that stays current
Two more commands are worth mentioning.
contracts:export-asyncapi generates a valid AsyncAPI 2.6.0 document describing every registered contract — channels, messages, payload schemas. I use this as the source of truth for inter-service documentation. It stays accurate because it generates from the contract classes, not from a manually maintained YAML file.
contracts:export-schema {contract} generates a JSON Schema file for a single contract. Non-PHP consumers — a Node.js worker, a Python service — can validate incoming payloads against the same schema the Laravel producer enforces. One schema, multiple runtimes.
The real benefit
The bug I traced at the start took about three hours to identify and roll back. The actual change that caused it — one renamed field — took thirty seconds.
With contracts in place, the producer would have thrown a validation error before the payload left the application. The CI breaking-change check would have caught it before the deploy. The consumer would have rejected any message that slipped through and sent it to the dead-letter queue.
Contracts do not make messaging systems simpler. They add explicit structure. But that structure is what gives you confidence that a payload change on the producer side will surface as a loud, immediate failure rather than a silent, hours-long data corruption incident.
The package is open source at github.com/satheez/laravel-message-contracts.