doctrine-patterns
Doctrine ORM best practices for Symfony: entity mapping as source of truth, repositories, DQL/QueryBuilder with parameters, N+1 prevention with fetch joins, relations (cascade/fetch/orphanRemoval), li
SKILL.mdplugins/symfony-plugin/skills/doctrine-patterns/SKILL.md
GitHubDoctrine Patterns (Symfony)
Patterns for working with Doctrine ORM that catch the common pitfalls (N+1, DQL injection, missing indexes, broken migrations). In Doctrine the entity mapping is the source of truth and migrations are generated from it.
1. Entity mapping with attributes
namespace App\Entity;
use App\Repository\SubscriptionRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SubscriptionRepository::class)]
#[ORM\Table(name: 'subscriptions')]
#[ORM\Index(columns: ['user_id', 'status'])]
class Subscription
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'subscriptions')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\Column(length: 255, unique: true)]
private string $stripeCustomerId;
#[ORM\Column(enumType: Status::class)]
private Status $status;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2)]
private string $amount;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $startsAt;
}
- Use
enumType:to map a backed enum directly. - Money/decimals map to
Types::DECIMAL(string in PHP — never float). - Prefer
DATETIME_IMMUTABLEover mutableDateTime. - Declare indexes/unique constraints on the entity — they become migration SQL.
2. Repositories — extend ServiceEntityRepository
namespace App\Repository;
use App\Entity\Subscription;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Subscription> */
final class SubscriptionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Subscription::class);
}
/** @return Subscription[] */
public function activeForUser(User $user): array
{
return $this->createQueryBuilder('s')
->where('s.user = :user')
->andWhere('s.status = :status')
->setParameter('user', $user)
->setParameter('status', Status::Active)
->getQuery()
->getResult();
}
}
Keep query logic in repositories — never build queries inline in controllers. Repositories are autowired by type.
3. DQL / QueryBuilder — always parameterized
// Right — named parameters
$em->createQuery('SELECT s FROM App\Entity\Subscription s WHERE s.status = :status')
->setParameter('status', Status::Active)
->getResult();
// Wrong — string concatenation (DQL injection)
$em->createQuery("SELECT s FROM App\Entity\Subscription s WHERE s.user = " . $userId); // ❌
$qb->where("s.name = '" . $input . "'"); // ❌
Positional (?1) or named (:name) parameters only. The same rule applies to native SQL via Connection::executeQuery($sql, $params).
4. N+1 prevention — fetch joins
The single most common Doctrine performance bug.
// Problem: lazy relation accessed in a loop → 1 query per row
$subscriptions = $repo->findAll();
foreach ($subscriptions as $s) {
echo $s->getUser()->getEmail(); // N+1
}
// Solution: JOIN FETCH
$repo->createQueryBuilder('s')
->addSelect('u')
->join('s.user', 'u')
->getQuery()
->getResult();
For read-heavy paths, also consider:
setFetchMode/#[ORM\... fetch: 'EAGER']only for relations always needed (use sparingly).- Pagination with
Doctrine\ORM\Tools\Pagination\Paginatorwhen fetch-joining to-many relations withLIMIT.
5. Relations — cascade, fetch, orphanRemoval
#[ORM\OneToMany(mappedBy: 'subscription', targetEntity: Invoice::class, cascade: ['persist'], orphanRemoval: true)]
private Collection $invoices;
#[ORM\ManyToOne(fetch: 'LAZY')]
#[ORM\JoinColumn(onDelete: 'CASCADE')]
private User $user;
- Default
fetch: 'LAZY'— avoidEAGER(it loads on every query, even when unused). cascade: ['persist']to save children with the parent; avoidcascade: ['remove']for large graphs — prefer DBonDelete.orphanRemoval: truedeletes children removed from the collection.onDeleteonJoinColumnenforces referential action at the DB level (faster than ORM cascade).
6. Identity map and flush discipline
- The
EntityManagertracks loaded entities (identity map) — fetching the same row twice returns the same object. - Call
flush()once per unit of work, not per entity. Doctrine batches the SQL inside a transaction.
foreach ($rows as $row) {
$em->persist($this->toEntity($row));
}
$em->flush(); // single transaction
7. Batch processing — clear to avoid memory blowup
$batchSize = 100;
$q = $em->createQuery('SELECT s FROM App\Entity\Subscription s');
foreach ($q->toIterable() as $i => $subscription) {
$subscription->expire();
if (($i % $batchSize) === 0) {
$em->flush();
$em->clear(); // detach processed entities, free memory
}
}
$em->flush();
$em->clear();
8. Lifecycle events / listeners
For cross-cutting concerns (timestamps, audit), prefer entity listeners or event subscribers over fat services.
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class Subscription
{
#[ORM\PrePersist]
public function onCreate(): void
{
$this->createdAt = new \DateTimeImmutable();
}
}
For reusable behaviour across entities (e.g. timestampable), use a dedicated #[AsEntityListener] or the StofDoctrineExtensionsBundle rather than copying callbacks.
9. Migrations — generated, then reviewed
- The mapping is the source of truth. Generate:
php bin/console doctrine:migrations:diff. - Review the generated SQL —
diffis not always perfect (enum CHECK constraints, defaults, index names). - Apply:
php bin/console doctrine:migrations:migrate. Roll back one:... migrate prev. - Verify in sync:
php bin/console doctrine:schema:validate. - Never
doctrine:schema:update --forceoutside throwaway prototypes — it bypasses migration history.
10. Anti-patterns to avoid
| Don't | Do |
|---|---|
| Concatenate input into DQL | setParameter() with :named / ?1 |
fetch: 'EAGER' everywhere |
Lazy by default; JOIN FETCH where needed |
| Access lazy relations in a loop | Fetch join upstream |
float for money |
Types::DECIMAL (string in PHP) |
flush() inside a loop |
flush() once per unit of work |
schema:update --force |
Generated migrations + review |
| Query building in controllers | Repository methods |
11. Checklist before merging Doctrine changes
- No N+1: loops over relations have a fetch join upstream
- All DQL/QueryBuilder uses bound parameters (no concatenation)
- Indexes declared on FKs and frequently-filtered columns
- Money/decimals use
Types::DECIMAL; datetimes are immutable - Relations have correct
fetch(lazy) andonDelete -
flush()called once per unit of work; batch loopsclear() - Migration generated via
diff, SQL reviewed,down()reverses cleanly -
doctrine:schema:validatereports in sync