Youâre pairing with a teammate, looking at a pull request. Theyâve added a new payment type to the system. You scan the controller â 60 new lines inside an already bloated method. Your stomach sinks. Not because the code is wrong, but because youâve seen this pattern before. Itâs the same shape as the last five features that made this class harder to maintain. You donât know the exact bug that will surface, but you know one will.
This is the skill no tutorial teaches: pattern recognition. The ability to look at a codebase and feel the weight of a design decision before it breaks. In Domain-Driven Design, pattern recognition is everything. Itâs the difference between forcing DDD where it doesnât belong and applying it exactly when your project needs it.
What Youâll Learn
- How to recognize when Transaction Script has reached its limit
- The specific signals that tell you DDD will pay off
- How to build a ubiquitous language with non-technical stakeholders
- The Dragon Wrangling approach to incremental adoption
- PHP 8.1+ code examples of Value Objects, Enums, and Domain Events
- Practical patterns for e-commerce, billing, and multi-step workflows
The Pattern Recognition Skill
Pattern recognition in software isnât about memorizing UML diagrams. Itâs about noticing repetition. You see the same bug fix applied three times in six months â thatâs a pattern. You watch a new developer make the same architectural mistake you made two years ago â thatâs a pattern. You feel dread opening a particular file â thatâs a pattern too.
The best architects Iâve worked with donât start with diagrams. They start with discomfort. A controller thatâs grown beyond 400 lines. A test that takes six seconds to run. A deployment that requires three people on Zoom. These are signals, not failures. The question is whether you recognize them early enough to act.
DDD is one of the most powerful pattern-recognition frameworks we have. It gives you vocabulary for what youâre feeling. When you can name âbounded contextâ as the reason two teams keep stepping on each otherâs code, or âaggregate boundaryâ as the reason your transaction keeps timing out, you stop guessing and start designing.
Reading the Room
Pattern recognition starts outside the code. Listen to your stakeholders. If they use different words to describe the same thing depending on context, youâve found a bounded context boundary. If they say âcustomerâ in billing discussions but âmemberâ in support discussions, those are different models.
// Billing context
final readonly class Customer
{
public function __construct(
public CustomerId $id,
public BillingPlan $plan,
public PaymentMethod $paymentMethod,
) {}
}
// Support context
final readonly class Member
{
public function __construct(
public MemberId $id,
public string $name,
public SupportTier $tier,
) {}
}Same person. Different models. Different bounded contexts. The moment you have two teams using the same database table for different purposes, youâve found a bounded context worth splitting.
Transaction Script vs Domain Model
The Transaction Script pattern organizes code by procedure â one method per database operation. Martin Fowler described it in Patterns of Enterprise Application Architecture as the simplest approach: get a request, do some work, return a response. Itâs the default for most PHP applications, and it works perfectly for 80% of use cases.
The Domain Model pattern organizes code by business concept. Instead of one method that does everything, you have objects that encapsulate rules and relationships. Itâs more complex, but it scales better when business logic is intricate.
When Transaction Script Wins
Use Transaction Script when:
- Each endpoint maps to a single database query
- Business rules fit in a few conditional branches
- You have one or two developers working on the code
- The domain is well-understood and unlikely to change
// Transaction Script â works fine for simple cases
readonly class UpdateProfileController
{
public function __construct(
private DatabaseConnection $db,
) {}
public function handle(ServerRequest $request): Response
{
$userId = $request->getAttribute('user_id');
$name = $request->getParsedBody()['name'];
$this->db->execute(
'UPDATE users SET name = ? WHERE id = ?',
[$name, $userId],
);
return new JsonResponse(['status' => 'ok']);
}
}This is fine. Itâs simple. Itâs testable. Do not add DDD here.
When Transaction Script Fails
The pain points are consistent across projects. Watch for these signals:
The same conditional appearing in multiple controllers. If three different endpoints check âis this athlete eligible for this season,â that logic is leaking. It belongs in a domain object.
Partial updates across related tables. A single user action modifies users, teams, subscriptions, and invoices. If any step fails, you need to roll everything back. Transaction Script makes this fragile.
Business rules that interact. âIf the athlete is on a scholarship and the season is summer and the competition is out-of-state, the fee is waived.â Thatâs not a query. Thatâs a domain rule.
Testing requires a full database. If every test needs fixtures with foreign keys cascading across six tables, your design is fighting you.
// Transaction Script â becoming painful
class RegistrationController
{
public function process(Request $request): Response
{
// This check is duplicated in three controllers
if ($request->get('athlete_type') === 'scholarship'
&& $request->get('season') === 'summer'
&& $request->get('competition_type') === 'out_of_state') {
// waive fee
}
// More business logic inline...
// More conditionals...
// Some SQL mixed in...
// Error handling via try-catch soup...
}
}When you see this pattern, Transaction Script has hit its limit. The code works. But every new feature makes it worse.
When Domain Model Wins
A Domain Model pays for itself when:
- Business rules involve multiple entities with non-trivial relationships
- The same rule manifests in multiple workflows
- You need to enforce invariants (things that must always be true)
- The domain experts argue about edge cases
// Domain Model â appropriate for complex rules
final class Athlete
{
public function __construct(
private readonly AthleteId $id,
private readonly AthleteType $type,
private readonly ScholarshipStatus $scholarship,
) {}
public function isExemptFromFee(Season $season, Competition $competition): bool
{
return $this->type === AthleteType::Student
&& $this->scholarship === ScholarshipStatus::Active
&& $season->isSummer()
&& $competition->isOutOfState();
}
}The rule lives in one place. Every controller, every service, every test calls the same method. Change it once, and the entire system updates.
Signs You Need DDD
DDD carries real overhead. You need more classes, more interfaces, and more discipline around testing. The payoff is proportional to complexity. Hereâs how to know if youâre ready.
The 80/20 Rule of Controllers
Track your controller file sizes. If 20% of your controllers contain 80% of your total controller code, those files are candidates for domain modeling. The long files arenât complex because the authors were lazy. Theyâre complex because the domain is complex.
The Stakeholder Vocabulary Gap
Sit in a requirements meeting. Listen for when stakeholders contradict each other. If the product manager says âa registration is complete when payment is receivedâ and the compliance officer says âa registration is complete when all waivers are signed,â you have two different domain models. The word âregistrationâ means different things in different contexts. DDD gives you a framework for modeling both meanings explicitly.
The âI Donât Know What This Doesâ Moment
When you open a method and canât describe its purpose in one sentence, the code has lost touch with the domain. DDD forces you to name things after business concepts. An Application Service called RegisterAthleteForSeason tells you exactly what it does. A controller method called process tells you nothing.
The Test Cascade
When changing one domain rule breaks tests across five different feature files, your tests are coupled to the wrong abstractions. DDD decouples tests by letting you test domain objects in isolation. You change the rule in one class, update its tests, and the rest of the test suite stays green.
Incremental Adoption
You donât need a Big Bang rewrite to adopt DDD. In fact, a Big Bang rewrite is the fastest way to fail. DDD adoption works best when you apply it one workflow at a time.
Step 1: Identify Your Target
Pick one workflow that hurts. Not the most important workflow. Not the CEOâs pet feature. The one that generates the most support tickets, the most regressions, the most âcan we just add one more thingâ conversations.
Step 2: Write the Safety Net
Before touching any code, write tests that pin the existing behavior. These donât need to be pretty. They need to pass before you start and pass after you finish.
final class RegistrationWorkflowTest extends TestCase
{
public function test_current_registration_behavior(): void
{
// Pin the exact behavior before refactoring
$response = $this->post('/register', [
'athlete_id' => 'abc-123',
'season_id' => 'season-2024',
'disciplines' => ['trap', 'skeet'],
]);
$this->assertSame(200, $response->getStatusCode());
$this->assertDatabaseHas('registrations', [
'athlete_id' => 'abc-123',
'status' => 'pending_payment',
]);
}
}Step 3: Extract One Concept
Find a domain concept buried in the controller. A status check. A fee calculation. An eligibility rule. Extract it into a Value Object or an Enum.
// Before
if ($registration['status'] === 'confirmed' || $registration['status'] === 'active') {
// allow something
}
// After
enum RegistrationStatus: string
{
case Pending = 'pending';
case PendingPayment = 'pending_payment';
case Confirmed = 'confirmed';
case Active = 'active';
case Cancelled = 'cancelled';
case Expired = 'expired';
public function isActive(): bool
{
return in_array($this, [
self::Confirmed,
self::Active,
], true);
}
}
if ($registration->status->isActive()) {
// allow something
}This is a small change. It doesnât require buy-in from the team. It doesnât change the architecture. But it moves the code one step closer to the domain language.
Step 4: Extract the Use Case
Once youâve extracted a few domain concepts, wrap them in an Application Service. The controller becomes thin â it parses the request, calls the service, and returns a response. All business logic lives in the service or the domain objects it coordinates.
Step 5: Repeat
Next sprint, pick another workflow. Over time, the codebase shifts. The balance tips from procedural controllers toward domain objects. You donât announce âweâre adopting DDD.â You just keep making one file better at a time.
Ubiquitous Language in Practice
Ubiquitous Language is the term Eric Evans used for the shared vocabulary between developers and domain experts. Itâs not about using âbusiness-friendlyâ names in code. Itâs about having one set of terms that everyone uses.
Finding the Language
Listen for nouns that come up repeatedly in conversations with stakeholders. These are your domain concepts. Write them down. Notice when different stakeholders use different words for the same thing, or the same word for different things. Those are bounded context boundaries waiting to be discovered.
Translating to Code
The language goes into classes, methods, variables, and directories. If stakeholders say âan athlete registers for a season,â your code should have athlete->registerForSeason($season). If they say âthe system sends a confirmation,â your domain event should be AthleteRegisteredForSeason.
// Code that speaks the domain language
final readonly class Athlete
{
/**
* This method name comes from the business language.
* Stakeholders say "register for a season" â not "create registration record."
*/
public function registerForSeason(
Season $season,
DisciplineCollection $disciplines,
): Registration {
if (! $this->isEligibleFor($season)) {
throw new AthleteNotEligible($this->id, $season->id);
}
return Registration::provisional(
athleteId: $this->id,
seasonId: $season->id,
disciplines: $disciplines,
);
}
}When Language and Code Diverge
The biggest red flag is when developers and stakeholders use different terms for the same concept. If developers say âthe pending queueâ and stakeholders say âthe onboarding funnel,â you have a language disconnect. Over time, this gap widens. Stakeholders canât read the code. Developers misunderstand requirements. The fix is to rename the code to match the stakeholders â not the other way around.
The Dragon Wrangling Approach
The Dragon Wrangling Pattern isnât about fighting complexity. Itâs about containing it. Each dragon â each business rule or workflow step â gets its own enclosure. You donât solve every problem in one massive method. You isolate each concern and define clear protocols for interaction.
Dragon #1: Payment Processing
Payment processing is a classic dragon. It involves multiple gateways, retry logic, reconciliation, refunds, chargebacks, and audit trails. In Transaction Script, all of this lives in the controller. In the Dragon Wrangling approach, payment processing is an Application Service with its own repository, its own error handling, and its own domain objects.
#[AsCommand('payment.process-athlete-pay')]
final readonly class ProcessAthletePayment
{
public function __construct(
private RRegistration $registrationRepository,
private PaymentGateway $gateway,
private InvoiceGenerator $invoices,
private ReportError $errorHandler,
) {}
public function handle(PayRegistrationCommand $command): RegistrationResult
{
try {
$registration = $this->registrationRepository
->findByAthleteId(new AthleteId($command->athleteId));
if ($registration->isPaid()) {
return RegistrationResult::alreadyPaid($registration);
}
$payment = $this->gateway->charge(
amount: $registration->totalFee(),
token: $command->paymentToken,
);
$registration->markAsPaid(
transactionId: $payment->transactionId,
paidAt: new \DateTimeImmutable(),
);
$this->registrationRepository->save($registration);
$this->invoices->generateFor($registration);
return RegistrationResult::success($registration);
} catch (PaymentDeclined $e) {
return $this->errorHandler->handlePaymentDeclined($e);
} catch (GatewayTimeout $e) {
return $this->errorHandler->handleRetryableFailure($e);
}
}
}Dragon #2: Eligibility Checks
Eligibility rules multiply fast. Age limits, residency requirements, skill divisions, team affiliations. Each rule is a simple check. Together, they form a combinatorial explosion. The Dragon Wrangling approach isolates each rule into its own specification:
interface EligibilitySpecification
{
public function isSatisfiedBy(Athlete $athlete, Season $season): bool;
}
final readonly class AgeEligibility implements EligibilitySpecification
{
public function __construct(
private int $minimumAge,
private int $maximumAge,
) {}
public function isSatisfiedBy(Athlete $athlete, Season $season): bool
{
$age = $athlete->dateOfBirth->ageAt($season->startDate);
return $age >= $this->minimumAge && $age <= $this->maximumAge;
}
}
final readonly class AndSpecification implements EligibilitySpecification
{
/** @param EligibilitySpecification[] $specifications */
public function __construct(
private array $specifications,
) {}
public function isSatisfiedBy(Athlete $athlete, Season $season): bool
{
foreach ($this->specifications as $spec) {
if (! $spec->isSatisfiedBy($athlete, $season)) {
return false;
}
}
return true;
}
}
// Usage
$eligibility = new AndSpecification([
new AgeEligibility(12, 18),
new ResidencyRequirement('Minnesota'),
new DivisionEligibility(Division::Varsity),
]);
if ($eligibility->isSatisfiedBy($athlete, $season)) {
$athlete->registerForSeason($season, $disciplines);
}Each specification is testable in isolation. Add a new rule by writing a new class. No existing code changes.
Real-World Use Cases
E-Commerce: Order Lifecycle
An e-commerce order isnât a single database row. Itâs a state machine. An order is created, then it can be paid, shipped, partially shipped, cancelled, refunded, or returned. Each transition has its own rules. You canât ship an unpaid order. You canât refund more than the customer paid. You canât cancel after shipment without initiating a return.
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case PartiallyShipped = 'partially_shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
case Refunded = 'refunded';
public function canTransitionTo(self $target): bool
{
return match ($this) {
self::Pending => in_array($target, [
self::Paid, self::Cancelled,
], true),
self::Paid => in_array($target, [
self::Shipped, self::PartiallyShipped, self::Cancelled,
], true),
self::Shipped => in_array($target, [
self::Delivered, self::Refunded,
], true),
// ...
};
}
}The state machine is explicit. You can read it, test it, and reason about it. No hidden conditions in a service layer.
SaaS Billing: Subscription Management
Billing is where domain models shine. Subscriptions have plans, trial periods, discounts, proration, upgrades, downgrades, cancellations, and reactivations. The rules interact. A downgrade that takes effect next billing cycle. A coupon that doesnât stack with a promotional rate. A trial that converts to a paid plan automatically unless cancelled.
final readonly class Subscription
{
public function __construct(
public SubscriptionId $id,
public CustomerId $customerId,
public Plan $plan,
public SubscriptionStatus $status,
public BillingPeriod $currentPeriod,
public ?Coupon $coupon,
) {}
public function downgradeTo(Plan $newPlan, BillingSchedule $schedule): self
{
if ($newPlan->monthlyPrice() >= $this->plan->monthlyPrice()) {
throw new CannotDowngradeToMoreExpensivePlan($this->id);
}
$effectiveDate = match ($schedule) {
BillingSchedule::Immediate => new \DateTimeImmutable('now'),
BillingSchedule::NextBilling => $this->currentPeriod->endDate,
};
return new self(
id: $this->id,
customerId: $this->customerId,
plan: $newPlan,
status: $this->status,
currentPeriod: $this->currentPeriod->withEndDate($effectiveDate),
coupon: $this->coupon,
);
}
}The downgradeTo method encapsulates the rules. It rejects upgrades disguised as downgrades. It calculates the effective date based on business policy. It returns a new instance, leaving the original unchanged â events can be dispatched based on the transition.
Multi-Step Workflows
Insurance applications, mortgage approvals, government permits â these are multi-step workflows where partial data is valid, validation rules change by step, and the workflow itself depends on previous answers. DDD handles this naturally: each step is an Application Service, the aggregate tracks progress, and domain events trigger the next step.
#[AsCommand('application.submit-step')]
final readonly class SubmitApplicationStep
{
public function __construct(
private RApplication $repository,
private ApplicationWorkflow $workflow,
private DomainEventDispatcher $events,
) {}
public function handle(SubmitStepCommand $command): Result
{
$application = $this->repository->find(new ApplicationId($command->applicationId));
$validation = $this->workflow->validateStep(
step: $command->step,
data: $command->data,
currentState: $application->progress,
);
if ($validation->hasErrors()) {
return Result::invalid($validation->errors);
}
$application = $application->withCompletedStep(
step: $command->step,
data: $command->data,
);
$this->repository->save($application);
foreach ($application->releaseEvents() as $event) {
$this->events->dispatch($event);
}
return Result::success($application->progress);
}
}Best Practices
Test-first before you refactor. Write tests that pin current behavior before you change a single line. These tests are your refactoring safety net. Without them, youâre not refactoring â youâre rewriting. The discipline of writing the test first forces you to understand what the code actually does, not what you think it does.
Use PHP 8.1 features for domain primitives. readonly classes make Value Objects immutable by default. Enums replace stringly-typed constants. Intersection types express constraints at the type level. Named arguments make constructor calls self-documenting.
// PHP 8.1 Value Object with readonly
final readonly class EmailAddress
{
public function __construct(
public string $value,
) {
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailAddress($value);
}
}
}
// PHP 8.1 Enum for domain concepts
enum MembershipTier: string
{
case Basic = 'basic';
case Premium = 'premium';
case Enterprise = 'enterprise';
public function monthlyPrice(): int
{
return match ($this) {
self::Basic => 999, // $9.99
self::Premium => 2999, // $29.99
self::Enterprise => 9999, // $99.99
};
}
public function maxTeamMembers(): int
{
return match ($this) {
self::Basic => 5,
self::Premium => 25,
self::Enterprise => 999,
};
}
}Let the domain language drive naming. If stakeholders say âdisqualify,â donât name the method setStatusToDisqualified. Name it disqualify. The code reads like the business speaks. New developers can follow requirements meetings using the same vocabulary they see in the codebase.
Small iterations over big redesigns. Extract one Value Object this week. One Application Service next week. One bounded context next month. Each change is small enough to review, test, and deploy. Each change makes the codebase slightly more expressive.
Common Mistakes
DDD for CRUD apps. If your application is 90% create-read-update-delete with simple validation, DDD adds accidental complexity. The Transaction Script pattern is better. Youâll know DDD fits when your Transaction Script tests start needing five fixtures to test one endpoint.
Over-engineering from day one. Donât design aggregates, repositories, and domain events before you understand the domain. Let the patterns emerge from the complexity. Start with Transaction Script. Extract domain objects when the pain justifies the abstraction.
Ignoring bounded contexts. A User in the authentication context is not the same as a User in the billing context. They share a login but have different responsibilities, different invariants, and different lifecycles. Model them separately. The duplication is cheaper than the coupling.
Making everything an Entity. Not every domain concept needs an identity. A Money object with a Currency and an Amount is a Value Object â two Money objects with the same values are interchangeable. A Payment is an Entity â each payment has a unique identity and lifecycle. Confusing the two leads to anemic models and unnecessary complexity.
Frequently Asked Questions
Q: How do I convince my team to try DDD?
Donât pitch DDD. Pitch making one specific file better. Pick the controller everyone dreads touching, extract one Value Object, and show the team how the tests become clearer. Let the pattern sell itself.
Q: Whatâs the smallest DDD change I can make today?
Replace a string constant with a PHP 8.1 Enum. Every place that checks $status === 'active' becomes $status === RegistrationStatus::Active. Itâs type-safe, searchable, and introduces the concept of domain language to the codebase.
Q: Do I need Doctrine or Eloquent for DDD?
No. Repositories can use raw PDO, the DBAL query builder, or Eloquent internally. The interface is what matters â it should speak the domain language, not the persistence language. Doctrineâs Entity Manager is useful for complex aggregates but is not required.
Q: When should I use Domain Events?
Use Domain Events when multiple parts of the system need to react to a domain concept. âA registration was completedâ might trigger an email, an invoice, a Slack notification, and a CRM update. The event decouples the reaction from the action. Donât use events for internal method calls â use them for cross-boundary communication.
Q: How do Aggregates relate to database transactions?
An Aggregate is a consistency boundary. One transaction should modify one aggregate. If you need to modify multiple aggregates atomically, reconsider your aggregate design â or use eventual consistency with Domain Events. This is the hardest DDD concept to internalize, and the one that prevents the most bugs.
Q: What if my team doesnât care about architecture?
Code for your future self. You will be the one debugging the 1,500-line controller at 2 AM. You will be the one explaining to a new hire why âjust add one more if statementâ broke three unrelated features. The patterns are not for the team. Theyâre for the code, and the code will outlast every team member.
Q: Does DDD work in Laravel?
Yes. Laravelâs container, Eloquent, queues, and event system map naturally to DDD concepts. Use Eloquent models inside repository implementations. Use the container for dependency injection. Use queued listeners for Domain Event subscribers. The framework supports the pattern â you just need the discipline to draw the boundaries.
The Takeaway
Pattern recognition is the skill that separates experienced developers from everyone else. Itâs not about knowing more patterns â itâs about knowing when a pattern fits. DDD is powerful precisely because it gives you a vocabulary and a framework for recognizing domain complexity before it becomes painful.
You donât need to adopt DDD everywhere. You need to recognize the moment when a simple controller starts accumulating the same three if-conditions across five different methods. You need to feel when your tests require more setup than assertions. You need to hear when stakeholders use words that donât appear anywhere in your codebase.
When you know the pattern, you donât force it. You let it emerge. You start with one Value Object. One Enum. One Application Service. You let the code tell you where the boundaries are, and you follow.
The dragons are waiting. But now you know how to spot them.