ahmed-bhs/doctrine-doctor
Doctrine Doctor is a runtime analysis tool for Doctrine ORM integrated into the Symfony Web Profiler. It detects real-world issues like N+1 queries, slow queries, missing indexes, hydration overhead, and injection risks, with actionable backtraces and suggestions.
Learn how to identify and fix N+1 query problems with Doctrine Doctor.
An N+1 query problem occurs when:
Result: N+1 queries instead of 1-2 optimized queries.
<?php
// src/Controller/ArticleController.php
namespace App\Controller;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class ArticleController extends AbstractController
{
#[Route('/articles', name: 'app_articles')]
public function index(ArticleRepository $articleRepository): Response
{
$articles = $articleRepository->findAll(); // 1 query
return $this->render('article/index.html.twig', [
'articles' => $articles,
]);
}
}
{% raw %}{# templates/article/index.html.twig #}
{% for article in articles %}
<article>
<h2>{{ article.title }}</h2>
<p>By {{ article.author.name }}</p> {# +N queries! #}
</article>
{% endfor %}{% endraw %}
🔴 Critical: N+1 Query Detected
Query executed 100 times:
SELECT * FROM author WHERE id = ?
Suggestion: Use eager loading with JOIN
<?php
public function index(ArticleRepository $articleRepository): Response
{
$articles = $articleRepository
->createQueryBuilder('a')
->leftJoin('a.author', 'author')
->addSelect('author') // Eager load authors
->getQuery()
->getResult();
return $this->render('article/index.html.twig', [
'articles' => $articles,
]);
}
Result: 1 query with a JOIN instead of 101 queries.
<?php
// Multiple levels of lazy loading
public function index(ArticleRepository $articleRepository): Response
{
$articles = $articleRepository->findAll();
return $this->render('article/index.html.twig', [
'articles' => $articles,
]);
}
{% raw %}{% for article in articles %}
<article>
<h2>{{ article.title }}</h2>
<p>By {{ article.author.name }}</p>
<p>From {{ article.author.country.name }}</p> {# Nested N+1! #}
<h3>Comments:</h3>
{% for comment in article.comments %} {# Another N+1! #}
<p>{{ comment.content }} - {{ comment.author.name }}</p> {# And another! #}
{% endfor %}
</article>
{% endfor %}{% endraw %}
🔴 Critical: Nested N+1 Query Detected
Multiple N+1 patterns detected:
- article.author: 100 queries
- author.country: 100 queries
- article.comments: 100 queries
- comment.author: 500 queries
Total: 801 queries instead of 1-2
<?php
public function index(ArticleRepository $articleRepository): Response
{
$articles = $articleRepository
->createQueryBuilder('a')
->leftJoin('a.author', 'author')
->leftJoin('author.country', 'country')
->leftJoin('a.comments', 'comments')
->leftJoin('comments.author', 'commentAuthor')
->addSelect('author', 'country', 'comments', 'commentAuthor')
->getQuery()
->getResult();
return $this->render('article/index.html.twig', [
'articles' => $articles,
]);
}
Result: 1 query with multiple JOINs.
<?php
public function generateReport(ArticleRepository $articleRepository): Response
{
$articles = $articleRepository->findAll();
$report = [];
foreach ($articles as $article) {
$report[] = [
'title' => $article->getTitle(),
'author' => $article->getAuthor()->getName(), // N+1!
'comments_count' => $article->getComments()->count(), // N+1!
];
}
return $this->json($report);
}
<?php
public function generateReport(ArticleRepository $articleRepository): Response
{
$articles = $articleRepository
->createQueryBuilder('a')
->leftJoin('a.author', 'author')
->leftJoin('a.comments', 'comments')
->addSelect('author', 'comments')
->getQuery()
->getResult();
$report = [];
foreach ($articles as $article) {
$report[] = [
'title' => $article->getTitle(),
'author' => $article->getAuthor()->getName(),
'comments_count' => $article->getComments()->count(),
];
}
return $this->json($report);
}
<?php
public function index(ArticleRepository $articleRepository): Response
{
$articles = $articleRepository->findAll();
foreach ($articles as $article) {
// Each of these triggers a query!
if ($article->getComments()->isEmpty()) { // Query!
continue;
}
$firstComment = $article->getComments()->first(); // Already loaded
$commentCount = $article->getComments()->count(); // No new query
}
}
Either eager load:
<?php
$articles = $articleRepository
->createQueryBuilder('a')
->leftJoin('a.comments', 'comments')
->addSelect('comments')
->getQuery()
->getResult();
Or use DQL for conditional logic:
<?php
$articles = $articleRepository
->createQueryBuilder('a')
->leftJoin('a.comments', 'c')
->having('COUNT(c.id) > 0') // Filter in database
->groupBy('a.id')
->getQuery()
->getResult();
doctrine_doctor:
analyzers:
n_plus_one:
threshold: 3 # Detect with 3+ duplicate queries
Recommended values:
2 (strict)5 (balanced)10 (focus on major issues)Queries: 501
Execution time: 1,247ms
Memory: 45MB
Queries: 1
Execution time: 23ms
Memory: 8MB
Improvement: 98% faster, 82% less memory
[← First Steps]({{ site.baseurl }}/getting-started/first-steps) | Missing Indexes →
How can I help you explore Laravel packages today?