# Symfony Yoandev Doctrine

> The persistence layer of a Symfony project: entities in maker format with attribute mapping, auto-increment integer ids, \DateTimeImmutable and native enums; relations and the collection traps they open; repositories as the single place where DQL, QueryBuilder and raw SQL are allowed; SELECT NEW projections into Read DTOs; who calls flush(); and migrations that are generated then read and corrected by hand. Use this skill whenever someone asks to create or change an entity, add a field or a column, store or persist something, link two things together, add a relation or a foreign key, write or fix a repository method, write a query, sort or filter a list from the database, count rows, make a query return less data, create or run a migration, rename a column without losing data, or asks "why is my query wrong", "why is this null", "why does saving not work", "the schema is out of sync", "I lost data after deploying".

- Skill: `yoanbernabeu/symfony-yoandev-doctrine` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add yoanbernabeu/symfony-yoandev-doctrine`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yoanbernabeu/symfony-yoandev-doctrine/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: yoanbernabeu (https://skillmd.com/u/yoanbernabeu)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yoanbernabeu/symfony-yoandev-doctrine

---


# Doctrine

> **Tier: core** — an entity, a repository and a reviewed migration are not optional. `SELECT NEW` projections are the on-demand part: reach for one when a list hydrates more than it shows.

Entities, relations, repositories, and migrations that survive contact with real data.

Everything here follows from one decision: **entities are dumb**. They are the maker
shape — private properties, getters and setters, mapping attributes, nothing else. That
choice has consequences all the way up the stack, so it is worth stating the consequence
before the rules.

`vendor/` is the source of truth over anything written here. This skill was verified
against Doctrine ORM 3.6, DBAL 4.4, doctrine-bundle 3.3 and Symfony 8.1; several APIs
that older blog posts still show no longer exist.

## The entity, and what it cannot do

```php
#[ORM\Entity(repositoryClass: BookRepository::class)]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(enumType: ReadingStatus::class)]
    private ReadingStatus $status;

    #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $lastReadAt = null;

    // getId(), getStatus(), setStatus(), … generated by make:entity
}
```

Auto-increment integer identifiers. Rejected: UUIDv7, ULID, an internal id plus a public
one. They solve problems this standard does not have (offline id generation, hiding row
counts) and they cost index size, readability in logs, and a conversation in every review.

`\DateTimeImmutable`, never `\DateTime`. A mutable date handed to a caller can be
modified behind the entity's back, and the change is silently persisted at the next
flush — a bug with no visible cause. Use `Types::DATE_IMMUTABLE` or
`Types::DATETIME_IMMUTABLE`; the plain `date`/`datetime` types map to `\DateTime`.

Native enums with `enumType:` for closed value sets, so the property is genuinely typed
and PHPStan can reason about it.

**The consequence, stated plainly: an entity with a setter for every property enforces
nothing by itself.** The maker shape is kept because one rule with no exceptions is easy
to hold and because `make:entity`, Form, fixtures and EasyAdmin all rely on it; the price
is that the guarantee rests on every write going through a service. So do not write
`publish()`, `rate()` or `archive()` on an entity: a method that guards one path next to
a setter that guards none is decoration. Every business rule lives in a service — the
entity is a typed row, the service owns the behaviour, the repository owns the SQL.

**The one exception: an admin surface that bypasses services.** EasyAdmin writes to the
entity directly, so no service rule applies there. Treat it as a trusted surface, and
when a local rule must still hold in the admin, duplicate it as an `#[Assert]` constraint
on the entity property, reading the same constants the service and the Input DTO read
(`Book::MAX_RATING`). EasyAdmin validates the entity, so the constraint produces a form
error there; it stays inert on the HTTP edge, where the Input DTO is what gets validated
(`symfony-yoandev-http`). The same goes for `#[UniqueEntity]`: on the Input DTO for the API, on
the entity too only if the admin edits that field.

Mapping details, relation ownership, cascade and `orphanRemoval`:
`references/entities-and-relations.md`.

## Relations, and the one line that will kill a page

Bidirectional when the inverse side is genuinely used — when a template or a service
really does walk from the book to its ratings. Unidirectional otherwise; a `mappedBy`
that nobody reads is a collection Doctrine has to manage for nothing.

The price of bidirectional is one specific trap:

```php
count($book->getRatings())          // loads every rating row into memory
$book->getRatings()->count()        // same thing
{{ book.ratings|length }}           // same thing, in a template, in a loop
```

`PersistentCollection::count()` initialises the collection unless the association is
mapped `fetch: 'EXTRA_LAZY'`. On a list of 50 books that is 50 extra queries and every
rating in the database hydrated as an object, to display a number.

**Count in the repository instead** — `countByBook(Book $book): int` doing
`->select('COUNT(r.id)')`. For a list, one grouped query returning counts per book, not
one query per row. The query-count test that proves it belongs to `symfony-yoandev-performance`.

`fetch: 'EXTRA_LAZY'` makes `count()` issue a `SELECT COUNT(*)` instead of hydrating, and
it is a legitimate fix on an existing codebase where the calls are everywhere. It is not
the default here, because it makes an expensive call look free and hides the query from
the person reading the template.

## Repositories

The only place in the application where DQL, QueryBuilder or raw SQL may appear. Not a
style preference: it is what makes `deptrac` able to enforce the layer contract, and what
makes a slow page findable by grepping one directory.

```php
/**
 * Not `final` on purpose: service unit tests double it.
 *
 * @extends ServiceEntityRepository<Review>
 */
class ReviewRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Review::class);
    }

    /** @return list<Review> */
    public function findLatestPublishedForBook(Book $book, int $limit = 10): array
    {
        return $this->createQueryBuilder('r')
            ->andWhere('r.book = :book')
            ->andWhere('r.publishedAt IS NOT NULL')
            ->setParameter('book', $book)
            ->orderBy('r.publishedAt', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }
}
```

Four things in that snippet are rules:

- **`ManagerRegistry` is `Doctrine\Persistence\ManagerRegistry`**, and
  `ServiceEntityRepository` is `Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository`.
  Getting these two imports wrong is the most common Doctrine autowiring failure.
- **Repositories are not `final`.** Everything else in this standard is — services,
  controllers, DTOs, voters, handlers — but a service unit test needs to double its
  repository, and PHPUnit cannot double a final class. The one deliberate exception.
- **`@extends ServiceEntityRepository<Review>` and `@return list<Review>`.** Without the
  generic annotation `find()` returns `object` and PHPStan at level max complains at
  every call site; without the return annotation `getResult()` is `mixed`.
- **The method is named after the caller's intent**, not the mechanism.
  `findLatestPublishedForBook` reads at the call site; `findByBookIdOrderedByDate` makes
  the caller reconstruct why, and lies as soon as the query changes.

`$this->_em` no longer exists — it was removed with ORM 3. Use the protected
`$this->getEntityManager()`.

## Return less: SELECT NEW into a Read DTO

A list page that hydrates entities pays for the identity map, change tracking, and every
column of every row, then uses four fields. Project instead:

```php
/** @return list<ShelfStatsRow> */
public function findShelfStats(): array
{
    return $this->createQueryBuilder('b')
        ->select(sprintf(
            'NEW %s(b.id, b.title, AVG(r.rating), COUNT(r.id), b.lastReadAt)',
            ShelfStatsRow::class,
        ))
        ->leftJoin('b.reviews', 'r')
        ->groupBy('b.id')
        ->getQuery()
        ->getResult();
}
```

The DTO lives in `src/Dto/Read/` and holds raw values in the shape the query dictates.
The service normalises Read → Output (rounding, ISO dates, null versus 0); that
normalisation is a business rule and is unit-testable without a database. If the
projection already matches the API contract, project straight into an Output DTO and skip
the layer.

Two verified behaviours that decide how the DTO is typed: a column mapped with
`enumType:` arrives in the constructor **as the enum**, not as a string; and aggregate
columns arrive with platform-dependent PHP types. Details and the full pattern:
`references/repositories-and-queries.md`.

## Who calls flush()

**The service, once per use case.** The repository does `persist()` and `remove()` and
stops there.

```php
$book = (new Book())->setTitle($input->title);   // maker shape: no constructor arguments
$this->books->add($book);          // repository: persist() only
$this->entityManager->flush();     // service: one flush, one use case
$this->notifier->notifyShelfOwner($book);
```

A repository that flushes takes the decision away from the only object that knows whether
the unit of work is finished — and it makes "add three books then save" cost three
transactions. A service that flushes twice in one method usually has two use cases in it.

`$entityManager->wrapInTransaction(callable $func)` when several flushes genuinely must
succeed or fail together (a batch import, a state change plus a ledger write). Not by
default: a single `flush()` is already atomic.

## Migrations are code, and they are reviewed like code

```bash
php bin/console make:migration          # or doctrine:migrations:diff
# read the file, correct it, then:
php bin/console doctrine:migrations:migrate
```

**The generated file is a draft, never a deliverable.** The generator compares two
schemas. It does not know about the rows already in the table, so a renamed property
comes out as `DROP COLUMN` plus `ADD COLUMN` — which is correct SQL, passes CI, and
destroys the column's contents in production. The fix is one hand-edited line
(`ALTER TABLE … RENAME COLUMN …`), and nothing but a human reading the file will catch
it.

The same applies to a new `NOT NULL` column on a populated table, to a type change that
silently truncates, and to `down()`, which the generator writes as the mechanical inverse
whether or not that inverse is possible.

**Never `doctrine:schema:update` in production.** It answers "make the database match the
mapping", which is a different question from "how do I get from the schema that exists,
with its data, to the one I want". Correcting a generated migration, data migrations and
the platform traps: `references/migrations.md`.

## When something is wrong

| Symptom | Cause |
|---|---|
| Changes are not saved | Nobody called `flush()`, or the service flushed before mutating |
| A list page fires hundreds of queries | `count($x->getItems())` in a loop, or a relation walked in a template |
| `Cannot autowire … ManagerRegistry` | Imported `Doctrine\ORM\…` or the DBAL registry instead of `Doctrine\Persistence\ManagerRegistry` |
| `getId()` is null after saving | Read before `flush()`; the identifier is assigned by the insert |
| A date changed on its own | `\DateTime` instead of `\DateTimeImmutable`, mutated by a caller |
| `nullable` ignored on a `ManyToOne` | `ManyToOne` has no `nullable` argument — it belongs on `#[ORM\JoinColumn]` |
| Schema out of sync, no migration pending | Mapping edited without generating a migration. Run `doctrine:schema:validate` |
| Deleting a parent throws a FK error | Missing `cascade: ['remove']` or `orphanRemoval`, or the owning side is the other one |
| A removed child comes back | `orphanRemoval` not set: removing from the collection only unsets the reference |
| Data lost after a deploy | A generated `DROP`/`ADD` shipped where a `RENAME` was needed |

`php bin/console doctrine:schema:validate` is the check that the mapping still matches the
database. It validates the mapping, the sync state, and — since ORM 3 — that PHP property
types match their Doctrine types. It belongs in CI, where a forgotten migration fails the
build instead of the deploy.

## Reference files

| File | When to read it |
|---|---|
| `references/entities-and-relations.md` | Adding a field or a relation, choosing cascade / `orphanRemoval` / the owning side, mapping an enum or a date |
| `references/repositories-and-queries.md` | Writing or fixing a repository method, projections, aggregates, pagination, PHPStan annotations |
| `references/migrations.md` | Before running a generated migration, renaming or backfilling a column, deploying a schema change |

