From symfony-skills
Defines Doctrine ORM 3 entity conventions, attribute mapping, associations, repositories, enums, and UUID identifiers in Symfony projects.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-skills:doctrine-ormThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
```php
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: 'orders')]
class Order
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)] // symfony/uid type
private Uuid $id;
#[ORM\Column(length: 255)]
private string $customerEmail;
#[ORM\Column(enumType: OrderStatus::class)] // backed enum, stored as string
private OrderStatus $status;
/** @var Collection<int, OrderItem> */
#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ['persist'], orphanRemoval: true)]
private Collection $items;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
// private constructor — force creation through a named factory
private function __construct(string $customerEmail)
{
$this->id = Uuid::v7(); // sortable UUID
$this->customerEmail = $customerEmail;
$this->status = OrderStatus::Pending;
$this->items = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
}
public static function create(string $customerEmail): self
{
return new self($customerEmail);
}
// Behavior, not a setter
public function addItem(Uuid $productId, int $quantity): void
{
$this->items->add(new OrderItem($this, $productId, $quantity));
}
public function getId(): Uuid { return $this->id; }
public function getStatus(): OrderStatus { return $this->status; }
/** @return Collection<int, OrderItem> */
public function getItems(): Collection { return $this->items; }
}
symfony/uid Uuid (prefer UUID v7 — time-ordered, index-friendly). Never expose auto-increment integers in a public API.#[ORM\Column(enumType: OrderStatus::class)] with a backed enum (string). Never store the ordinal/int.Types::DATETIME_IMMUTABLE + \DateTimeImmutable. Never mutable \DateTime.new ArrayCollection()), typed Collection<int, X>. Never null.Order::create()), so an entity can't be built in an invalid state.declare(strict_types=1); in every file.#[ORM\ManyToOne] and #[ORM\ManyToMany] are LAZY by default — keep it that way. Never fetch: 'EAGER'.#[ORM\JoinColumn]); the inverse side uses mappedBy.// Child owns the FK
#[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'items')]
#[ORM\JoinColumn(name: 'order_id', nullable: false, onDelete: 'CASCADE')]
private Order $order;
// ❌ BAD — eager fetch loads the whole graph on every query
#[ORM\ManyToOne(fetch: 'EAGER')]
private Customer $customer;
Use cascade: ['persist'] deliberately; avoid cascade: ['remove'] — prefer orphanRemoval or explicit deletion so you don't accidentally wipe shared records.
/** @extends ServiceEntityRepository<Order> */
final class OrderRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Order::class);
}
public function save(Order $order, bool $flush = false): void
{
$this->getEntityManager()->persist($order);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/** @return Order[] */
public function findActiveByCustomer(string $email): array
{
return $this->createQueryBuilder('o')
->andWhere('o.customerEmail = :email')
->andWhere('o.status = :status')
->setParameter('email', $email)
->setParameter('status', OrderStatus::Active)
->getQuery()
->getResult();
}
}
@extends ServiceEntityRepository<Entity> so static analysis knows the return types.exists* checks: SELECT 1 … setMaxResults(1) is faster than loading the entity.layered-architecture).#[ORM\Embedded(class: Address::class)]
private Address $shippingAddress;
For single-column value objects (Email, Money), write a custom DBAL type so the model stays rich and the schema stays flat.
fetch: 'EAGER' on associations — keep ManyToOne/ManyToMany LAZY; fetch explicitly when needed (see doctrine-query-optimization).int IDs — use symfony/uid Uuid (v7).enumType with a pure (non-backed) enum — use a string-backed enum.\DateTime — use \DateTimeImmutable with DATETIME_IMMUTABLE.orphanRemoval: true on owning OneToMany — orphaned child rows pile up.cascade: ['remove'] casually — prefer orphanRemoval / explicit deletes to avoid wiping shared rows.new ArrayCollection() in the constructor.@ORM\Entity) — use PHP 8 attributes.@extends ServiceEntityRepository<Entity> generic — analysis loses return types.npx claudepluginhub fatonh/symfony-skills --plugin symfony-skillsModels 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.
Optimizes Doctrine entity fetching with DTO hydration, lazy loading, query hints, and DBAL 4 access. Use when tuning ORM performance or schema evolution.
Generates TypeORM entity configs, code, and best-practice guidance for backend projects.