Laravel Message Contracts
Contracts enforce validated, versioned message payloads across Laravel services so schema drift fails loudly at the boundary instead of silently corrupting data downstream.
Problem
In a distributed system with asynchronous messaging, the transport layer does not care what you put in the payload. RabbitMQ, SQS, Kafka, Redis Streams — they will all deliver a malformed JSON blob just as reliably as a valid one. Schema incompatibilities between producer and consumer are not caught at publish time. They surface later: a null value where a required field was expected, an integer where a string was needed, a renamed key that a downstream consumer never receives.
The questions that matter when building async services are:
- If the producer changes the payload shape, how does the consumer know?
- How do you run two versions of a contract simultaneously during a rolling deploy?
- How do you validate on both sides — before publishing and before processing — without duplicating logic?
- How do you generate AsyncAPI documentation from the same source of truth as your validation rules?
- How do you detect breaking changes before they reach production?
Laravel has strong validation primitives, but nothing in the framework applies them to inter-service message payloads. Without a contract layer, teams end up with validation scattered across consumers, schema documentation that drifts from reality, and incidents traced to a payload change nobody noticed.
Constraints
- Contract-as-class. Each message shape must be a first-class PHP object with explicit
contract(),version(), andrules()methods — not a config array or a JSON file. - Standard envelope. Every message must carry a canonical wrapper —
{ "contract": "...", "version": 1, "payload": {...} }— so consumers can route and validate without inspecting raw content. - Transport-agnostic. The package must work regardless of the broker. It handles the payload contract; it does not touch the connection.
- Bidirectional validation. The same contract class validates outgoing messages on the producer and incoming messages on the consumer. One source of truth.
- Versioning without breaking consumers. A V2 contract can coexist with V1. Consumers declare which version they consume. Producers emit the version they support. No forced simultaneous upgrade.
- Testable. The package must ship test utilities that let teams assert against contract shape in Pest or PHPUnit without setting up a broker.
- Artisan-first. All tooling — contract generation, listing, schema export, breaking change detection, AsyncAPI docs — must be reachable from
php artisan.
Key decisions
Contract class as the single source of truth. The abstract MessageContract base class forces every message type to declare its identifier string, version integer, and Laravel validation rules. Validation, serialisation, versioning, documentation generation, and breaking-change detection all derive from the same class. Duplicating this in separate schema files would create drift.
Standard envelope wraps every payload. { "contract": "app.user.registered", "version": 1, "payload": { ... } } is the wire format for every message. The envelope allows consumers to resolve the correct contract class before validation, and allows routers to dispatch without inspecting raw payload keys. Envelope structure is fixed; payload contents are contract-defined.
Bidirectional validation on one contract class. UserRegisteredV1Message::message($data) validates outgoing data on the producer before serialisation. Message::fromJson($json)->validateOrFail() resolves and validates incoming data on the consumer. Same rules, same class, same failure modes. There is no separate producer schema and consumer schema to keep in sync.
Explicit versioning with V1, V2 class conventions. Contracts are versioned by class — UserRegisteredV1Message, UserRegisteredV2Message. The registry maps both. Consumers pin to the version they consume. Producers emit the version they publish. This allows phased migrations: publish V2, let consumers upgrade incrementally, then retire V1 — without requiring a coordinated deploy.
Breaking change detection via snapshot comparison. php artisan contracts:check-breaking serialises current contract rules and compares against a stored snapshot. It reports added required fields, removed fields, and type changes. This runs in CI before deploy, not after an incident.
AsyncAPI 2.6.0 documentation generated from contracts. php artisan contracts:export-asyncapi walks the registry and emits a valid AsyncAPI document describing every channel, message, and payload schema. The document stays current because it derives from the contract classes, not from manually maintained YAML.
JSON Schema export for cross-language consumers. php artisan contracts:export-schema {contract} generates a JSON Schema file for a single contract. Non-PHP consumers — Node.js workers, Python services, Go microservices — can validate against the same schema the Laravel producer enforces.
Spatie Laravel Data integration. If a team already uses Spatie’s Data objects, a contract can wrap an existing Data class and inherit its validation rules. No duplication.
MessageAssert test helper for Pest and PHPUnit. MessageAssert::for(UserRegisteredV1Message::class)->assertValid($payload)->assertInvalid($incompletePayload) covers contract validation in a test without instantiating the full service container or a broker connection.
Example usage
# Install
composer require satheez/laravel-message-contracts
# Generate a new contract class
php artisan contracts:make UserRegistered --version=1
# List all registered contracts
php artisan contracts:list
# Validate a raw JSON payload against a contract
php artisan contracts:validate app.user.registered --version=1 --file=payload.json
# Export JSON Schema for a contract
php artisan contracts:export-schema app.user.registered
# Generate AsyncAPI 2.6.0 documentation
php artisan contracts:export-asyncapi --output=asyncapi.yaml
# Check for breaking changes against the stored snapshot
php artisan contracts:check-breaking
Producer:
$message = UserRegisteredV1Message::message([
'user_id' => 123,
'email' => 'user@example.com',
'name' => 'Alice',
]);
// $message->toJson() → {"contract":"app.user.registered","version":1,"payload":{...}}
$broker->publish('user.events', $message->toJson());
Consumer:
$raw = $broker->consume('user.events');
$message = Message::fromJson($raw);
$message->validateOrFail(); // throws if payload violates contract rules
$userId = $message->payload('user_id');
$email = $message->payload('email');
Test:
it('rejects registration message without email', function () {
MessageAssert::for(UserRegisteredV1Message::class)
->assertInvalid(['user_id' => 123, 'name' => 'Alice']); // missing email
});
Outcome
Schema mismatches in async systems are now caught at the boundary — either before publish on the producer side, or at consume time on the consumer side — rather than as silent data corruption downstream. A single contract class governs validation, serialisation, versioning, documentation, and breaking-change detection. Teams can evolve message shapes across versions without coordinated deploys. AsyncAPI documentation stays current because it generates from the same PHP classes that enforce the rules. Breaking changes surface in CI, not in production logs.
What I’d do differently
The versioning system uses distinct class names (e.g., V1, V2), which is simple and robust but can lead to class duplication in rapidly evolving systems. If I were rebuilding it today, I would explore attribute-based version routing within a single contract class to keep the codebase flatter. Additionally, while the JSON Schema export is useful for cross-language consumers, automating schema publishing directly to an external schema registry during CI would make multi-language environments much smoother to manage.