Domain Communication
What this is
When code in one domain needs work from another, there are three sanctioned shapes the call can take: a direct Contract call (synchronous, blocking), an event (asynchronous, fan-out), or a queued job (background, retry-able). Each one ships work across a domain boundary differently, and each one comes with its own trade-offs.
Picking the wrong shape produces real bugs. A synchronous call that should have been queued slows the response. A queued job that should have been synchronous commits the outer transaction before its downstream work runs. An event used where a direct call belongs hides the dependency graph and turns every "who calls this?" question into a repo-wide search. The decision is small, but worth making consciously.
The default, when in doubt, is a direct Contract call. Move to an event or a job only once you have evidence — a slow controller, an actual second subscriber, a real retry requirement.
The shape
The same CreateOrder Action, showing all three in one place:
final class CreateOrder
{
public static function handle(CreateOrderData $data): Order
{
return DB::transaction(function () use ($data) {
// 1. Direct Contract call — synchronous, blocks the request, lives in the transaction.
$provider = app(ProviderContract::class)->getProvider($data->provider_id);
$order = Order::create([
'provider_id' => $provider->id,
'customer_id' => $data->customer_id,
'amount' => $data->amount,
]);
// 2. Direct Contract call — must succeed inside this transaction, so still synchronous.
app(AccountingContract::class)->recordTransaction($order->id, $data->amount);
// 3. Event — other domains may want to react; CreateOrder shouldn't know who.
OrderCreated::dispatch($order->id, $order->provider_id, $order->amount);
// 4. Job — slow downstream work (PDF, external API) that shouldn't block the response.
ProcessOrder::dispatch($order->id);
return $order;
});
}
}
Four hops, three patterns. The Provider fetch and the Accounting write both have to complete before the order returns — direct calls. OrderCreated is a fact other domains may care about; the publisher doesn't list them — event. ProcessOrder is heavy work that mustn't slow the HTTP response — job.
Pattern 1 — Direct Contract call (synchronous)
For operations that must complete in the current request. The caller blocks on the result, and any failure rolls back the surrounding transaction.
$provider = app(ProviderContract::class)->getProvider($data->provider_id);
app(AccountingContract::class)->recordTransaction($order->id, $data->amount);
This is the default when you're unsure. The trade-off you accept: the caller pays the latency of the downstream call, and the whole flow lives in one transaction. That's usually what you want — fewer moving parts, no eventual-consistency window, easy to test.
Pattern 2 — Events (asynchronous, decoupled)
For facts that other domains may opt into without the publisher knowing who listens. The event carries scalar data only — no Eloquent Models. Listeners rehydrate state by resolving the upstream Contract themselves.
✅ Correct — scalar payload, listener re-fetches
final class OrderCreated
{
public function __construct(
public readonly int $order_id,
public readonly int $provider_id,
public readonly float $amount,
) {}
}
// In the publishing Action:
OrderCreated::dispatch($order->id, $order->provider_id, $order->amount);
The listener lives in the subscribing domain:
final class RecordOrderTransaction
{
public function handle(OrderCreated $event): void
{
app(AccountingContract::class)->recordTransaction($event->order_id, $event->amount);
}
}
❌ Wrong — Model in the event payload
final class OrderCreated
{
public function __construct(public readonly Order $order) {}
}
OrderCreated::dispatch($order);
The wrong version serialises the Model (and its loaded relations) through the queue, couples the listener to the upstream's storage schema, and produces stale-data bugs when the job retries minutes after the data has moved on. Scalars don't have those problems.
Pattern 3 — Jobs (background, queued)
For heavy work that must survive process death and supports retry. Pass IDs, not Models, and rehydrate inside handle().
✅ Correct
final class ProcessOrder implements ShouldQueue
{
public function __construct(private readonly int $order_id) {}
public function handle(): void
{
app(OrderContract::class)->processOrder($this->order_id);
}
}
// Dispatched from an Action:
ProcessOrder::dispatch($order->id);
❌ Wrong — Model serialised into the job
final class ProcessOrder implements ShouldQueue
{
public function __construct(private readonly Order $order) {}
public function handle(): void
{
$this->order->status = 'processing';
$this->order->save(); // stale; another process may have moved it.
}
}
The wrong version is a stale-data factory. By the time the worker picks the job up — seconds to minutes later, longer on retry — the Order's row may have changed. The serialised copy doesn't know. Pass $order->id, re-fetch in handle(), work on fresh state.
SomeJob::dispatch() directly bypasses the domain's decision-making.defer() over a queued job. A job has overhead (serialise, hop to Redis, schedule a worker) that doesn't pay off for a sub-second one-shot operation like firing an analytics ping.Picking the right pattern
A useful rule of thumb:
- The caller needs the result, or the work must succeed inside the same transaction → direct Contract call.
- Other domains may want to react and the publisher should not know who listens → event.
- The work is slow (PDF render, external API, bulk write), can fail and retry, or shouldn't slow the response → job.
Side-by-side:
| Pattern | Latency | Survives process death | Other domains can subscribe | Use when |
|---|---|---|---|---|
| Direct Contract call | inline | n/a | no | caller blocks on the result, or it must be in the same transaction |
| Event | inline (sync listener) or queued (queue listener) | depends on listener config | yes | other domains may want to react and the publisher shouldn't know who |
| Job | queued | yes | no | work is slow, can fail and retry, or shouldn't block the response |
When in doubt, start with a direct call. It's the easiest to reason about and the easiest to refactor. Move to an event or a job once the evidence justifies it.
The ID-not-Model rule (events and jobs)
Both events and jobs serialise their payload — events because queue listeners are common, jobs because that's what ShouldQueue does. Eloquent Models do not serialise cleanly:
- They drag loaded relations along with them, bloating the payload.
- They snapshot the row at dispatch time; by the time the worker runs, the row may have moved.
- The job/listener becomes coupled to the upstream's storage schema, defeating the boundary.
The rule: payloads contain int IDs, string enum values, floats, bools, and arrays of the same. The handler re-fetches the live entity via the upstream Contract on the way in.
// Dispatch — scalars only.
OrderCreated::dispatch($order->id, $order->provider_id);
ProcessOrder::dispatch($order->id);
// Handler — re-fetch via Contract.
public function handle(OrderCreated $event): void
{
$order = app(OrderContract::class)->getOrder($event->order_id);
// ... work on fresh state.
}
Avoiding circular dependencies
If Domain\A's Contract calls Domain\B's Contract, and Domain\B's Contract calls back into Domain\A's Contract, you have a cycle. At best it's a confusing dependency graph; at worst it deadlocks under transactions or recurses infinitely when both sides try to "stay consistent."
Three fixes, roughly in order of preference:
- Move the shared concept into a third domain —
Coreor a new bounded context that owns the bridge. - Use an event — one side publishes, the other subscribes. The cycle breaks because the publisher no longer imports the subscriber.
- Model the relationship explicitly with a join entity that owns the bridging behaviour.
The reviewer agent flags cycles as #16 — Circular dependencies between domains.
What to read next
- Domain boundaries — the rule for who can call whom and what they're allowed to import.
- DDD overview — the bigger map of domains, panels, and what lives where.
- Actions — where the
app(SomeContract::class), eventdispatch, andJob::dispatchcalls actually live. - Services and contracts — how to add a method to a Contract and bind its Service.
- Anti-patterns — items covering Model-in-payload, controller-dispatched jobs, and circular dependencies.
Boundaries
The rule for calling code in another domain — Actions directly inside the same domain, Contracts across domains, and never Models or internals.
Glossary
Roughly forty bounded contexts live under `src/Domain/`. This glossary names each one and describes its responsibility in a sentence or two so you can pattern-match against the right place when something new needs to land. When a description is marked **TODO**, the owning team should fill it in — better to leave a marker than to guess.