- 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 a four-year-old Laravel or Symfony repo. The folder names look fine. The README still claims "service layer" and "repositories." Then you open OrderService.php and it scrolls for thirty seconds. The team is shipping at half the speed it did in year one and nobody can say exactly why.
The reason is rarely one bad commit. It is a handful of shapes that drifted in while everyone was busy shipping. None of them looked like a problem the day they appeared. Each one charges interest in code-review time, test setup, and onboarding friction. By year four the interest is the whole budget.
These seven are the ones you can find this afternoon with grep. Each comes with the cost it accrues, the line you can run to spot it, and the smallest change that stops the bleeding. The full refactors live in the book. The diagnoses live here.
1. The service-layer dumping ground
The single class with twenty public methods, ten constructor arguments, and a name that ends in Service.
<?php
namespace App\Services;
final class OrderService
{
public function createOrder(array $data): Order { /* 90 lines */ }
public function cancelOrder(int $id, ?string $reason): void { /* 110 */ }
public function refundOrder(int $id, ?int $amount): Refund { /* 95 */ }
public function shipOrder(int $id, string $carrier): void { /* 60 */ }
public function applyDiscount(int $id, string $code): Order { /* 75 */ }
public function exportToCsv(array $filters): string { /* 80 */ }
// ... fourteen more
}
Cost it accrues. Every public method inherits the full constructor. Test cancelOrder and you instantiate the payment gateway, the CSV exporter, the tax calculator, and the email sender. None of them are used. They came along for the ride. The class can never become smaller than its largest method's needs.
Grep to find it.
grep -rEc '^\s*public function ' app/Services \
| awk -F: '$2 >= 7 {print}'
Any file with seven or more public methods is on the list.
Smallest fix. One class per verb, named for the verb, with its own constructor:
final class CancelOrder
{
public function __construct(
private OrderRepository $orders,
private Clock $clock,
) {}
public function execute(CancelOrderInput $input): void
{
$order = $this->orders->find(new OrderId($input->orderId))
?? throw new OrderNotFound($input->orderId);
$order->cancel($this->clock->now());
$this->orders->save($order);
}
}
Two constructor arguments instead of ten. The test file shrinks accordingly.
2. ActiveRecord as the domain
The Eloquent model that owns business rules, sends email, and recalculates totals inside booted().
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = ['customer_id', 'status', 'total_cents'];
protected static function booted(): void
{
static::created(fn (Order $o) =>
Mail::to($o->customer)->send(new OrderPlaced($o)));
static::updating(fn (Order $o) => $o->recalculateTotal());
}
public function cancel(?string $reason): void
{
if ($this->status === 'shipped') {
throw new \DomainException('Cannot cancel a shipped order.');
}
$this->status = 'cancelled';
$this->save();
}
}
Symfony codebases produce the same shape with Doctrine LifecycleCallbacks. The library changes, the mistake doesn't.
Cost it accrues. $order->save() now triggers email, recalculation, and possibly webhooks. The word save does five things. You cannot unit-test cancellation without booting the framework. Every Eloquent major version is a domain-rule migration in disguise.
Grep to find it.
grep -rln 'extends Model' app/Models | while read f; do
grep -lE 'function (cancel|approve|confirm|ship|refund)' "$f"
done
Any model that defines a verb method is doing domain work in the persistence layer.
Smallest fix. Split into three classes: a pure-PHP Order entity, an OrderRow Eloquent class with no behavior, and a repository that maps between them. The Eloquent model stays — it just stops pretending to be the domain.
3. Repository as DAO
A class named OrderRepository whose methods return array, Collection, or stdClass.
class OrderRepository
{
public function getOrdersAsArray(): array
{
return DB::table('orders')->get()->toArray();
}
public function executeRawQuery(string $sql, array $bindings = []): array
{
return DB::select($sql, $bindings);
}
public function findOrderById(int $id): ?array
{
return DB::table('orders')->where('id', $id)->first()?->toArray();
}
}
Cost it accrues. The application layer ends up writing $row['status'] === 'paid'. Column names leak into use cases. When the column gets renamed, the change ripples through every caller. The executeRawQuery method is a confession: the abstraction failed, so the caller composes SQL directly. Once that method exists, every other method becomes decorative.
Grep to find it.
grep -rE 'Repository.*function .*: (array|Collection|stdClass)' app/
If your repository signatures don't return domain objects, you have a DAO with a marketing tag.
Smallest fix. Move the interface into the domain folder and rewrite each method to answer a business question:
namespace App\Domain\Order;
interface OrderRepository
{
public function find(OrderId $id): ?Order;
public function save(Order $order): void;
/** @return Order[] */
public function pendingFulfilmentOlderThan(\DateTimeImmutable $cut): array;
public function nextIdentity(): OrderId;
}
Read the method names aloud to a product manager. If they recognize every one as a question their business asks, you have a repository.
4. The anemic entity with fat services
The entity is a bag of getters and setters with no rules. Every state transition lives in some service that mutates it from the outside.
class Order
{
public function getStatus(): string { return $this->status; }
public function setStatus(string $s): void { $this->status = $s; }
public function getCancelledAt(): ?\DateTimeImmutable { /* ... */ }
public function setCancelledAt(?\DateTimeImmutable $t): void { /* ... */ }
// ... twelve more pairs
}
class OrderService
{
public function cancelOrder(Order $order, ?string $reason): void
{
if ($order->getStatus() === 'shipped') {
throw new \DomainException('Cannot cancel.');
}
$order->setStatus('cancelled');
$order->setCancelledAt(new \DateTimeImmutable());
$this->repo->save($order);
}
}
Cost it accrues. The rule about shipped orders lives in the service. The next service that needs the same rule copy-pastes it. Three months later the rule changes and three of the four copies get updated. The fourth ships a bug to production. Fowler named this pattern in 2003; PHP framework generators still produce it by default.
Grep to find it.
grep -rEc 'public function (get|set)[A-Z]' app/Domain \
| awk -F: '$2 >= 10 {print}'
Ten or more accessor pairs and no domain verbs is the signature.
Smallest fix. Make the constructor private. Add a named static factory that enforces invariants. Add named methods for every state transition. Delete the setters.
final class Order
{
// no getStatus/setStatus, no setCancelledAt — all gone.
private function __construct(
public readonly OrderId $id,
private OrderStatus $status,
private Money $total,
) {}
public static function place(OrderId $id, Money $total): self
{
if ($total->amountInMinorUnits <= 0) {
throw new \DomainException('Order total must be positive.');
}
return new self($id, OrderStatus::Placed, $total);
}
public function cancel(\DateTimeImmutable $now): void
{
if (!$this->status->canCancel()) {
throw new OrderAlreadyFulfilled($this->id);
}
$this->status = OrderStatus::Cancelled;
}
}
5. Primitive-obsessed signatures
Every method takes int $orderId, int $customerId, int $productId, string $currency, int $amountInCents. Five primitives, all interchangeable at the type system level. Swap two arguments and PHP cheerfully runs.
public function refund(
int $orderId,
int $customerId,
int $amount,
string $currency,
string $reason,
): void {
// anyone can pass (customerId, orderId, currency, amount, reason)
// and you find out in production
}
Cost it accrues. Every refactor that adds or reorders an argument is a code search across the entire repo. Static analysis cannot catch the swap because the types match. The bugs that survive are silent: a refund of customer 14 charged against order 14 because they happened to share an ID space.
Grep to find it.
grep -rE 'function .*\(.*int \$[a-z]+Id.*int \$[a-z]+Id' app/
Two int $somethingId arguments in the same signature is the tell.
Smallest fix. Wrap each ID in a one-line readonly class. PHP 8.3 makes it cheap:
final readonly class OrderId
{
public function __construct(public string $value) {}
}
final readonly class CustomerId
{
public function __construct(public string $value) {}
}
public function refund(
OrderId $order,
CustomerId $customer,
Money $amount,
string $reason,
): void {}
The type checker now rejects the swap. The cost is one file per ID type. The first swapped call now fails at static-analysis time, not in a production refund log.
6. Booleans where enums belong
The is_active, is_pending, is_archived, is_deleted boolean column set. Four flags, sixteen possible states, only four of which make sense.
class Subscription
{
public bool $isActive;
public bool $isPaused;
public bool $isCancelled;
public bool $isPendingPayment;
}
Now write the query for "active subscriptions." Is it is_active = 1? Is it is_active = 1 AND is_cancelled = 0? Both, because someone six months ago forgot to flip one of the bits when they cancelled an account. Your dashboard starts disagreeing with itself and nobody trusts the numbers.
Cost it accrues. The state machine is implicit. Every read site reinvents it. The database accumulates rows in impossible state combinations (is_active = true AND is_cancelled = true) and there is no clean way to migrate them because nobody can say which flag was supposed to win.
Grep to find it.
grep -rE '\$(table|fillable|casts).*is_(active|pending|cancelled|archived)' app/
Three or more is_* booleans on one model is the smell.
Smallest fix. Replace the booleans with a PHP 8.1 native enum and one status column.
enum SubscriptionStatus: string
{
case Active = 'active';
case Paused = 'paused';
case Cancelled = 'cancelled';
case PendingPayment = 'pending_payment';
public function isBilling(): bool
{
return match ($this) {
self::Active, self::PendingPayment => true,
self::Paused, self::Cancelled => false,
};
}
}
One column. Four states. match is exhaustive — add a new state and every call site fails to compile until you handle it. That is the dashboard bug, caught at deploy time instead of after a quarter of wrong reports.
7. The framework controller as use case
The Laravel OrderController@store that validates, parses, queries, mutates, sends mail, and returns JSON in 200 lines.
public function store(Request $request)
{
$validated = $request->validate([
'customer_id' => 'required|exists:customers,id',
'items' => 'required|array',
]);
DB::transaction(function () use ($validated) {
$customer = Customer::find($validated['customer_id']);
$order = new Order();
$order->customer_id = $customer->id;
$order->total_cents = collect($validated['items'])
->sum(fn ($i) => $i['price'] * $i['quantity']);
$order->save();
foreach ($validated['items'] as $item) {
OrderItem::create([...]);
}
Mail::to($customer)->send(new OrderConfirmation($order));
});
return response()->json(['ok' => true]);
}
Symfony controllers reach the same shape with $entityManager and MailerInterface injected at the top.
Cost it accrues. The application logic is HTTP-shaped. To run the same operation from a queue worker, a CLI command, or a scheduled job, you copy-paste the controller body and pray nobody finds the divergence. The validation rules are framework-flavored. The transaction is HTTP-scoped. There is no way to test the order-placement logic without spinning up Laravel's request lifecycle.
Grep to find it.
grep -rE 'function (store|update|destroy)\(' app/Http/Controllers \
| xargs -I{} wc -l {} | awk '$1 >= 50 {print}'
Any controller method over fifty lines is doing application work that doesn't belong there.
Smallest fix. The controller becomes a six-line adapter that parses input, calls a use case, and shapes the response. The use case lives in App\Application\Order\PlaceOrder and works the same whether the caller is HTTP, a queue, or a Tinker session.
public function store(Request $request, PlaceOrder $useCase): JsonResponse
{
$input = PlaceOrderInput::fromArray($request->all());
$output = $useCase->execute($input);
return response()->json(['order_id' => $output->orderId]);
}
The same PlaceOrder runs unchanged from the artisan command, the queue worker, and the integration test. The transaction moves inside the use case where it belongs.
What changes when you ship the fixes
None of these are heroic refactors. Each one is a folder change, a constructor cleanup, or a s/setStatus/cancel/ rewrite. They land in a week each and pay back every quarter after.
A repository that returns domain objects keeps returning domain objects because the alternative would mean adding a getOrdersAsArray method, which now looks obviously wrong sitting next to four clean ones.
The hardest part is not the refactor. It is the habit of catching the seven shapes in pull request review before they ship, and naming them out loud when you do. Once the team has shared names for the shapes, a five-minute review comment is enough to push back on a regression.
Start with one. Pick the shape your codebase carries the heaviest copy of. Refactor one file. Run the grep again next week and watch the count drop.
If this was useful
The book builds the full version of every fix in this post. The three-namespace layout, the value-object bestiary, and the repository interface placed inside the domain folder where it belongs. It also includes the migration playbook for moving a legacy Laravel or Symfony codebase to the same shape without a freeze week. The chapter on anti-patterns is where this post comes from; the rest of the book is what you do once you can name them.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)