From symfony-skills
Models domain logic in Symfony using DDD tactical patterns: aggregates, entities, value objects, domain events, and repositories. Activates on DDD, aggregate, value object, domain event, or invariant mentions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-skills:domain-driven-designThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
| Tactical pattern | In Symfony/PHP |
| Tactical pattern | In Symfony/PHP |
|---|---|
| Aggregate root | Entity class that guards invariants and is the only entry point to its cluster |
| Entity | Has identity (OrderId), mutable over its lifecycle |
| Value object | final readonly class, no identity, compared by value (Money, Email) |
| Domain event | final readonly class recorded on the aggregate, dispatched after persist |
| Repository | Interface in the domain, Doctrine adapter in infrastructure (see hexagonal-architecture) |
| Domain service | Stateless logic that doesn't belong to a single aggregate |
Immutable, self-validating, compared by value. Replace primitive obsession.
// ✅ GOOD — value object enforces its own invariants
final readonly class Email
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$value}");
}
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
final readonly class Money
{
public function __construct(public int $amountInCents, public Currency $currency) {}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \DomainException('Currency mismatch');
}
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}
// ❌ BAD — primitives everywhere, no invariants, validation scattered across services
function createOrder(string $email, int $totalCents): void { /* ... */ }
Map value objects with Doctrine embeddables (#[ORM\Embedded]) or a custom DBAL type, so the table stays flat while the model stays rich.
// ✅ GOOD — behavior methods guard invariants; children reached only through the root
#[ORM\Entity]
class Order
{
#[ORM\Id, ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(enumType: OrderStatus::class)]
private OrderStatus $status;
/** @var Collection<int, OrderLine> */
#[ORM\OneToMany(targetEntity: OrderLine::class, mappedBy: 'order', cascade: ['persist'], orphanRemoval: true)]
private Collection $lines;
/** @var DomainEvent[] */
private array $recordedEvents = [];
private function __construct(Uuid $id)
{
$this->id = $id;
$this->status = OrderStatus::Pending;
$this->lines = new ArrayCollection();
}
public static function place(): self
{
$order = new self(Uuid::v7());
$order->record(new OrderPlaced($order->id));
return $order;
}
public function addLine(Uuid $productId, int $quantity): void
{
if ($this->status !== OrderStatus::Pending) {
throw new \DomainException('Cannot modify a confirmed order'); // invariant
}
$this->lines->add(new OrderLine($this, $productId, $quantity));
}
public function confirm(): void
{
if ($this->lines->isEmpty()) {
throw new \DomainException('Cannot confirm an empty order'); // invariant
}
$this->status = OrderStatus::Confirmed;
$this->record(new OrderConfirmed($this->id));
}
/** @return DomainEvent[] */
public function releaseEvents(): array
{
$events = $this->recordedEvents;
$this->recordedEvents = [];
return $events;
}
private function record(DomainEvent $event): void
{
$this->recordedEvents[] = $event;
}
}
// ❌ BAD — anemic entity: public setters let any caller break invariants
class Order
{
public OrderStatus $status;
public Collection $lines;
public function setStatus(OrderStatus $s): void { $this->status = $s; }
}
Record events inside the aggregate; dispatch them after the transaction commits so subscribers never see uncommitted state. A Doctrine postFlush listener or Messenger does the dispatch.
// ✅ Dispatch recorded events after flush via a Doctrine event subscriber
#[AsDoctrineListener(event: Events::postFlush)]
final class DispatchDomainEventsListener
{
public function __construct(private readonly MessageBusInterface $eventBus) {}
public function postFlush(PostFlushEventArgs $args): void
{
$uow = $args->getObjectManager()->getUnitOfWork();
foreach ($uow->getIdentityMap() as $entities) {
foreach ($entities as $entity) {
if ($entity instanceof RecordsEvents) {
foreach ($entity->releaseEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
}
}
}
}
Order::confirm(), not Order::setStatusToConfirmed().App\Sales\…, App\Billing\…); the same word may differ between them.string/int for Email, Money, OrderId — introduce value objects.#[ORM\ManyToOne] private Customer $customer) — reference by ID across aggregate boundaries.flush() — record on the aggregate, dispatch after commit.save, update) instead of intent (confirm, cancel).npx claudepluginhub fatonh/symfony-skills --plugin symfony-skillsApplies DDD tactical patterns including entities, value objects, aggregates, repositories, and domain events. Use when designing domain models with explicit invariants.
Generates DDD-compliant aggregates for PHP 8.4 with root entity, child entities, domain events, invariant protection, and unit tests.
DDD tactical patterns for complex business modeling: entities, value objects, aggregates, domain services, repositories, specifications, and bounded contexts. Includes Python dataclass implementations with TypeScript alternatives.