# Fix N+1 Queries in a Symfony App

Yes. From a slow-page alert, an agent counts the Doctrine queries behind the route, fixes the lazy loads with a join and opens a pull request with a query test.

[Run this on Rush](https://cloud.getrush.ai/run/fix-n-plus-one-queries/symfony/), in a Project that has your repository.

## What the agent does

The agent starts from the alert and the route it names, then reads how this app loads data. In a Symfony app like `symfony/demo`, `composer.json` requires PHP 8.4, Symfony 8, `doctrine/orm ^3.5`, `doctrine/doctrine-bundle ^3.0`, PHPUnit 11.5 with `dama/doctrine-test-bundle`, `doctrine/doctrine-fixtures-bundle` and `phpstan/phpstan-doctrine`, and `.env` points `DATABASE_URL` at `data/database.sqlite` with a `database_test.sqlite` beside it. `config/packages/doctrine.yaml` enables `profiling_collect_backtrace` in debug, which means the web profiler's database panel already shows every query with the line that caused it. The agent reads the repository first: `PostRepository::findLatest` already does this right, with `addSelect('a', 't')`, an `innerJoin('p.author', 'a')` and a `leftJoin('p.tags', 't')`, so the blog index renders author names and tags from one query, and the agent says so rather than rewriting it.

The problem is on the post page. `BlogController::postShow` receives the `Post` through the `{slug:post}` route mapping, which loads the post alone, and `templates/blog/post_show.html.twig` then loops `post.comments`, a lazy `OneToMany` on `Post` ordered by `publishedAt`, and prints `comment.author.fullName` for each, where `Comment::author` is a `ManyToOne` with the default lazy fetch. That is one query for the comments and one more per distinct commenter. The agent proves it before changing anything: it loads the fixtures from `src/DataFixtures/AppFixtures.php`, requests a post page with the profiler enabled and reads the query count from the `db` collector, noting that the fixtures give every comment to the same user, so the identity map hides most of the cost locally while a post with thirty different commenters in production pays thirty queries.

The fix follows the pattern the repository already uses. The agent adds `PostRepository::findOneBySlugWithComments`, a query builder that selects the post, `leftJoin`s `p.comments` and each comment's author with `addSelect`, and keeps the `publishedAt` ordering the entity declares; `postShow` gets a `#[MapEntity]` attribute that resolves the slug through that method, the same attribute `commentNew` already imports for its own route. Nothing changes in the Twig template or the entities, and the `OrderBy` on `Post::comments` still applies. The agent then re-reads the admin pages, `Admin/BlogController::index` and its `findBy(['author' => $user])`, and the RSS output that shares `findLatest`, and reports that neither has a lazy load in a loop.

The proof is a test. `tests/Controller/BlogControllerTest.php` already extends `WebTestCase` and checks the index, the RSS feed, a new comment and the search, so the agent adds a test that enables the profiler on the client, requests a post page, and asserts the `db` collector's query count against a fixed ceiling, failing with the count and the queries when it is exceeded. It runs `vendor/bin/phpunit`, which `phpunit.dist.xml` configures with `failOnDeprecation`, `failOnWarning` and the DAMA extension that wraps each test in a transaction, and `vendor/bin/phpstan analyse`, and puts both outputs in the pull request beside the before and after query counts from the profiler.

## Start it with one API call

`POST https://api.prix.dev/o/{org}/sessions`

```json
{
  "agent": "claude",
  "input": "A latency alert names the blog post page of this Symfony app. Find and fix the N+1 queries behind it and open a pull request. Read composer.json, .env, config/packages/doctrine.yaml, src/Controller/BlogController.php, src/Repository/PostRepository.php, src/Entity/Post.php, src/Entity/Comment.php, templates/blog/post_show.html.twig, templates/blog/_post.html.twig, src/DataFixtures/AppFixtures.php, tests/Controller/BlogControllerTest.php and phpunit.dist.xml first. Load the fixtures into the test database, request a post page with the profiler enabled and record the query count and the queries from the db collector before any change. Fix each lazy load in a loop with a join and addSelect in the repository, following the style of findLatest, and map the route parameter to the new repository method with MapEntity; do not change the templates or add eager fetch to entity mappings. Check the index, RSS and admin pages for the same pattern and report what you find. Add a WebTestCase test that asserts the post page's query count stays under a fixed ceiling and prints the queries when it fails. Run vendor/bin/phpunit and vendor/bin/phpstan analyse, and put the before and after query counts and both outputs in the pull request description.",
  "resources": [
    {
      "url": "https://github.com/your-org/your-app"
    }
  ]
}
```

1. **Get an API key.** In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
2. **Send the request.** POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
3. **Read what came back.** Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.

## What comes back

What comes back is a pull request with a new method in `src/Repository/PostRepository.php`, a `#[MapEntity]` attribute on `postShow` in `src/Controller/BlogController.php`, and a new test in `tests/Controller/BlogControllerTest.php`. The description shows the query list the profiler's `db` collector produced for a post page before the change and after it, with the count on each, and quotes the `vendor/bin/phpunit` and `vendor/bin/phpstan analyse` output from the branch.

The description also says what was checked and left alone: `findLatest` already joins author and tags, the admin post list reads no relation in its loop, and the RSS feed shares the index query. That way a reviewer knows the agent looked at the whole blog, not only the route in the alert.

A reviewer checks the join does not change the comment order the entity declares, that the ceiling in the new test leaves room for the queries the security layer adds on a logged-in request, and that the fixtures still load. Then they merge, and the test keeps the page from regressing when someone adds `comment.author.image` to the template next year.

## Run it on every slow-page alert

In the Project that has your repository, add an automation with this prompt and a `webhook` trigger. Rush shows its URL and secret once. A Grafana contact point can send a custom `Authorization: Bearer <secret>` header, so a latency alert on the post route posts straight to the trigger; the alert payload follows the prompt as the event, and the agent reads the route from it. An alerting service that cannot set that header posts to a small handler on your server, which forwards the payload with the header.

Each fire starts one run in its own sandbox, duplicate deliveries with the same `Idempotency-Key` are dropped, and the automation's `max_runs_per_hour` keeps a flapping alert from starting a run a minute.

## Why run it on Rush?

An N+1 is easy to fix and hard to prove fixed: the profiler panel shows it in development, but the fixtures give every comment to one author, so the local count looks fine and the production page still runs thirty queries. In a Rush sandbox the agent loads the fixtures, reads the real query list from the profiler collector, makes the change, reads it again, and keeps both lists in the transcript beside the diff.

The pull request carries a test that makes the fix permanent, run under the project's own `phpunit.dist.xml` with deprecations and warnings failing, and the PHPStan pass with the Doctrine extension that would catch a wrong DQL alias. The alert that started it is answered with a pull request and the evidence, not a note to look into it.

## Frequently asked questions

### Why not set fetch EAGER on Comment::author?

Because that changes every load of a comment everywhere, including the ones that never read the author, and it hides the decision in an entity attribute. A join in the repository method that serves the post page fixes the page that is slow and leaves the rest of the app as it was, which is also how `findLatest` is already written.

### How does the test count queries?

It enables the profiler on the test client, requests a post page, and reads the `db` collector's query count, the same number the web profiler panel shows. The ceiling is a constant in the test, and the failure message prints the queries so the regression is visible without opening the profiler.

### Will the fixtures show the problem?

Only partly. `AppFixtures.php` gives every comment the same author, so Doctrine's identity map answers the repeated lookups from memory and the count stays low. The agent says so in the pull request and reasons from the mapping, a lazy `ManyToOne` read in a loop, which costs one query per distinct author in production.

### Does it change the templates?

No. `post_show.html.twig` still loops `post.comments` and prints `comment.author.fullName`; the difference is that the collection and the authors arrive with the post in one query instead of being loaded on first access.

### What if the alert names a route that is fine?

The agent records the query count for that route first and, when it finds no lazy load in a loop, says so in the session result with the query list and looks at the other blog routes for the pattern, opening a pull request only if it has something to fix.
