agentleFS
fatonhfatonh/symfony-skills/skills/doctrine-orm/SKILL.md

doctrine-orm

Skill35 starsChanged 5 months agoMITView on GitHub

Use when creating Doctrine ORM 3 entities, associations, repositories, enums, or UUID identifiers in Symfony. Defines entity conventions, attribute mapping, fetch strategy, and repository structure. Use when the task mentions entity, Doctrine, repository, association, or mapping.

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Keep both sides in sync with a helper on the owning aggregate.

    line 81
  2. Very specific:

    Use cascade: ['persist'] deliberately.

    line 96
  3. Very specific:

    Never put business logic in repositories (see layered-architecture).

    line 133

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Doctrine ORM 3 (Symfony)

Entity conventions

#[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; }
}

Rules

  • IDs: symfony/uid Uuid (prefer UUID v7 — time-ordered, index-friendly). Never expose auto-increment integers in a public API.
  • Enums: #[ORM\Column(enumType: OrderStatus::class)] with a backed enum (string). Never store the ordinal/int.
  • Dates: Types::DATETIME_IMMUTABLE + \DateTimeImmutable. Never mutable \DateTime.
  • Collections: initialize in the constructor (new ArrayCollection()), typed Collection<int, X>. Never null.
  • No public setters — expose behavior methods; getters are fine.
  • Factory creation — private constructor + named static factory (Order::create()), so an entity can't be built in an invalid state.
  • declare(strict_types=1); in every file.

Associations & fetch strategy

  • #[ORM\ManyToOne] and #[ORM\ManyToMany] are LAZY by default — keep it that way. Never fetch: 'EAGER'.
  • The owning side holds the FK column (#[ORM\JoinColumn]); the inverse side uses mappedBy.
  • Keep both sides in sync with a helper on the owning aggregate.
// 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.

Repositories

/** @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();
    }
}
  • Type the repository with @extends ServiceEntityRepository<Entity> so static analysis knows the return types.
  • exists* checks: SELECT 1 … setMaxResults(1) is faster than loading the entity.
  • Never put business logic in repositories (see layered-architecture).

Value objects: embeddables & custom types

#[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.

Gotchas

  • Agent uses fetch: 'EAGER' on associations — keep ManyToOne/ManyToMany LAZY; fetch explicitly when needed (see doctrine-query-optimization).
  • Agent uses auto-increment int IDs — use symfony/uid Uuid (v7).
  • Agent stores enums as int / uses enumType with a pure (non-backed) enum — use a string-backed enum.
  • Agent adds public setters to entities — use behavior methods + a factory.
  • Agent uses mutable \DateTime — use \DateTimeImmutable with DATETIME_IMMUTABLE.
  • Agent forgets orphanRemoval: true on owning OneToMany — orphaned child rows pile up.
  • Agent adds cascade: ['remove'] casually — prefer orphanRemoval / explicit deletes to avoid wiping shared rows.
  • Agent leaves collections uninitialized — new ArrayCollection() in the constructor.
  • Agent uses Doctrine annotations (@ORM\Entity) — use PHP 8 attributes.
  • Agent forgets the @extends ServiceEntityRepository<Entity> generic — analysis loses return types.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.