agentleFS
fatonhfatonh/symfony-skills/skills/doctrine-query-optimization/SKILL.md

doctrine-query-optimization

Skill35 starsChanged 5 months agoMITView on GitHub

Use when writing Doctrine queries, fixing N+1 problems, paginating large result sets, building read models, or tuning DQL/query builder performance. Use when the task mentions N+1, slow query, pagination, DQL, hydration, or read model.

The file

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 3 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    When fetch-joining a to-many and paginating, use Doctrine's Paginator (it switches to a windowed two-query strategy so setMaxResults stays …

    line 38
  2. Distinctive:

    Fix B — read-model DTO (when you only need to display): don't hydrate entities at all.

    line 45
  3. Very specific:

    Default getResult() returns managed entities — most expensive (UoW tracks them).

    line 64
  4. Distinctive:

    Don't load a million rows into memory.

    line 98
  5. Very specific:

    Use EXISTS/SELECT 1 … setMaxResults(1) for existence checks, not count() of loaded rows.

    line 122
  6. Distinctive:

    Add a second-level / result cache only for genuinely hot, rarely-changing queries — measure first.

    line 123

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Doctrine Query Optimization (Symfony)

N+1: identify and fix

One query for the parents + one query per parent for a lazy association = N+1. It hides in loops that touch a lazy relation.

// ❌ N+1 — every $order->getItems() fires a query
$orders = $repo->findBy(['status' => OrderStatus::Active]);
foreach ($orders as $order) {
    $total = count($order->getItems());   // SELECT ... WHERE order_id = ? — N times
}

Fix A — JOIN FETCH (when you need the entities):

public function findActiveWithItems(): array
{
    return $this->createQueryBuilder('o')
        ->addSelect('i')                       // hydrate items in the same query
        ->leftJoin('o.items', 'i')
        ->andWhere('o.status = :status')
        ->setParameter('status', OrderStatus::Active)
        ->getQuery()
        ->getResult();
}

When fetch-joining a to-many and paginating, use Doctrine's Paginator (it switches to a windowed two-query strategy so setMaxResults stays correct):

$query = $qb->setFirstResult($offset)->setMaxResults($limit)->getQuery();
$paginator = new Paginator($query, fetchJoinCollection: true);

Fix B — read-model DTO (when you only need to display): don't hydrate entities at all.

public function summaries(OrderStatus $status): array
{
    return $this->createQueryBuilder('o')
        ->select(sprintf(
            'NEW %s(o.id, o.customerEmail, o.status, o.createdAt)',
            OrderSummary::class,
        ))
        ->andWhere('o.status = :status')
        ->setParameter('status', $status)
        ->getQuery()
        ->getResult();   // array of OrderSummary DTOs — no managed entities, no lazy loading
}

Hydration modes

  • Default getResult() returns managed entities — most expensive (UoW tracks them).
  • getArrayResult() / getScalarResult() — when you only read and never mutate.
  • DQL NEW (DTO hydration) — best for read endpoints; immutable, no identity map cost.
  • For read-only list endpoints, prefer DTOs over entities every time.

Pagination

// Offset pagination (fine for early pages)
$qb->setFirstResult($page * $perPage)->setMaxResults($perPage);

OFFSET scans and discards every skipped row — page 5,000 reads 100,000 rows to return 20. For deep pages / infinite scroll, use keyset (seek) pagination: filter by the last seen sortable key.

// ✅ Keyset — constant time regardless of depth. UUID v7 / createdAt are sortable.
public function pageAfter(?Uuid $lastId, int $limit): array
{
    $qb = $this->createQueryBuilder('o')
        ->orderBy('o.id', 'DESC')
        ->setMaxResults($limit);

    if ($lastId !== null) {
        $qb->andWhere('o.id < :lastId')->setParameter('lastId', $lastId, 'uuid');
    }

    return $qb->getQuery()->getResult();
}

Index the sort key. With a tie-prone sort (createdAt), order by (createdAt DESC, id DESC) and seek on the tuple so the cursor is stable.

Batch processing large sets

Don't load a million rows into memory. Iterate and clear the identity map periodically.

$query = $em->createQuery('SELECT o FROM App\Entity\Order o WHERE o.status = :s')
    ->setParameter('s', OrderStatus::Pending);

$batchSize = 500;
$i = 0;
foreach ($query->toIterable() as $order) {
    $order->markStale();
    if (++$i % $batchSize === 0) {
        $em->flush();
        $em->clear();   // detach everything, release memory
    }
}
$em->flush();
$em->clear();

For inserts, enable JDBC-style batching and flush in chunks. Note: GenerationType.IDENTITY-style auto-increment forces a round-trip per row; UUID v7 keeps batching intact (another reason to use UUIDs).

Other wins

  • Select only the columns you need with partial DTO hydration, not whole entities.
  • Use EXISTS/SELECT 1 … setMaxResults(1) for existence checks, not count() of loaded rows.
  • Add a second-level / result cache only for genuinely hot, rarely-changing queries — measure first.
  • Profile with the Symfony Profiler's Doctrine panel; watch query count and duplicate queries.

Gotchas

  • Agent writes a loop that touches a lazy association — fetch-join it or use a read-model DTO.
  • Agent fetch-joins a to-many and paginates with plain setMaxResults — wrap in Paginator(fetchJoinCollection: true).
  • Agent hydrates full entities for a read-only list endpoint — use DQL NEW DTO hydration.
  • Agent uses OFFSET for deep pagination — switch to keyset/seek on a sortable indexed key.
  • Agent loads huge result sets into an array — use toIterable() + periodic flush()/clear().
  • Agent calls count($repo->findBy(...)) to check existence — use an EXISTS/SELECT 1 query.
  • Agent forgets to index the column used in WHERE/ORDER BY for keyset — add the index.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.