Doctrine
Tier: core — an entity, a repository and a reviewed migration are not optional.
SELECT NEWprojections 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
#[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:
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.
/**
* 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:
ManagerRegistryisDoctrine\Persistence\ManagerRegistry, andServiceEntityRepositoryisDoctrine\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 annotationfind()returnsobjectand PHPStan at level max complains at every call site; without the return annotationgetResult()ismixed.- The method is named after the caller's intent, not the mechanism.
findLatestPublishedForBookreads at the call site;findByBookIdOrderedByDatemakes 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:
/** @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.
$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
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 |