SuperAPI AI Directory

symfony-yoandev-doctrine

Coding & development

View repository

Updated 10-16 07:07

About this skill

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

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

  • 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:

/** @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