| name | symfony-doctrine |
| description | 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". |
Doctrine
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
#[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;
}
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 cannot
hold an invariant. Anything setStatus() was supposed to guarantee is bypassed by the
next caller. So do not write publish(), rate() or archive() on an entity: a method
that enforces nothing, next to a setter that enforces nothing either, 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.
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:
count($book->getRatings())
$book->getRatings()->count()
{{ book.ratings|length }}
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-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.
class ReviewRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Review::class);
}
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:
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.
$book = new Book();
$this->books->add($book);
$this->entityManager->flush();
$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
php bin/console make:migration
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 |