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.
Doctrine Doctor follows a layered architecture pattern with clear separation of concerns:
┌──────────────────────────────────────────────┐
│ Presentation Layer │
│ (Symfony Web Profiler Integration) │
└────────────────────┬─────────────────────────┘
│
┌────────────────────▼─────────────────────────┐
│ Application Layer │
│ (Data Collectors, Issue Reconstructors) │
└────────────────────┬─────────────────────────┘
│
┌────────────────────▼─────────────────────────┐
│ Domain Layer │
│ (Analyzers, Issues, Suggestions) │
└────────────────────┬─────────────────────────┘
│
┌────────────────────▼─────────────────────────┐
│ Infrastructure Layer │
│ (Doctrine ORM, Database, Templates) │
└──────────────────────────────────────────────┘
Each analyzer implements AnalyzerInterface (query-based) or MetadataAnalyzerInterface (metadata-based), enabling runtime composition. This pattern provides:
Doctrine Doctor uses Symfony's DataCollector + LateDataCollectorInterface, but analysis is currently executed in collect() for worker-mode safety.
Rationale: avoid stale Doctrine objects in persistent runtimes (FrankenPHP/RoadRunner/Swoole).
Each analyzer creates Issue objects with:
critical, warning, info)High-level modules depend on abstractions, not concrete implementations:
TemplateRendererInterface, not specific renderersAnalyzerInterface[], not concrete analyzersTemplateRendererInterface (implemented by PhpTemplateRenderer or TwigTemplateRenderer)This allows easy substitution of implementations without changing high-level code.
graph TB
subgraph "Symfony Application"
A[HTTP Request] --> B[Controller]
B --> C[Doctrine ORM]
end
subgraph "Doctrine Bundle"
C --> D[DoctrineDataCollector]
end
subgraph "Doctrine Doctor Bundle"
D --> E[DoctrineDoctorDataCollector]
E --> F[ServiceHolder]
F --> G[analyzeQueriesLazy]
G --> H1[Performance Analyzers]
G --> H2[Security Analyzers]
G --> H3[Integrity Analyzers]
G --> H4[Configuration Analyzers]
H1 --> I[Issues]
H2 --> I
H3 --> I
H4 --> I
I --> J[IssueDeduplicator]
J --> K[Profiler Panel]
end
style A fill:#e1f5ff
style E fill:#fff4e1
style G fill:#ffe1e1
style K fill:#e1ffe1
sequenceDiagram
participant Request
participant Symfony
participant DoctrineDC
participant DoctorDC
participant Analyzers
participant Profiler
Request->>Symfony: HTTP Request
activate Symfony
Symfony->>DoctrineDC: Execute queries
DoctrineDC->>DoctrineDC: Log queries
Symfony->>DoctorDC: collect()
activate DoctorDC
DoctorDC->>DoctrineDC: Get query data
DoctrineDC-->>DoctorDC: Query metadata
DoctorDC->>DoctorDC: Store in $data
deactivate DoctorDC
DoctorDC->>DoctorDC: Run analysis in collect()
loop For each analyzer
DoctorDC->>Analyzers: analyze(QueryDataCollection)
Analyzers->>Analyzers: Detect patterns
Analyzers-->>DoctorDC: Return IssueCollection
end
DoctorDC->>DoctorDC: Deduplicate issues
DoctorDC->>DoctorDC: Calculate statistics
deactivate DoctorDC
Symfony-->>Request: Response sent ✓
deactivate Symfony
Profiler->>DoctorDC: getData()
DoctorDC-->>Profiler: Analysis results
Profiler->>Profiler: Render UI
/**
* Analyzer Interface - Strategy Pattern (query-based analyzers)
*/
interface AnalyzerInterface
{
public function analyze(QueryDataCollection $queryDataCollection): IssueCollection;
}
/**
* Metadata Analyzer Interface (metadata-based analyzers)
* Extends AnalyzerInterface for backward compatibility.
* Uses MetadataAnalyzerTrait to bridge analyze() -> analyzeMetadata().
*/
interface MetadataAnalyzerInterface extends AnalyzerInterface
{
public function analyzeMetadata(): IssueCollection;
}
/**
* Template Renderer Interface - Dependency Inversion
*/
interface TemplateRendererInterface
{
/**
* Render a template with given context.
*
* [@param](https://github.com/param) string $templateName Name/identifier of the template
* [@param](https://github.com/param) array<string, mixed> $context Variables to pass to the template
* [@throws](https://github.com/throws) \RuntimeException If template not found or rendering fails
* [@return](https://github.com/return) array{code: string, description: string} Rendered code and description
*/
public function render(string $templateName, array $context): array;
/**
* Check if a template exists.
*/
public function exists(string $templateName): bool;
}
/**
* Issue Interface - Domain Model
*/
interface IssueInterface
{
public function getType(): string;
public function getTitle(): string;
public function getDescription(): string;
public function getSeverity(): Severity;
public function getCategory(): string;
public function getSuggestion(): ?SuggestionInterface;
public function getBacktrace(): ?array;
public function getQueries(): array;
public function getData(): array;
public function toArray(): array;
}
┌─────────────────────────────────┐
│ DoctrineDoctorDataCollector │
│ extends DataCollector │
│ implements LateDataCollectorInterface │
├─────────────────────────────────┤
│ - analyzers: AnalyzerInterface[]│
│ - helpers: DataCollectorHelpers │
│ - doctrineCollector │
├─────────────────────────────────┤
│ + collect(Request, Response) │
│ + lateCollect() │
│ + getData(): array │
│ + getName(): string │
└────────────┬────────────────────┘
│ uses
▼
┌─────────────────────────────────┐
│ DataCollectorHelpers │
├─────────────────────────────────┤
│ - databaseInfoCollector │
│ - issueReconstructor │
│ - queryStatsCalculator │
│ - dataCollectorLogger │
│ - issueDeduplicator │
└────────────┬────────────────────┘
│ coordinates
▼
┌─────────────────────────────────┐
│ AnalyzerInterface │◄──────────┐
├─────────────────────────────────┤ │
│ + analyze(QueryDataCollection) │ │
│ : IssueCollection │ │
└─────────────────────────────────┘ │
△ │
│ implements │
┌───────┴────────┬───────────────┐ │
│ │ │ │
┌────────┐ ┌──────────────┐ ┌────────┐ │
│N+1 │ │SlowQuery │ │Security│ │
│Analyzer│ │Analyzer │ │Analyzer│ │
└────────┘ └──────────────┘ └────────┘ │
│
uses │
┌─────────────────────────────────┐ │
│ TemplateRendererInterface │───────────┘
├─────────────────────────────────┤
│ + render(name, ctx): array │
│ + exists(name): bool │
└─────────────────────────────────┘
△
│ implements
┌───────┴────────┐
│ │
┌────────────┐ ┌──────────────┐
│PhpTemplate │ │TwigTemplate │
│Renderer │ │Renderer │
└────────────┘ └──────────────┘
Decision: Keep LateDataCollectorInterface contract but execute analysis in collect().
Rationale:
Trade-offs:
Decision: Use native PHP templates instead of solely Twig.
Rationale:
Implementation:
// Template: left_join_with_not_null.php
<?php ob_start(); ?>
## Issue: LEFT JOIN with IS NOT NULL
Your query uses LEFT JOIN but filters with IS NOT NULL:
```sql
<?= $context->original_query ?>
```
This is contradictory. Use INNER JOIN instead.
<?php
$code = ob_get_clean();
return ['code' => $code, 'description' => 'Suggestion'];
Note: Templates use SafeContext for automatic XSS protection. See Template Security Guide for details.
Decision: Each analyzer is stateless and independent.
Rationale:
Implementation:
IssueCollection for type safety and predictabilityDecision: Three-level severity system (critical, warning, info).
Rationale:
Criteria:
# config/services.yaml
services:
App\Analyzer\CustomBusinessRuleAnalyzer:
arguments:
$threshold: 100
tags:
- { name: 'doctrine_doctor.analyzer' }
namespace App\Infrastructure;
use AhmedBhs\DoctrineDoctor\Template\Renderer\TemplateRendererInterface;
class CustomTemplateRenderer implements TemplateRendererInterface
{
public function render(string $templateName, array $context): array
{
// Custom rendering logic (e.g., Markdown, reStructuredText)
}
}
services:
App\Infrastructure\CustomTemplateRenderer: ~
AhmedBhs\DoctrineDoctor\Template\Renderer\TemplateRendererInterface:
alias: App\Infrastructure\CustomTemplateRenderer
Extend DataCollectorHelpers for custom issue processing:
namespace App\Service;
use AhmedBhs\DoctrineDoctor\Collector\DataCollectorHelpers;
class CustomDataCollectorHelpers extends DataCollectorHelpers
{
public function processIssues(array $issues): array
{
// Custom filtering, prioritization, or enrichment
return array_filter($issues, fn($issue) =>
$this->matchesBusinessRules($issue)
);
}
}
[← Back to Main Documentation]({{ site.baseurl }}/) | Configuration Reference →
How can I help you explore Laravel packages today?