All jobs

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 RushIn 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, leftJoins 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 with this body:

{
  "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.

The use case this job follows, with its automation and trigger: Fix a bug from an alert.

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.