Contracts
A Contract is a PHP interface that defines a domain's public API. Anything outside the domain that wants to talk to it goes through the Contract — never through a Model, an Action, or the Service class directly.
The Contract lives at src/Domain/{Domain}/Contracts/{Entity}Contract.php. It's the "published API" of the domain. Once a method is on the Contract, removing or renaming it is a breaking change — every other domain may now depend on it. The smaller the Contract surface, the easier the domain is to refactor.
The shape
namespace Domain\Order\Contracts;
use Domain\Order\Actions\GetOrders;
use Domain\Order\DataTransferObjects\CreateOrderData;
use Domain\Order\DataTransferObjects\FilterOrderData;
use Domain\Order\DataTransferObjects\UpdateOrderData;
use Domain\Order\Models\Entities\Order;
interface OrderContract
{
public function getOrders(?FilterOrderData $data, array $withRelations = [], array $withCount = []): GetOrders;
public function getOrder(int $id): ?Order;
public function create(CreateOrderData $data): Order;
public function update(Order $entity, UpdateOrderData $data): Order;
}
The Contract declares what the domain promises. The Service behind it implements those promises by calling Actions. Callers always resolve the Contract — app(OrderContract::class) — so the implementation can be swapped without changing any caller.
Always resolve the Contract, never the Service
Every Service in src/Domain/{Domain}/Services/ has a matching Contract in src/Domain/{Domain}/Contracts/. Consumers never new a Service or type-hint it directly — they always resolve {Entity}Contract from the container.
// ✅ Good — resolve via the Contract
$provider = app(ProviderContract::class)->getProvider($id);
// ❌ Bad — direct instantiation, bypasses the binding
$provider = (new ProviderService())->getProvider($id);
The Contract is the gateway to the domain. Everything behind it — Actions, internal Services, Models — is an implementation detail that external consumers must never depend on. If you ever feel the need to type-hint a Service directly, that's the warning sign you're skipping the boundary.
Method signatures on one line
No multi-line parameter lists, even when the line gets long. Pint won't reformat them for you and neighbour files all stay on one line.
// ❌ Bad — multi-line signature
public function getProvider(
?int $id = null,
?FilterProviderData $filters = null,
array $withRelations = []
): ?ProviderData;
// ✅ Good — single line
public function getProvider(?int $id = null, ?FilterProviderData $filters = null, array $withRelations = []): ?ProviderData;
Generic names, not specific ones
Use one generic getter with optional filters, not several specific getters.
// ❌ Bad — multiple specific getters
public function getProviderById(int $id): ?ProviderData;
public function getProviderByEmail(string $email): ?ProviderData;
// ✅ Good — one generic getter with filters
public function getProvider(?int $id = null, ?FilterProviderData $filters = null, array $withRelations = []): ?ProviderData;
Standard verbs: get{Entity}s, get{Entity}, create, update, delete. Domain-specific verbs follow the natural language for the operation: approveQuotation, recalculateBalance, issueWaybill.
Parameter order
For single-item getters: ?int $id first, then ?Filter{Entity}Data $filters, then array $withRelations = [], then array $withCount = [].
public function getOrder(?int $id = null, ?FilterOrderData $filters = null, array $withRelations = [], array $withCount = []): ?Order;
Putting id first makes the common case — fetch by id — read naturally: app(OrderContract::class)->getOrder(123).
What to expose on a Contract
Only Actions consumed by other domains or the Presentation layer belong on the Contract. Internal-only Actions stay private to the domain and are imported directly by sibling code.
- Expose
getProvider,createProvider,updateProviderBalance— these are called fromOrderService, controllers, jobs. - Do not expose
RecalculateProviderInternalCommissionif onlyUpdateProviderBalance(same domain) calls it. Sibling Actions import each other directly.
A note on returning Models vs DTOs
Long-term, Contracts should return read DTOs ({Entity}Data) — that way the downstream domain has no path to mutate the row, no accidental N+1 from relation access, and no leak of the upstream's schema.
In practice, the existing Contract templates emit Eloquent Models as their return type and most of the codebase follows that. There is a future refactor that will introduce {Entity}Data read DTOs. Until that lands, new code follows the existing pattern (return Models) and reviews flag it as informational only. See Anti-Patterns item #4.
Anti-patterns to avoid
- Calling another domain's Service class directly instead of resolving the Contract — bypasses the binding and the boundary. (anti-patterns #1)
- Multi-line method signatures on the Contract — keep signatures on one line, even with several optional parameters. (anti-patterns #3)
- Specific getter methods like
getProviderByEmail— collapse into one genericgetProvider(?int $id, ?FilterProviderData $filters, array $withRelations). (anti-patterns #2) - Returning Models from Contracts — informational only; the current pattern emits Models. Don't block new code that follows it. (anti-patterns #4)
What to read next
- Services — the class that implements the Contract.
- Actions — where the queries and writes live behind the Contract.
- DTOs — the typed payloads the Contract accepts and returns.
- Domain Boundaries — why every cross-domain call goes through the Contract.
Services
The class behind every Contract. Services translate Contract calls into Action calls and orchestrate cross-domain work — they hold no business logic and run no queries.
DTOs
How to define typed payloads (DTOs) that flow between requests, Contracts, Actions, and Eloquent without a mapping layer.