Key Concepts

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.

A Service is the class that implements a Contract. Its only job is to translate Contract method calls into Action calls. The Service holds no business logic and runs no database queries — every query, every write, every transaction lives inside an Action.

A Service lives at src/Domain/{Domain}/Services/{Entity}Service.php. It's bound to its Contract in the domain's ServiceProvider, so callers always resolve the Contract — app(OrderContract::class) — and the framework hands them the Service.

The shape

namespace Domain\Order\Services;

use Domain\Order\Actions\CreateOrder;
use Domain\Order\Actions\GetOrder;
use Domain\Order\Actions\GetOrders;
use Domain\Order\Actions\UpdateOrder;
use Domain\Order\Contracts\OrderContract;
use Domain\Order\DataTransferObjects\CreateOrderData;
use Domain\Order\DataTransferObjects\FilterOrderData;
use Domain\Order\DataTransferObjects\UpdateOrderData;
use Domain\Order\Models\Entities\Order;

class OrderService implements OrderContract
{
    public function getOrders(?FilterOrderData $data, array $withRelations = [], array $withCount = []): GetOrders
    {
        return GetOrders::for($data, $withRelations, $withCount);
    }

    public function getOrder(int $id): ?Order
    {
        return GetOrder::handle($id);
    }

    public function create(CreateOrderData $data): Order
    {
        return CreateOrder::handle($data);
    }

    public function update(Order $entity, UpdateOrderData $data): Order
    {
        return UpdateOrder::handle($entity, $data);
    }
}

Each method matches the Contract signature exactly — same parameters, same defaults, same return type — and does nothing except call an Action.

Services delegate to Actions — no direct queries

A Service method contains zero direct Eloquent queries. Every database touch — Model::query(), Model::find(), Model::create(), ->update(), ->delete(), DB::table(...), raw SQL — lives in an Action. The Service orchestrates; the Action executes.

// ❌ Bad — Service holds the query
class EmployeeService implements EmployeeContract
{
    public function getEmployee(int $id): ?Employee
    {
        return Employee::query()->where('id', $id)->first();
    }
}
// ✅ Good — Service delegates
class EmployeeService implements EmployeeContract
{
    public function getEmployee(int $id): ?Employee
    {
        return GetEmployee::handle($id);
    }
}

final class GetEmployee
{
    public static function handle(int $id): ?Employee
    {
        return Employee::query()->where('id', $id)->first();
    }
}

The same rule applies to writes — wrap the write in an Action with DB::transaction() rather than inlining it in the Service:

// ❌ Bad — write logic in the Service
class OrderService implements OrderContract
{
    public function create(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = Order::create([...]);
            $order->lines()->createMany([...]);

            return $order;
        });
    }
}
// ✅ Good — Service delegates, transaction lives in the Action
class OrderService implements OrderContract
{
    public function create(CreateOrderData $data): Order
    {
        return CreateOrder::handle($data);
    }
}

Actions are reusable across multiple Services, easy to test in isolation, and keep query logic centralised. Services become thin orchestrators.

Orchestration belongs in an Action, not the Service

If a Service method needs cross-domain orchestration — say create calls ProviderContract and AccountingContract — put that orchestration in an Action and have the Service delegate to it. The Service stays a pass-through:

// ✅ Good — orchestration in the Action, Service is a pass-through
class OrderService implements OrderContract
{
    public function create(CreateOrderData $data): Order
    {
        return CreateOrder::handle($data);
    }
}

final class CreateOrder
{
    public static function handle(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            $provider = app(ProviderContract::class)->getProvider($data->provider_id);

            $order = Order::create([
                'provider_id' => $provider->id,
                'customer_id' => $data->customer_id,
                'amount' => $data->amount,
            ]);

            app(AccountingContract::class)->recordTransaction($order->id, $data->amount);

            return $order;
        });
    }
}

Bind the Contract in a ServiceProvider

Each domain has one ServiceProvider in src/Domain/{Domain}/Providers/{Domain}ServiceProvider.php. Use the $bindings array — no manual $this->app->bind() calls. Laravel reads $bindings at boot and registers each pair.

namespace Domain\Order\Providers;

use Domain\Order\Contracts\OrderContract;
use Domain\Order\Services\OrderService;
use Illuminate\Support\ServiceProvider;

class OrderServiceProvider extends ServiceProvider
{
    public array $bindings = [
        OrderContract::class => OrderService::class,
    ];
}

Then register the provider in bootstrap/providers.php:

return [
    // ...
    Domain\Order\Providers\OrderServiceProvider::class,
];

If the domain has multiple Services (e.g., Order has OrderService and OrderLineService), bind each pair in the same ServiceProvider:

public array $bindings = [
    OrderContract::class => OrderService::class,
    OrderLineContract::class => OrderLineService::class,
];
Forgetting to register the provider in bootstrap/providers.php is the single most common cause of BindingResolutionException: Target [Domain\X\Contracts\YContract] is not instantiable. When a freshly written Contract throws that error, check bootstrap/providers.php first.

Resolve dependencies inline inside the domain

Inside domain classes (Services and Actions) we resolve dependencies inline with app(SomeContract::class), not via constructor injection. This contradicts the generic Laravel guidance — the override is deliberate.

// ❌ Bad — constructor injection inside a domain Service
class OrderService implements OrderContract
{
    public function __construct(
        private ProviderContract $providerService,
        private AccountingContract $accountingService,
    ) {}

    public function processOrder(int $id): void
    {
        $provider = $this->providerService->getProvider($id);
        // ...
    }
}
// ✅ Good — resolve inline
class OrderService implements OrderContract
{
    public function processOrder(int $id): void
    {
        $provider = app(ProviderContract::class)->getProvider($id);
        $accounting = app(AccountingContract::class);
        // ...
    }
}

Three reasons:

  • Actions are static and have no constructor — they must use app() anyway. Making Services match keeps the pattern consistent end-to-end.
  • Cross-domain dependency lists in Services balloon to 8–15 Contracts. Constructor injection clutters the signature for dependencies that are only used in one or two methods.
  • The rest of the codebase already follows this pattern. New code that uses constructor injection in a Service stands out and breaks the app()-based test stubs other modules rely on.

Constructor injection remains correct for non-domain classes:

  • Controllers — inject the domain Contract via the constructor (see Controllers).
  • Form Requests, Middleware, Console Commands, Jobs — standard Laravel constructor injection.
  • Adapter classes at system boundaries — payment gateways, notification channels, HTTP clients.

The override applies to Services and Actions inside src/Domain/. Everywhere else, follow the generic Laravel rule.

Cross-domain calls go through the Contract

Inside a domain, you own the code and may import Actions directly. Across domains, go through the upstream Contract.

// ✅ Same domain — Action is yours
use Domain\Order\Actions\CalculateOrderTotal;

$total = CalculateOrderTotal::handle($order);
// ❌ Cross-domain — Action is not yours
$provider = \Domain\Provider\Actions\GetProvider::handle($id);

// ✅ Cross-domain — go through the Contract
$provider = app(ProviderContract::class)->getProvider($id);

See Domain Boundaries for the full diagram and rationale.

Anti-patterns to avoid

  • Direct model query inside a Service methodModel::query(), Model::find(), ->create(), ->update() are all forbidden in Services. Move to an Action. (anti-patterns #15)
  • Calling another domain's Service or Action directly — every cross-domain call goes through the Contract. No use Domain\Other\Services\... or use Domain\Other\Actions\... outside that domain's own files. (anti-patterns #1)
  • Constructor injection in a Service — use app(SomeContract::class) inline. (anti-patterns #7)
  • DB::transaction() inlined in a Service — wrap the work in an Action; the Service stays a pass-through.
  • Contracts — the interface every Service implements.
  • Actions — where the queries and writes actually live.
  • DTOs — typed payloads between Services and the outside world.
  • Domain Boundaries — the rule for who can call whom.