- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: System Design Pocket Guide: Fundamentals
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open any PHP project that has been alive for more than two years. Run find . -name "*Service.php" | wc -l. The number is going to make you wince.
UserService, OrderService, PaymentService, EmailService, NotificationService, ReportService, ExportService, ImportService, SyncService, ValidatorService. Each one is 400 lines. Each one injects the Eloquent model, the mailer, the queue, the cache, three repositories, the logger, and a Twig renderer. Each one has methods called handle, process, execute, and doStuff. None of them are domain services.
That is the problem with the word service in PHP. Frameworks use it for "anything you put in the container." DDD uses it for one specific thing, and that one specific thing is rare. The two meanings collided, and "service" became the dumping ground for code nobody wanted to put on a model.
This post is about the rare case where a real domain service is the right answer, what it looks like in PHP 8.3, and how to tell it apart from the seventeen other classes in your app/Services folder.
The rule: a domain service holds behaviour that doesn't fit on any single entity
That is the whole definition. Eric Evans wrote it down in 2003 and nobody has improved on it.
If a behaviour belongs to one entity (Order::confirm(), Subscription::pause(), Invoice::recordPayment()), it goes on the entity. That is a method, not a service. If the behaviour is a calculation over inputs with no business identity (TotalFor($items)), it can be a static helper or a value object method. Also not a service.
A domain service shows up when the behaviour:
- Operates on two or more entities at once, and the rules that hold them together don't belong inside either entity.
- Is expressed in the language of the domain (the product owner would name it the same way you do).
- Touches no infrastructure: no database, no HTTP, no queue, no clock, no UUID generator. Pure inputs to pure outputs.
PHP devs trip over the no-infrastructure rule the most. The moment your "domain service" injects a repository, it isn't a domain service anymore. It is a use case (an application service in Clean Architecture terms), and it belongs one ring further out. The naming matters because the ring matters: a use case can coordinate I/O, a domain service cannot.
The canonical example: transfer money between two accounts
The textbook example is also the best one, because it actually happens in production code and almost everyone gets it wrong.
You have an Account entity. It has a balance and a currency. It has methods to debit and credit itself. A transfer between two accounts has rules: same currency, source has enough funds, destination accepts deposits. Where do those rules live?
Option A, on the source account: $source->transferTo($destination, $amount). This puts the destination's invariants inside the source's method. Now Account::transferTo has to know about Account::canReceive. Two-way coupling, hidden inside an instance method. The source ends up reaching into the destination's state to check rules it shouldn't own.
Option B, on the destination account: same problem, mirrored.
Option C, in a use case (TransferMoneyUseCase): the use case loads both accounts from a repository, transfers, saves them, commits a transaction. This works, but it mixes the business rule ("you can transfer between same-currency accounts when funds are sufficient") with the orchestration ("load A, load B, save A, save B, commit"). When a second entry point appears (a CLI replay, a queue handler, an admin override), the rule gets re-stated in each one. Or worse, it ends up subtly different in each.
Option D, a domain service: a TransferDomainService that takes two Account entities and an Amount, performs the transfer in memory, and returns. The rule lives in one place. The use case loads, calls the domain service, and saves. The CLI does the same. The admin tool does the same. The rule has one definition.
D is the answer when the behaviour is non-trivial and crosses entities. Here is what it looks like in PHP 8.3.
src/Domain/Account.php
<?php declare(strict_types=1);
namespace App\Domain;
final class Account
{
public function __construct(
public readonly AccountId $id,
public readonly Currency $currency,
private Money $balance,
private bool $frozen,
) {}
public function balance(): Money
{
return $this->balance;
}
public function isFrozen(): bool
{
return $this->frozen;
}
public function debit(Money $amount): void
{
if (!$amount->currency->equals($this->currency)) {
throw new CurrencyMismatch(
$amount->currency,
$this->currency,
);
}
if ($this->balance->lessThan($amount)) {
throw new InsufficientFunds($this->id, $amount);
}
$this->balance = $this->balance->minus($amount);
}
public function credit(Money $amount): void
{
if (!$amount->currency->equals($this->currency)) {
throw new CurrencyMismatch(
$amount->currency,
$this->currency,
);
}
$this->balance = $this->balance->plus($amount);
}
}
Pure PHP. No extends Model. No annotations. No traits from the framework. The Account enforces its own invariants (currency, sufficient funds) but knows nothing about other accounts.
src/Domain/TransferDomainService.php
<?php declare(strict_types=1);
namespace App\Domain;
final class TransferDomainService
{
public function transfer(
Account $source,
Account $destination,
Money $amount,
): TransferResult {
if ($source->id->equals($destination->id)) {
throw new SameAccountTransfer($source->id);
}
if (!$source->currency->equals($destination->currency)) {
throw new CurrencyMismatch(
$source->currency,
$destination->currency,
);
}
if ($source->isFrozen()) {
throw new AccountFrozen($source->id);
}
if ($destination->isFrozen()) {
throw new AccountFrozen($destination->id);
}
if (!$amount->isPositive()) {
throw new NonPositiveAmount($amount);
}
$source->debit($amount);
$destination->credit($amount);
return new TransferResult(
$source->id,
$destination->id,
$amount,
);
}
}
Read it twice. Count the things it does not do.
- It doesn't load anything. The two
Accountobjects arrive already loaded. - It doesn't save anything. After the call, the use case is responsible for persistence.
- It doesn't dispatch events to a queue. It returns a
TransferResultthat the use case can turn into events later. - It doesn't log. It doesn't trace. It doesn't open a transaction.
- It has no constructor dependencies. You can
new TransferDomainService()in a test without a container.
And the short list of things it actually does:
- Enforces the cross-entity rules: same account, currency match, freeze status, positive amount.
- Mutates the two entities in memory by calling their own methods.
- Returns a value object describing what happened.
That is the entire shape of a domain service. Pure inputs. Domain-typed outputs. No I/O. The rule lives here, in one file, named in the language of the business.
The use case wraps it; the use case is not it
The piece that the framework actually wires up is the use case. It is the one that touches infrastructure.
<?php declare(strict_types=1);
namespace App\Application;
use App\Domain\AccountRepository;
use App\Domain\Money;
use App\Domain\TransferDomainService;
use App\Domain\UnitOfWork;
final class TransferMoneyUseCase
{
public function __construct(
private readonly AccountRepository $accounts,
private readonly TransferDomainService $transfer,
private readonly UnitOfWork $uow,
) {}
public function execute(TransferCommand $cmd): TransferResult
{
return $this->uow->transactional(function () use ($cmd) {
$source = $this->accounts->getById($cmd->sourceId);
$destination = $this->accounts->getById($cmd->destinationId);
$result = $this->transfer->transfer(
$source,
$destination,
Money::of($cmd->amount, $cmd->currency),
);
$this->accounts->save($source);
$this->accounts->save($destination);
return $result;
});
}
}
The use case loads, delegates the rule to the domain service, saves, commits. It is the only place in the call chain that knows a transaction exists. The domain service has no idea.
When the second entry point arrives (say, a queue worker that processes a batch of reversed transfers from a daily reconciliation file), it gets its own use case (ReverseTransferUseCase) that calls the same TransferDomainService::transfer with the arguments swapped. The rule never gets copy-pasted. That is the payoff.
Tests are tiny when the domain service is pure
Because there is no I/O, the test is new TransferDomainService() and two in-memory accounts. No Doctrine fixtures. No Laravel database refresh. No mocks.
<?php declare(strict_types=1);
namespace Tests\Domain;
use App\Domain\Account;
use App\Domain\AccountId;
use App\Domain\Currency;
use App\Domain\InsufficientFunds;
use App\Domain\Money;
use App\Domain\TransferDomainService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TransferDomainServiceTest extends TestCase
{
#[Test]
public function it_moves_money_between_two_accounts(): void
{
$eur = Currency::of('EUR');
$alice = new Account(
new AccountId('alice'),
$eur,
Money::of(10000, 'EUR'),
frozen: false,
);
$bob = new Account(
new AccountId('bob'),
$eur,
Money::of(0, 'EUR'),
frozen: false,
);
$service = new TransferDomainService();
$service->transfer($alice, $bob, Money::of(2500, 'EUR'));
$this->assertEquals(7500, $alice->balance()->amount);
$this->assertEquals(2500, $bob->balance()->amount);
}
#[Test]
public function it_rejects_a_transfer_that_would_overdraw(): void
{
$eur = Currency::of('EUR');
$alice = new Account(
new AccountId('alice'),
$eur,
Money::of(100, 'EUR'),
frozen: false,
);
$bob = new Account(
new AccountId('bob'),
$eur,
Money::of(0, 'EUR'),
frozen: false,
);
$service = new TransferDomainService();
$this->expectException(InsufficientFunds::class);
$service->transfer($alice, $bob, Money::of(500, 'EUR'));
}
}
Two tests, no fixtures, runs in milliseconds. The rule is verified directly. When the auditor asks "what rules govern transfers", you point at the service file and the test file. That is the whole answer.
If you tried to write this test against the framework-style TransferService that injects six things, you would be mocking three of them, hand-rolling a fake repository, and reading 200 lines of setup to verify one rule.
How to tell the difference, on review
You can spot a fake domain service in a pull request without reading the body. Four signals do most of the work:
- The constructor injects a
Repository, anHttpClient, aMailer, aLogger, aLoggerInterface, aCacheInterface, or anything ending inManager. Real domain service constructors are usually empty. - The methods return
voidand do their work through side effects on injected collaborators. Real domain services return value objects. - The method names are framework verbs (
handle,dispatch,process,execute). Real domain services use business verbs (transfer,priceAt,apportion,settle). - A test would need a database to run. Real domain service tests run with
new ServiceUnderTest().
If three of those four are true, the class is a use case wearing the wrong name, or it is the dumping-ground service Evans warned about. Rename it. Move it. Push the pure rule down into a real domain service if one is hiding inside, and leave the I/O orchestration in the use case.
Most "services" in your codebase are use cases
The OrderService that creates an order, charges the card, sends the email, and queues the fulfilment is not a domain service. It is a use case (or three use cases that someone glued together). The domain service inside it, if there is one, is the part that computes the order total across line items, taxes, and discounts according to rules that span items. That part is small. It might be ten lines. It might not exist for your particular order model, because the entity is rich enough to hold its own rules.
A real codebase has one or two domain services per bounded context. Transfer between accounts. Match a trade. Apportion a payment across invoices. Compute the next billing date for a paused-then-resumed subscription. These are the rare ones, the rules that genuinely refuse to live on a single entity.
Everything else is a use case, an entity method, a value object, or a static helper. Naming each one correctly is what makes the codebase legible five years in, when the framework you started with is two majors behind and someone is finally moving you off it.
If this was useful
Decoupled PHP walks the full layout (entities, value objects, domain services, use cases, ports, adapters) for a real PHP 8.3 application where Laravel and Symfony show up only at the edges. The chapter on domain services in particular goes deeper into the trade-offs: when to keep a rule on the entity, when to extract a domain service, and when the honest answer is "it is a use case, name it that way".
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now. Portuguese and Spanish coming soon.



Top comments (0)