- 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 the biggest file in your PHP project. Odds are good its name ends in Service. Chances are the file is 800 lines, the constructor takes ten arguments, and three of those arguments are only used by one method out of thirty. Welcome to the most overloaded word in the language.
Service started life as a precise term. Eric Evans wrote about domain services in 2003: a narrow concept for a domain behavior that does not naturally belong to any single entity. By the time the word reached PHP, it had been sanded down to mean "a class where logic goes." It is now the default suffix every framework generator hands you, the default folder name every tutorial uses, and the default answer every junior gets when they ask where to put a method.
You can fix this. Not by banning the word (though that would help), but by picking the right name for what you actually have. Four shapes account for almost everything that currently sits in a Service class. None of them are called Service.
What you really have: FooService doing five jobs
Pull up a real one. This is the shape that ships in week eighteen of almost every Laravel codebase a reviewer will ever read:
<?php
namespace App\Services;
final class OrderService
{
public function __construct(
private DB $db,
private Mailer $mailer,
private StripeClient $stripe,
private TaxCalculator $tax,
private CsvWriter $csv,
private EventBus $events,
private Logger $log,
private FeatureFlags $flags,
) {}
public function createOrder(array $data): Order
{
$order = new Order();
$order->customer_id = $data['customer_id'];
$order->status = 'pending';
$order->total_cents = $this->tax->compute($data['items']);
$order->save();
$this->mailer->send(new OrderPlaced($order));
return $order;
}
public function cancelOrder(int $id, ?string $reason): void
{
$order = Order::find($id);
if ($order->status === 'shipped') {
throw new \DomainException('Cannot cancel.');
}
$order->status = 'cancelled';
$order->cancellation_reason = $reason;
$order->save();
$this->stripe->refund($order->payment_id);
$this->events->publish(new OrderCancelled($order->id));
}
public function findOrdersByStatus(string $status): array
{
return DB::table('orders')
->where('status', $status)
->get()
->toArray();
}
public function exportOrdersCsv(array $filters): string
{
$rows = $this->findOrdersByStatus($filters['status'] ?? 'paid');
return $this->csv->write($rows);
}
public function recalculateTax(int $id): void
{
$order = Order::find($id);
$order->total_cents = $this->tax->compute($order->items);
$order->save();
}
}
Read what this class actually contains.
createOrder and cancelOrder are business operations: verbs the application performs once per HTTP request. findOrdersByStatus is something else: a read query. recalculateTax is another thing again: a domain calculation that could run anywhere. And the StripeClient injected in the constructor is yet another kind of thing entirely: a gateway to an external system dressed up as a regular dependency.
Four kinds of work crammed behind one class name. Every test method has to mock eight collaborators because the class refuses to declare which method actually needs which. Every code reviewer has to scroll past twenty methods to find the one that changed.
The fix is to give each kind of work the name it deserves. Four names follow.
UseCase: one verb, one class
When a method describes something the application does in response to a request (place an order, cancel a subscription, approve a refund), that method is a use case in Robert Martin's sense. One verb. One class. One execute() method that orchestrates the work. The constructor lists exactly the collaborators that one verb needs and nothing else.
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\{OrderId, OrderNotFound, OrderRepository};
use App\Application\Port\{Clock, EventBus, PaymentGateway};
final readonly class CancelOrder
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payments,
private EventBus $events,
private Clock $clock,
) {}
public function execute(CancelOrderInput $input): CancelOrderOutput
{
$order = $this->orders->find(new OrderId($input->orderId))
?? throw new OrderNotFound($input->orderId);
$order->cancel($this->clock->now());
$this->payments->refund($order->paymentId());
$this->orders->save($order);
$this->events->publishAll($order->releaseEvents());
return new CancelOrderOutput($order->id->value);
}
}
Look at the constructor: four collaborators, down from eight. Compare it to the old OrderService. The CsvWriter, the Mailer, the TaxCalculator, the FeatureFlags are gone, because CancelOrder does not need them. They lived in the constructor only because some other method down the file wanted them, and the class was generous enough to share.
A use case has one verb in its name. If you find yourself writing OrderUseCase with five public methods, you have rebuilt OrderService with a different suffix. Stop. Split. Break it into PlaceOrder, CancelOrder, RefundOrder, FulfillOrder. The folder grows; every individual class shrinks.
Repository: a port that speaks domain, not SQL
When a method's job is to fetch or save a domain object, it belongs on a repository. It does not belong on a Service class, and it does not belong on the use case. A repository is a port: an interface defined in the domain, expressed in the domain's vocabulary, with no SQL leaking through.
Here is the test: read the interface aloud to a product manager. If every method sounds like a question the business actually asks, you have a repository. If any of them sounds like "give me the rows where status equals 'pending'", you have a DAO with a marketing tag.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
interface OrderRepository
{
public function find(OrderId $id): ?Order;
public function save(Order $order): void;
/** @return Order[] */
public function pendingFulfilmentOlderThan(
\DateTimeImmutable $cutoff,
): array;
public function nextIdentity(): OrderId;
}
Four methods. Each returns a domain object or saves one. None of them mention array, Collection, stdClass, or executeRawQuery. The interface lives next to the Order entity, inside the domain namespace, because the domain is the consumer — it decides what shape the persistence call has.
The implementation, with all the Eloquent or Doctrine machinery, lives at the edge:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Eloquent;
use App\Domain\Order\{Order, OrderId, OrderRepository};
final class EloquentOrderRepository implements OrderRepository
{
public function find(OrderId $id): ?Order
{
$row = OrderRow::query()->find($id->value);
return $row ? $this->toDomain($row) : null;
}
public function save(Order $order): void
{
$row = OrderRow::query()
->firstOrNew(['id' => $order->id->value]);
$this->toRecord($order, $row);
$row->save();
}
// toDomain() and toRecord() do the row <-> entity mapping
}
The Eloquent model still exists. The team keeps migrations, the query builder, every helper they like. It just lives behind the interface, inside the persistence namespace, never imported by a use case. The day Eloquent ships a breaking change, only this file moves.
If the class returning arrays in your codebase is called OrderRepository, rename it OrderDao and move it to App\Infrastructure\Dao. Then build a real repository next to the domain. Two classes, two responsibilities. You will not regret the split.
DomainService: the only place "service" earns its name
There is a legitimate use of the word. It is narrow.
A domain service is the home for behavior that genuinely does not belong on any single entity. Two entities interact, or a calculation depends on outside data the entity should not know about, or a policy spans the aggregate boundary. In those cases, and only in those cases, the verb deserves a class of its own, and that class can be called a service because Evans wrote the term down two decades ago and it would be silly to invent a synonym.
A discount policy that depends on a customer's history and the cart's current contents is one example:
<?php
declare(strict_types=1);
namespace App\Domain\Pricing;
use App\Domain\Customer\Customer;
use App\Domain\Order\Cart;
use App\Domain\Shared\Money;
final readonly class LoyaltyDiscountPolicy
{
public function __construct(
private DiscountTiers $tiers,
) {}
public function applyTo(Customer $customer, Cart $cart): Money
{
$tier = $this->tiers->forSpendCents(
$customer->lifetimeSpendCents(),
);
return $cart->subtotal()->percentOff($tier->percent);
}
}
Three properties tell you this is a real domain service, not a dumping ground in disguise:
- No infrastructure. No database. No HTTP. No queue. The class works on domain objects in memory. You can unit-test it without a single mock.
-
One verb in the name.
LoyaltyDiscountPolicy.applyTo(). NotDiscountService.applyDiscount() + .recalculate() + .listAvailable() + .exportCsv(). If you are reaching for a second public method, you are reaching for a second class. - It lives in the domain layer. Same folder as the entities it operates on. No mapper between it and the use case. The use case calls it directly.
OrderService fails all three tests. It crosses every layer in the application — it is a misnamed pile, not a domain service.
Adapter: where the framework lives
The last piece of the original OrderService that has not found a home yet is the part that talked to the outside world. The StripeClient in the constructor. The Mailer. Anywhere your code crosses the process boundary (HTTP out, SQL out, AMQP out, file write), there is a port and an adapter that implements it. The port is owned by the domain or the use case.
The port is small and stated in the domain's language:
<?php
declare(strict_types=1);
namespace App\Application\Port;
use App\Domain\Order\PaymentId;
use App\Domain\Shared\Money;
interface PaymentGateway
{
public function charge(PaymentId $id, Money $amount): void;
public function refund(PaymentId $id): void;
}
The adapter is whatever messy class is needed to make Stripe (or PayPal, or the in-memory fake your test uses) satisfy that interface:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Payments;
use App\Application\Port\PaymentGateway;
use App\Domain\Order\PaymentId;
use App\Domain\Shared\Money;
use Stripe\StripeClient;
final readonly class StripePaymentGateway implements PaymentGateway
{
public function __construct(
private StripeClient $stripe,
) {}
public function charge(PaymentId $id, Money $amount): void
{
$this->stripe->paymentIntents->create([
'amount' => $amount->amountInMinorUnits,
'currency' => $amount->currency->value,
'metadata' => ['payment_id' => $id->value],
]);
}
public function refund(PaymentId $id): void
{
$this->stripe->refunds->create([
'payment_intent' => $id->value,
]);
}
}
Notice what is missing. There is no Service suffix. There is no business rule inside the adapter, no "if the order is shipped, throw." That rule belongs on the domain Order entity (note the $order->cancel(...) call in the use case above: the invariant lives on the entity, not the orchestrator). The adapter has one job: translate between the application's port and the vendor's SDK. Nothing else.
The pattern repeats for every external system. HTTP inbound? An adapter, typically a controller that calls a use case. Queue worker? Another adapter, calling the same use case from a different entry point. Email sender? An adapter behind a Notifier port. Once you start naming adapters as adapters, the framework stops being the application and goes back to being a delivery mechanism, which is what it was always supposed to be.
The renaming exercise
You do not have to rewrite everything tonight. Do this instead. Open the largest Service in your codebase and ask, method by method:
- Is this a single business operation triggered by a request? It is a UseCase. Move it to
App\Application\<Aggregate>\<Verb>and trim the constructor to what it actually uses. - Does it fetch or save a domain object? It belongs on a Repository interface in the domain layer, with an adapter in
App\Infrastructure\Persistence. - Does it express a domain rule that does not fit on an entity, with no infrastructure dependencies? Keep the DomainService name, drop the
Servicesuffix in favor of the rule's actual name (LoyaltyDiscountPolicy,TaxBreakdown), and move it into the domain layer. - Does it talk to something outside the process — DB, HTTP, queue, email, SMS, S3? Wrap it behind a port and call the implementation an Adapter.
Most methods end up in one of those four buckets. The ones that fit none usually turn out to be a fifth thing in disguise (a read query, a CSV export, a report) that belonged in its own slim class on the read side all along, and was hiding inside OrderService because nobody asked.
Do that exercise on one file this week. Then run git grep -l 'class \w\+Service' and decide which of the four real names each of those files deserves. The codebase shrinks in concept while the file count grows, and the constructors finally tell you what each class needs.
If this was useful
The four shapes above — UseCase, Repository, DomainService, Adapter — are the spine of Decoupled PHP. The book walks the same renaming exercise across a real application: Laravel and Symfony as adapters, Doctrine as one persistence option among several, a domain that survives every framework migration. Read it when you want the next four hundred pages of "where does this code actually go."
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)