From symfony-plugin
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), lifecycle events, batch processing, and generated migrations. Apply when: writing or reviewing Doctrine entities, repositories, and queries. Activated automatically by symfony-plugin/stack.md as a convention skill for the development and database phases. Do NOT use this skill for: - PHP language idioms — see php-foundation:php-conventions. - Controllers/services/forms — see symfony-plugin:symfony-conventions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/symfony-plugin:doctrine-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
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;
}
enumType: to map a backed enum directly.Types::DECIMAL (string in PHP — never float).DATETIME_IMMUTABLE over mutable DateTime.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.
// 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).
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).Doctrine\ORM\Tools\Pagination\Paginator when fetch-joining to-many relations with LIMIT.#[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;
fetch: 'LAZY' — avoid EAGER (it loads on every query, even when unused).cascade: ['persist'] to save children with the parent; avoid cascade: ['remove'] for large graphs — prefer DB onDelete.orphanRemoval: true deletes children removed from the collection.onDelete on JoinColumn enforces referential action at the DB level (faster than ORM cascade).EntityManager tracks loaded entities (identity map) — fetching the same row twice returns the same object.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
$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();
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.
php bin/console doctrine:migrations:diff.diff is not always perfect (enum CHECK constraints, defaults, index names).php bin/console doctrine:migrations:migrate. Roll back one: ... migrate prev.php bin/console doctrine:schema:validate.doctrine:schema:update --force outside throwaway prototypes — it bypasses migration history.| 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 |
Types::DECIMAL; datetimes are immutablefetch (lazy) and onDeleteflush() called once per unit of work; batch loops clear()diff, SQL reviewed, down() reverses cleanlydoctrine:schema:validate reports in syncnpx claudepluginhub aratkruglik/claude-sdlc --plugin symfony-pluginDefines Doctrine ORM 3 entity conventions, attribute mapping, associations, repositories, enums, and UUID identifiers in Symfony projects.
Optimizes Doctrine entity fetching with DTO hydration, lazy loading, query hints, and DBAL 4 access. Use when tuning ORM performance or schema evolution.
Guides completion of development work by verifying tests, detecting environment, and presenting structured options for merge, PR, or cleanup.