Make Your PHP Domain Speak the Business Language
Your PHP domain code should read like the business problem it solves — not like a framework manual. PHP attributes, plain classes, and a domain a new hire can understand in 30 seconds.
Updated on 2026-05-17
A new developer joins your team. They open OrderService.php as their first task. The class extends AbstractMessageHandler, implements QueueAwareInterface, has half a dozen use Symfony\Component\... statements, and the actual business logic — the part that says “you can’t ship an order that wasn’t paid for” — is on line 84 of a 200-line file. Twenty minutes in, they’ve learned a lot about your framework and almost nothing about your business. That’s the cost of letting the framework speak louder than the domain.
The short version. Maintainable PHP isn’t about avoiding refactors — AI assistants make framework upgrades and renames trivial in 2026. It’s about code that says what the business does, in the language of the business, with as little framework noise as possible. PHP attributes get you there: the framework reads them and wires everything up, but the domain class itself stays free of framework imports. The result is code a human reads as fast as a machine.
What framework noise actually costs
Framework coupling used to be expensive because upgrades broke things. With modern AI tooling, that cost has collapsed — rename a class across a 50k-line codebase in seconds. The cost that hasn’t changed is cognitive:
- Onboarding tax. Every framework concept in your domain is a concept the next reader has to learn before they can read your business logic.
- Bugs hidden under boilerplate. When the business rule is line 84 of a method that started with 80 lines of framework wiring, reviewers and AI both miss it.
- Wrong things look right. A class that extends
AbstractControllerlooks like every other controller — even when it’s actually doing domain work that doesn’t belong in HTTP-tier code. - Slow code review. Reviewers have to mentally subtract the framework noise to see what changed semantically.
None of these are caught by tests. None are caught by AI. They’re paid every time a human reads the code — which is roughly 10x more often than the code is written.
The principle — framework reads your code, your code doesn’t read the framework
The cleanest indicator that a class speaks the domain rather than the framework: it has no use statement importing the framework. PHP attributes make this possible because attributes are read by the framework but don’t have to be imported by the class declaring them — they live in their own namespace.
Compare two ways to register a message handler. First, the framework-coupled version:
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final class OrderHandler implements MessageHandlerInterface
{
public function __invoke(PlaceOrder $command): void
{
// ...
}
}Now the attribute version:
final class OrderHandler
{
#[CommandHandler]
public function place(PlaceOrder $command): void
{
// ...
}
}The second version reads as a sentence: “OrderHandler can place a PlaceOrder command.” The first reads as: “OrderHandler is a Symfony Messenger MessageHandlerInterface that invokes itself with a PlaceOrder.” Same behaviour, completely different cognitive shape.
The proof — the domain becomes portable as a side effect
When the domain stops importing the framework, something useful happens for free: the same code runs anywhere. Here’s a state-stored aggregate written once:
#[Aggregate]
final class Order
{
public function __construct(
#[Identifier] private string $id,
private OrderStatus $status,
) {}
#[CommandHandler]
public static function place(PlaceOrder $command): self
{
return new self($command->orderId, OrderStatus::Placed);
}
#[CommandHandler]
public function ship(ShipOrder $command): void
{
if ($this->status !== OrderStatus::Placed) {
throw new \DomainException("Cannot ship an order that wasn’t placed");
}
$this->status = OrderStatus::Shipped;
}
}The receipt for “the domain reads as the business” is that the file is byte-identical whether you run it on Symfony, Laravel, or standalone:
composer require ecotone/symfony-bundle # Symfony
composer require ecotone/laravel # Laravel
composer require ecotone/lite-application # Standalone / lambda / workerThat’s not the goal — portability is a consequence. The goal is the file itself: it says what an Order is and what can be done with it, with no framework getting in the way of that statement.
What still belongs in the framework
This isn’t an argument against frameworks — the framework still owns the parts that aren’t domain logic:
- HTTP — controllers, routing, request/response. Symfony controllers and Laravel routes live where the framework expects them.
- Console — CLI command parsing, IO. Same.
- DI — service definitions, autowiring. The framework’s container reads your domain attributes and wires everything up.
- Persistence transports — Doctrine, Eloquent, SQL builders. But the domain shouldn’t extend these — it should be persisted by them via repository patterns.
The dividing line is the one PHP file: a controller is allowed to import Symfony or Laravel; a domain aggregate is not. Inside the domain, the only language is the business’s.
Common questions
Doesn’t this require an extra layer of abstraction?
The opposite. The attribute-driven approach has fewer layers than implementing framework interfaces — no adapter classes, no factory wrappers, no double-typing. The framework reads attributes and dispatches directly. There’s no layer in between.
How do I handle stuff like database transactions then?
Cross-cutting concerns like transactions, retries, and logging live in interceptors registered with the framework — not in the domain code. The handler stays clean; the interceptor wraps it.
How does this work with AI-assisted coding?
It works better. AI assistants generate cleaner output when the surrounding code reads as a sentence rather than a tangle of framework conventions. A domain class that says “Order can be placed and shipped” gives the assistant unambiguous context; one that mixes extends AbstractMessageHandler implements TraceableInterface with the actual rule forces the model to guess what part is business intent.
Wrapping up
The longest-lived production PHP codebases share one trait: their domain files read like the business they support. Not because someone enforced “clean code” with a ruler, but because every framework concept that crept into the domain was an obstacle to the next person reading the file. Make your PHP domain speak the business language — everything else is implementation detail.
Companion reads: CQRS in PHP — Stop Mixing Reads and Writes for the read/write split that makes domain intent easier to express, and Enterprise PHP in 2026: The Patterns You’re Missing for the bigger picture.
Dariusz Gafka is a Software Architect and author of the Ecotone Framework. He writes about event sourcing, CQRS, and message-driven PHP architecture.