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-skillsTranslates domain rules into code using entities, value objects, aggregates, repositories, and domain events with explicit invariants.
Generates DDD-compliant aggregates for PHP 8.4 with root entity, child entities, domain events, invariant protection, and unit tests.
Enforces DDD tactical patterns including aggregates, value objects, entity identity, and bounded contexts when designing or modifying domain models.