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 Doctrine Doctor identifies missing database indexes and improves query performance.
A missing index forces the database to perform a full table scan, reading every row to find matches. This becomes exponentially slower as tables grow.
Impact:
Doctrine Doctor uses EXPLAIN queries to analyze execution plans:
EXPLAIN SELECT * FROM article WHERE status = 'published'
Signs of missing index:
type: ALL (full table scan)rows: 1000000 (scanning many rows)key: NULL (no index used)<?php
// src/Repository/ArticleRepository.php
public function findPublished(): array
{
return $this->createQueryBuilder('a')
->where('a.status = :status')
->setParameter('status', 'published')
->getQuery()
->getResult();
}
<?php
// src/Entity/Article.php
#[ORM\Entity]
class Article
{
#[ORM\Column(type: 'string')]
private string $status; // No index!
// ...
}
🔴 Critical: Missing Index Detected
Query: SELECT * FROM article WHERE status = ?
Rows scanned: 50,000
Execution time: 234ms
Suggested index:
ALTER TABLE article ADD INDEX idx_status (status);
Add index annotation:
<?php
#[ORM\Entity]
#[ORM\Index(name: 'idx_status', columns: ['status'])]
class Article
{
#[ORM\Column(type: 'string')]
private string $status;
// ...
}
Or directly in MySQL:
ALTER TABLE article ADD INDEX idx_status (status);
Query: SELECT * FROM article WHERE status = ?
Rows scanned: 8,500 (only matching rows)
Execution time: 12ms
Improvement: 95% faster
<?php
public function findByUserAndStatus(User $user, string $status): array
{
return $this->createQueryBuilder('a')
->where('a.author = :author')
->andWhere('a.status = :status')
->setParameter('author', $user)
->setParameter('status', $status)
->getQuery()
->getResult();
}
<?php
#[ORM\Entity]
#[ORM\Index(name: 'idx_author', columns: ['author_id'])]
#[ORM\Index(name: 'idx_status', columns: ['status'])]
class Article
{
#[ORM\ManyToOne(targetEntity: User::class)]
private User $author;
#[ORM\Column(type: 'string')]
private string $status;
}
🟠 Warning: Suboptimal Index Usage
Query uses multiple WHERE conditions but only one index.
Consider a composite index for better performance.
Suggested composite index:
ALTER TABLE article ADD INDEX idx_author_status (author_id, status);
<?php
#[ORM\Entity]
#[ORM\Index(name: 'idx_author_status', columns: ['author_id', 'status'])]
class Article
{
#[ORM\ManyToOne(targetEntity: User::class)]
private User $author;
#[ORM\Column(type: 'string')]
private string $status;
}
Index Column Order Matters:
(author_id, status) - Good for queries filtering by author, or author+status(status, author_id) - Good for queries filtering by status, or status+authorChoose based on your most common query patterns.
<?php
public function findByCategory(Category $category): array
{
return $this->createQueryBuilder('a')
->where('a.category = :category')
->setParameter('category', $category)
->getQuery()
->getResult();
}
<?php
#[ORM\Entity]
class Article
{
#[ORM\ManyToOne(targetEntity: Category::class)]
#[ORM\JoinColumn(nullable: false)]
private Category $category; // Foreign key without index!
}
🔴 Critical: Missing Foreign Key Index
Table: article
Foreign key: category_id
Rows scanned: 120,000
This severely impacts JOIN performance.
Suggested index:
ALTER TABLE article ADD INDEX idx_category (category_id);
<?php
#[ORM\Entity]
#[ORM\Index(name: 'idx_category', columns: ['category_id'])]
class Article
{
#[ORM\ManyToOne(targetEntity: Category::class)]
#[ORM\JoinColumn(nullable: false)]
private Category $category;
}
!!! tip "Automatic Indexes" Some databases (like MySQL InnoDB) automatically create indexes on foreign keys. However, it's best to be explicit.
<?php
public function findLatest(int $limit = 10): array
{
return $this->createQueryBuilder('a')
->orderBy('a.publishedAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
<?php
#[ORM\Entity]
class Article
{
#[ORM\Column(type: 'datetime')]
private \DateTimeInterface $publishedAt; // No index!
}
🟠 Warning: Missing Index for Sorting
Query uses ORDER BY published_at without index.
Causes filesort for large tables.
Suggested index:
ALTER TABLE article ADD INDEX idx_published_at (published_at);
<?php
#[ORM\Entity]
#[ORM\Index(name: 'idx_published_at', columns: ['published_at'])]
class Article
{
#[ORM\Column(type: 'datetime')]
private \DateTimeInterface $publishedAt;
}
<?php
public function search(string $term): array
{
return $this->createQueryBuilder('a')
->where('a.title LIKE :term OR a.content LIKE :term')
->setParameter('term', '%' . $term . '%')
->getQuery()
->getResult();
}
🔵 Info: LIKE Query Without Index
Wildcard searches with leading % cannot use indexes efficiently.
Suggestions:
1. Use full-text search index for MySQL
2. Use PostgreSQL trigram indexes
3. Consider Elasticsearch for complex searches
<?php
#[ORM\Entity]
#[ORM\Index(name: 'idx_fulltext', columns: ['title', 'content'], flags: ['fulltext'])]
class Article
{
#[ORM\Column(type: 'string')]
private string $title;
#[ORM\Column(type: 'text')]
private string $content;
}
<?php
public function search(string $term): array
{
return $this->createQueryBuilder('a')
->where('MATCH(a.title, a.content) AGAINST (:term IN BOOLEAN MODE) > 0')
->setParameter('term', $term)
->getQuery()
->getResult();
}
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_title_trgm ON article USING gin (title gin_trgm_ops);
CREATE INDEX idx_content_trgm ON article USING gin (content gin_trgm_ops);
doctrine_doctor:
analyzers:
missing_index:
slow_query_threshold: 50 # Analyze queries slower than 50ms
min_rows_scanned: 1000 # Flag if scanning 1000+ rows
explain_queries: true # Execute EXPLAIN (requires permissions)
# config/packages/test/doctrine_doctor.yaml
doctrine_doctor:
analyzers:
missing_index:
explain_queries: false # Don't run EXPLAIN in tests
Without index: 5ms
With index: 2ms
Impact: Minimal
Without index: 50-200ms
With index: 5-10ms
Impact: 10-20x improvement
Without index: 2,000-10,000ms
With index: 10-50ms
Impact: 100-500x improvement
-- Check index usage (MySQL)
SHOW INDEX FROM article;
-- Analyze table statistics
ANALYZE TABLE article;
-- Remove unused indexes
DROP INDEX idx_unused ON article;
How can I help you explore Laravel packages today?