Werk je aan een PHP-applicatie die elk kwartaal complexer wordt? Dan loont het om naar domain-driven design in PHP te kijken. DDD helpt je om de groeiende business logica gestructureerd in code te vangen, zonder dat alles verzandt in dikke controllers of onleesbare service-classes.
In deze gids leer je de kernconcepten van DDD, hoe je ze toepast in moderne PHP en wanneer DDD wel of juist niet past. We bouwen voort op concepten uit eerdere artikelen, zoals object-oriented PHP basics en het MVC pattern in PHP.
Wat is domain-driven design?
Domain-driven design is een softwareontwerpaanpak die in 2003 werd geïntroduceerd door Eric Evans in zijn boek "Domain-Driven Design". De kerngedachte is simpel: laat je code de werkelijkheid van het bedrijfsdomein weerspiegelen.
In plaats van te starten vanuit databases of frameworks, begin je vanuit de begrippen die domeinexperts dagelijks gebruiken. Een verzekeringspakket, een verkooporder, een patiëntdossier, die concepten krijgen een eigen plek in je code.
DDD is dus geen framework of library. Het is een set principes en patterns die je helpen om complexe domeinen beheersbaar te maken.
Waarom DDD?
Veel PHP-projecten beginnen klein en groeien organisch. Eerst een paar Eloquent models, dan wat services, dan een paar custom helpers. Voor je het weet zit je business logica verspreid over controllers, models, traits en losse functies.
DDD biedt een tegenwicht door te zeggen: business logica hoort op één plek thuis, in het domein. De rest van je applicatie, HTTP, database, queues, is slechts infrastructuur.
De ubiquitous language
Een belangrijk concept binnen DDD is de ubiquitous language: een gedeelde taal tussen developers en domeinexperts. Praat een productmanager over een "klant", dan heet die in jouw code ook Klant (of Customer), niet User of Account.
Deze gedeelde taal voorkomt vertaalfouten. Iedereen praat over hetzelfde, of het nu in een meeting, een ticket of een class-naam is.
// Niet gewenst: technisch jargon dat niets met het domein te maken heeft
class UserRecord {
public function processData(array $input): bool { /* ... */ }
}
// Wel gewenst: de taal van het domein
class Klant {
public function plaatsBestelling(Winkelmandje $mandje): Bestelling { /* ... */ }
}
De bouwstenen van DDD
DDD definieert een aantal tactische patterns die je direct in PHP kunt toepassen. We bespreken de belangrijkste.
Entities
Een entity is een object met een unieke identiteit die in de tijd blijft bestaan. Twee klanten met dezelfde naam zijn niet hetzelfde, ze hebben verschillende ID's.
final class Klant
{
public function __construct(
private readonly KlantId $id,
private string $naam,
private Emailadres $email,
) {}
public function wijzigEmail(Emailadres $nieuw): void
{
$this->email = $nieuw;
}
public function id(): KlantId
{
return $this->id;
}
}
De identiteit (KlantId) maakt een entity uniek, niet de waarden van zijn properties.
Value objects
Een value object wordt volledig bepaald door zijn waarden en is onveranderlijk. Twee value objects met dezelfde waarden zijn gelijk. Denk aan een geldbedrag, een datum of een e-mailadres.
final class Emailadres
{
public function __construct(private readonly string $waarde)
{
if (!filter_var($waarde, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Ongeldig e-mailadres: {$waarde}");
}
}
public function waarde(): string
{
return $this->waarde;
}
public function gelijkAan(Emailadres $ander): bool
{
return $this->waarde === $ander->waarde;
}
}
Value objects beschermen je tegen ongeldige data. Heb je een Emailadres-object in handen, dan weet je zeker dat het geldig is, validatie hoef je niet meer te herhalen. Dit sluit naadloos aan bij de principes uit ons artikel over validatie en security basics.
Aggregates en aggregate roots
Een aggregate is een groep gerelateerde objecten die als één geheel wordt behandeld. De aggregate root is de hoofd-entity die de consistentie binnen de groep bewaakt.
Stel je hebt een Bestelling met Bestelregels. De Bestelling is de root; de buitenwereld werkt nooit direct met losse Bestelregels.
final class Bestelling
{
/** @var Bestelregel[] */
private array $regels = [];
public function __construct(
private readonly BestellingId $id,
private readonly KlantId $klantId,
) {}
public function voegProductToe(ProductId $productId, int $aantal, Geld $prijs): void
{
if ($aantal < 1) {
throw new InvalidArgumentException('Aantal moet minimaal 1 zijn.');
}
$this->regels[] = new Bestelregel($productId, $aantal, $prijs);
}
public function totaal(): Geld
{
return array_reduce(
$this->regels,
fn(Geld $totaal, Bestelregel $regel) => $totaal->plus($regel->subtotaal()),
Geld::nul('EUR'),
);
}
}
Door alleen via de root toe te voegen of te wijzigen, bewaak je business rules op één plek.
Repositories
Een repository abstraheert de opslag van aggregates. In je domein praat je niet met PDO of Eloquent, je vraagt aan een repository om een Bestelling op te halen of te bewaren.
interface BestellingRepository
{
public function vind(BestellingId $id): ?Bestelling;
public function bewaar(Bestelling $bestelling): void;
}
De implementatie zit in je infrastructuurlaag. Daar gebruik je bijvoorbeeld PDO voor databasetoegang of een ORM zoals Eloquent uit Laravel.
final class PdoBestellingRepository implements BestellingRepository
{
public function __construct(private readonly PDO $pdo) {}
public function vind(BestellingId $id): ?Bestelling
{
$stmt = $this->pdo->prepare('SELECT * FROM bestellingen WHERE id = :id');
$stmt->execute(['id' => $id->waarde()]);
// ... map naar Bestelling-aggregate
}
public function bewaar(Bestelling $bestelling): void
{
// ... persist via prepared statements
}
}
Domain services
Soms hoort logica niet bij één specifieke entity of value object. Dan plaats je het in een domain service: een stateless object dat een domeinhandeling uitvoert die meerdere aggregates raakt.
final class KortingsBerekenaar
{
public function bereken(Klant $klant, Bestelling $bestelling): Geld
{
// Logica die niet logisch in Klant of Bestelling thuishoort
}
}
Bounded contexts
In grotere systemen krijg je te maken met bounded contexts: duidelijk afgebakende delen van je domein met een eigen ubiquitous language. Het begrip "Product" in de catalogus-context kan iets anders betekenen dan in de magazijn-context.
Door bounded contexts expliciet te maken, voorkom je dat één gigantisch model alles probeert te dekken. Elke context heeft eigen entities, repositories en services. Communicatie tussen contexts gaat via API's, events of message queues.
Een voorbeeldarchitectuur in PHP
Een typische DDD-mappenstructuur in PHP ziet er als volgt uit:
src/
Verkoop/ # Bounded context
Domain/ # Pure domeinlogica, framework-vrij
Bestelling.php
Bestelregel.php
Geld.php
BestellingRepository.php
Application/ # Use cases, command/query handlers
PlaatsBestellingHandler.php
Infrastructure/ # Implementaties (PDO, HTTP, queues)
PdoBestellingRepository.php
Voorraad/ # Andere bounded context
Domain/
Application/
Infrastructure/
De Domain-laag bevat geen use statements naar Laravel, Symfony, PDO of welke library dan ook. Dat houdt je domein puur en testbaar.
DDD en testen
Omdat je domeinlogica framework-onafhankelijk is, wordt testen een stuk eenvoudiger. Je hoeft geen database op te tuigen om een Bestelling te testen, je instantieert hem gewoon en checkt het gedrag.
public function test_totaal_telt_alle_bestelregels_op(): void
{
$bestelling = new Bestelling(BestellingId::nieuw(), KlantId::nieuw());
$bestelling->voegProductToe(ProductId::nieuw(), 2, Geld::eur(1000));
$bestelling->voegProductToe(ProductId::nieuw(), 1, Geld::eur(500));
$this->assertTrue($bestelling->totaal()->gelijkAan(Geld::eur(2500)));
}
Meer over testschrijven lees je in onze gids over testing in PHP met PHPUnit.
Wanneer gebruik je DDD niet?
DDD is geen wondermiddel. De overhead is aanzienlijk en niet elke applicatie verdient deze investering. Skip DDD als:
- Je een eenvoudige CRUD-applicatie bouwt zonder noemenswaardige business rules
- Je team weinig ervaring heeft met OOP en design patterns
- De levensduur van het project kort is (proof of concept, marketingactie)
- Het domein technisch is in plaats van business-driven (denk aan een proxy of CLI-tool)
In die gevallen is een rechttoe-rechtaan MVC-aanpak met goede routing en middleware prima.
DDD-tooling in het PHP-ecosysteem
Er zijn enkele packages die DDD in PHP makkelijker maken:
- moneyphp/money, een volwassen Money-implementatie als value object
- ramsey/uuid, UUID's als sterke ID-types
- prooph of broadway/broadway, frameworks voor event sourcing en CQRS
- symfony/messenger, uitstekend voor command/query buses
Je hoeft echter geen library te gebruiken om met DDD te starten. Pure PHP en goed gebruikte Composer-autoloading volstaan vaak.
Wil je dieper duiken? De DDD reference van Eric Evans is een gratis pdf met alle bouwstenen op een rij, en het boek Implementing Domain-Driven Design van Vaughn Vernon wordt vaak aangeraden voor praktische voorbeelden.
Conclusie
Domain-driven design in PHP geeft je een toolkit om complexe applicaties beheersbaar te houden. Door entities, value objects, aggregates en repositories te combineren met een gedeelde ubiquitous language, krijgt je code een directe relatie met het bedrijfsdomein.
Begin klein. Pak één bounded context, modelleer een paar value objects en zie wat het oplevert. Met de tijd ontwikkel je gevoel voor wanneer welke pattern past, en wanneer juist niet.
Veelgestelde vragen
Wat is domain-driven design in PHP?
Domain-driven design (DDD) is een aanpak waarbij je de structuur van je PHP-code laat aansluiten op het bedrijfsdomein. Je modelleert begrippen uit de echte wereld als entities, value objects en aggregates, zodat code en business logica één geheel vormen.
Wanneer gebruik je DDD in een PHP-project?
DDD is vooral waardevol bij complexe applicaties met veel business rules, zoals webshops, financiële systemen of verzekeringssoftware. Voor een eenvoudige CRUD-applicatie of klein blog is de overhead meestal niet de moeite waard.
Wat is het verschil tussen een entity en een value object?
Een entity heeft een unieke identiteit die in de tijd blijft bestaan, zoals een gebruiker met een ID. Een value object wordt alleen gedefinieerd door zijn waarden, zoals een geldbedrag of e-mailadres, en is altijd onveranderlijk.
Werkt DDD goed samen met Laravel of Symfony?
Ja, beide frameworks kunnen prima dienen als infrastructuurlaag rondom een DDD-domein. Je houdt je domeinlaag dan framework-onafhankelijk en gebruikt Laravel of Symfony alleen voor HTTP, persistence en services.
Wat is een aggregate root?
Een aggregate root is de hoofd-entity binnen een aggregate die de consistentie van de groep bewaakt. Andere objecten benaderen het aggregate alleen via de root, waardoor je business rules op één plek kunt afdwingen.