Reduce multi-relation Doctrine queries to a single optimized SQL statement using JSON aggregation.
Solves Doctrine's documented N+1 problem (Issue #4762) where fetch="EAGER" still generates multiple queries for OneToMany/ManyToMany relations.
Perfect for read-heavy APIs, dashboards, and admin panels where traditional Doctrine eager loading generates too many queries.
When querying Doctrine entities, relations are loaded either lazily (classic N+1) or eagerly using separate queries for collections.
Example with traditional Doctrine entity loading:
$qb = $entityManager->createQueryBuilder();
$partners = $qb
->select('p, profile, country')
->from(Partner::class, 'p')
->leftJoin('p.profile', 'profile')
->leftJoin('p.country', 'country')
->getQuery()
->getResult();Still produces 3–4 separate queries:
SELECT ... FROM partners p
LEFT JOIN profiles profile ON ...
LEFT JOIN countries country ON ...
SELECT ... FROM partner_promocodes WHERE partner_id IN (...) -- additional eager-loading query
SELECT ... FROM discount_rules WHERE promocode_id IN (...)While this is not a fetch join, it still results in multiple database round-trips, heavy Doctrine hydration, increased memory usage, and slower response times.
Fetch joins can reduce the number of queries to one, but introduce row explosion, break pagination, and do not scale when multiple collections are involved.
Doctrine's Known Issue:
Even with fetch="EAGER", OneToMany and ManyToMany relations cause N+1 queries. This is a documented limitation that has existed since 2015.
Complex pages easily generate 5–15 queries, increasing:
- Database round-trips
- Doctrine hydration overhead
- Response time
- Memory usage
- Server load
Transform multiple queries into one optimized SQL statement using JSON aggregation:
$partners = $partnerRepository->aggregatedQuery()
->withJsonRelation('profile', ['id', 'name', 'email'])
->withJsonRelation('country', ['id', 'name', 'code'])
->withJsonCollection('promocodes', ['id', 'code', 'discount'])
->withCount('promocodes')
->getResult();Generates a single query:
SELECT e.*,
JSON_OBJECT('id', rel_profile.id, 'name', rel_profile.name, 'email', rel_profile.email) AS profile,
JSON_OBJECT('id', rel_country.id, 'name', rel_country.name, 'code', rel_country.code) AS country,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'code', code, 'discount', discount))
FROM partner_promocodes WHERE partner_id = e.id) AS promocodes,
(SELECT COUNT(*) FROM partner_promocodes WHERE partner_id = e.id) AS promocodes_count
FROM partners e
LEFT JOIN partner_profiles rel_profile ON rel_profile.partner_id = e.id
LEFT JOIN countries rel_country ON rel_country.id = e.country_idResult:
- ✅ 1 database round-trip instead of 4
- ✅ No Doctrine hydration overhead (uses DBAL directly)
- ✅ Up to 7x faster response time
- ✅ 90%+ less memory usage
- ✅ Consistent array output
Real-world benchmark using a Symfony application with a realistic e-commerce dataset (products, images, reviews).
Dataset context:
- ~100,000 products
- ~300,000 images
- ~500,000 reviews
Example run (limit = 1000):
| Method | Time | Memory | Queries |
|---|---|---|---|
| Traditional Eloquent / Doctrine-style loading | 167.49ms | 24.37 MB | 23 |
| Aggregated Query | 28.17ms | 6.91 MB | 1 |
| Improvement | ⚡ 83.2% faster | 💾 71.6% less | 🔢 22 fewer |
These numbers depend on hardware and DB state, but the overall trend is consistent. The primary gains come from reduced SQL round-trips and avoiding ORM entity hydration.
A standalone Symfony-based benchmark with full setup instructions and fixtures is available here:
The benchmark focuses on read-only, DTO-style queries and compares traditional ORM loading versus aggregated SQL queries in real-world conditions.
| Component | Version |
|---|---|
| PHP | ^8.1 |
| Symfony | ^6.0 | ^7.0 |
| Doctrine ORM | ^2.14 | ^3.0 |
| MySQL | ^8.0 |
| PostgreSQL | ^12.0 |
composer require rgalstyan/symfony-aggregated-queriesIf you're using Symfony Flex, the bundle is automatically registered.
Otherwise, add to config/bundles.php:
return [
// ...
Rgalstyan\SymfonyAggregatedQueries\Bundle\SymfonyAggregatedQueriesBundle::class => ['all' => true],
];Create config/packages/aggregated_queries.yaml:
aggregated_queries:
enabled: true
debug: '%kernel.debug%'
max_relations: 15
default_hydrator: 'array' # array|entity<?php
namespace App\Repository;
use App\Entity\Partner;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Rgalstyan\SymfonyAggregatedQueries\Repository\AggregatedRepositoryTrait;
class PartnerRepository extends ServiceEntityRepository
{
use AggregatedRepositoryTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Partner::class);
}
public function findAllOptimized(): array
{
return $this->aggregatedQuery()
->withJsonRelation('profile', ['id', 'name', 'email'])
->withJsonRelation('country', ['id', 'name', 'code'])
->withJsonCollection('promocodes', ['id', 'code', 'discount'])
->withCount('promocodes')
->where('status', 'active')
->orderBy('createdAt', 'DESC')
->limit(50)
->getResult();
}
}<?php
namespace App\Entity;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PartnerRepository::class)]
#[ORM\Table(name: 'partners')]
class Partner
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(length: 50)]
private ?string $status = null;
#[ORM\ManyToOne(targetEntity: PartnerProfile::class)]
#[ORM\JoinColumn(name: 'profile_id', referencedColumnName: 'id')]
private ?PartnerProfile $profile = null;
#[ORM\ManyToOne(targetEntity: Country::class)]
#[ORM\JoinColumn(name: 'country_id', referencedColumnName: 'id')]
private ?Country $country = null;
#[ORM\OneToMany(targetEntity: PartnerPromocode::class, mappedBy: 'partner')]
private Collection $promocodes;
// Getters/setters...
}<?php
namespace App\Service;
use App\Repository\PartnerRepository;
class PartnerService
{
public function __construct(
private readonly PartnerRepository $partnerRepository
) {}
public function getAllPartnersForApi(): array
{
$partners = $this->partnerRepository->findAllOptimized();
// Transform to API format if needed
return array_map(fn($p) => [
'id' => $p['id'],
'name' => $p['name'],
'profile' => $p['profile'],
'country' => $p['country'],
'promocode_count' => $p['promocodes_count'],
], $partners);
}
}<?php
namespace App\Controller;
use App\Service\PartnerService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
class PartnerController extends AbstractController
{
public function __construct(
private readonly PartnerService $partnerService
) {}
#[Route('/api/partners', methods: ['GET'])]
public function index(): JsonResponse
{
$partners = $this->partnerService->getAllPartnersForApi();
return $this->json($partners);
}
}[
[
'id' => 1,
'name' => 'Partner A',
'status' => 'active',
'created_at' => '2024-01-15 10:30:00',
'profile' => [ // array or null
'id' => 10,
'name' => 'John Doe',
'email' => 'john@example.com'
],
'country' => [ // array or null
'id' => 1,
'name' => 'USA',
'code' => 'US'
],
'promocodes' => [ // always array, never null
['id' => 1, 'code' => 'SAVE10', 'discount' => 10],
['id' => 2, 'code' => 'SAVE20', 'discount' => 20],
],
'promocodes_count' => 2
],
// ...
]Inject AggregatedQueryBuilder directly when you need more flexibility:
<?php
namespace App\Service;
use App\Entity\Partner;
use Rgalstyan\SymfonyAggregatedQueries\AggregatedQueryBuilder;
class ReportService
{
public function __construct(
private readonly AggregatedQueryBuilder $queryBuilder
) {}
public function generatePartnerReport(): array
{
return $this->queryBuilder
->from(Partner::class)
->withJsonRelation('profile', ['id', 'name'])
->withJsonRelation('country', ['id', 'name'])
->withCount('promocodes')
->where('status', 'active')
->orderBy('createdAt', 'DESC')
->getResult();
}
public function generateStatsReport(): array
{
return $this->queryBuilder
->from(Partner::class)
->withCount('orders')
->withCount('promocodes')
->withCount('discountRules')
->where('status', 'active')
->getResult();
}
}$partners = $partnerRepository->aggregatedQuery()
->withJsonRelation('profile')
->where('status', 'active')
->where('countryId', 5)
->whereIn('typeId', [1, 2, 3])
->orderBy('name', 'ASC')
->limit(100)
->offset(50)
->getResult();$partners = $partnerRepository->aggregatedQuery()
->withJsonRelation('profile')
->withJsonCollection('promocodes', ['id', 'code', 'discount', 'expiresAt'])
->withJsonCollection('discountRules', ['id', 'type', 'value'])
->getResult();
// Result structure:
[
'id' => 1,
'profile' => [...],
'promocodes' => [
['id' => 1, 'code' => 'SAVE10', 'discount' => 10, 'expires_at' => '2024-12-31'],
['id' => 2, 'code' => 'SAVE20', 'discount' => 20, 'expires_at' => '2025-01-31'],
],
'discount_rules' => [
['id' => 1, 'type' => 'percentage', 'value' => 15],
]
]$partners = $partnerRepository->aggregatedQuery()
->withJsonRelation('profile')
->withCount('promocodes')
->withCount('discountRules')
->withCount('orders')
->getResult();
// Result structure:
[
'id' => 1,
'profile' => [...],
'promocodes_count' => 15,
'discount_rules_count' => 3,
'orders_count' => 127
]// Load single relation (ManyToOne, OneToOne)
->withJsonRelation(string $relation, array $columns = [])
// Load collection (OneToMany)
->withJsonCollection(string $relation, array $columns = [])
// Count related records
->withCount(string $relation)->where(string $field, mixed $value)
->where(string $field, mixed $value, string $operator = '=')
->whereIn(string $field, array $values)
->orderBy(string $field, string $direction = 'ASC')
->limit(int $limit)
->offset(int $offset)->getResult() // array (default, fastest)
->getResult('array') // Same as above
->getResult('entity') // Hydrate into Doctrine entities (slower)
->getOneOrNullResult() // Get first result or null->toSql() // Get generated SQL
->getParameters() // Get query parameters
->debug() // Enable debug logging- ✅ API endpoints with multiple relations
- ✅ Admin dashboards with complex data views
- ✅ Mobile backends where latency matters
- ✅ Listing pages with 3–10 relations per row
- ✅ Read-heavy services (90%+ reads)
- ✅ High-traffic applications needing DB optimization
- ✅ Replacing Doctrine's N+1 problem
- ❌ Write operations (use standard Doctrine)
- ❌ Doctrine lifecycle events (results are arrays by default)
- ❌ Deep nested relations like
profile.company.country(not yet supported in v1.0) - ❌ Polymorphic relations (not in v1.0)
- ❌ ManyToMany relations (planned for v1.1)
Results are arrays, not Doctrine entities (by default).
This means:
- ❌ No Doctrine lifecycle events (
postLoad,preUpdate, etc.) - ❌ No entity listeners
- ❌ No lazy loading
- ❌ Cannot call
persist(),flush(),remove()
Use for read operations only. For writes, use standard Doctrine.
| Feature | Always Returns |
|---|---|
withJsonRelation() |
array or null |
withJsonCollection() |
array (empty [] if no records) |
withCount() |
integer |
No surprises. No null collections. Consistent types across MySQL and PostgreSQL.
For large exports or background jobs:
use Doctrine\ORM\EntityManagerInterface;
class DataExportService
{
public function __construct(
private readonly PartnerRepository $partnerRepository,
private readonly EntityManagerInterface $em
) {}
public function exportPartners(): void
{
$batchSize = 500;
$offset = 0;
$csvFile = fopen('partners_export.csv', 'w');
do {
$partners = $this->partnerRepository->aggregatedQuery()
->withJsonRelation('profile', ['id', 'name', 'email'])
->withJsonRelation('country', ['id', 'name'])
->withCount('orders')
->orderBy('id', 'ASC')
->limit($batchSize)
->offset($offset)
->getResult();
if (empty($partners)) {
break;
}
foreach ($partners as $partner) {
fputcsv($csvFile, [
$partner['id'],
$partner['name'],
$partner['profile']['name'] ?? 'N/A',
$partner['country']['name'] ?? 'N/A',
$partner['orders_count'],
]);
}
$offset += $batchSize;
// Free memory
unset($partners);
$this->em->clear();
} while (true);
fclose($csvFile);
}
}Do NOT use limit(5000) without batching!
# config/packages/aggregated_queries.yaml
aggregated_queries:
# Enable/disable bundle
enabled: true
# Auto-enable debug in dev environment
debug: '%kernel.debug%'
# Maximum relations per query (safety limit)
max_relations: 15
# Default hydrator: 'array' (fast) or 'entity' (slower)
default_hydrator: 'array'
# Fallback to regular Doctrine on error (not recommended for production)
fallback_enabled: falseCurrently not supported (planned for future versions):
- Nested relations (
profile.company.country) - ManyToMany (
belongsToMany) - Polymorphic relations (not common in Doctrine)
- Doctrine Query Language (DQL) integration
- Callbacks in relations
- Automatic result caching
See Roadmap for planned features.
// Repository
public function findAllTraditional(): array
{
return $this->createQueryBuilder('p')
->select('p, profile, country')
->leftJoin('p.profile', 'profile')
->leftJoin('p.country', 'country')
->getQuery()
->getResult();
}
// Result: 3+ queries
// Hydration: Full Doctrine entities
// Performance: Slower
// Memory: Higher// Repository
public function findAllOptimized(): array
{
return $this->aggregatedQuery()
->withJsonRelation('profile')
->withJsonRelation('country')
->getResult();
}
// Result: 1 query
// Hydration: None (direct arrays)
// Performance: 6-7x faster
// Memory: 90% less# Install dependencies
composer install
# Run tests
composer test
# Run tests with coverage
composer test:coverage
# Static analysis (PHPStan level 9)
composer phpstan
# Code style check
composer cs:check
# Fix code style
composer cs:fix
# Run all checks
composer checkCause: Repository not configured as a service with auto-wiring.
Solution: Ensure repositories are in services.yaml:
services:
App\Repository\:
resource: '../src/Repository/*'
calls:
- setAggregatedQueryBuilder: ['@Rgalstyan\SymfonyAggregatedQueries\AggregatedQueryBuilder']Cause: Typo in relation name or relation not defined in entity.
Solution: Check entity's #[ORM\ManyToOne], #[ORM\OneToMany], etc. attributes.
Causes:
- Missing database indexes on foreign keys
- Too many relations (>10)
- Large collections without LIMIT
Solutions:
-- Add indexes on foreign keys
CREATE INDEX idx_partner_profile ON partners(profile_id);
CREATE INDEX idx_partner_country ON partners(country_id);- Limit collections using filters (future feature)
- Use
->limit()on main query - Use batching for large datasets
See the /examples directory for complete working examples:
| Example | Description |
|---|---|
basic-usage.php |
Simple queries with 2-3 relations |
multiple-relations.php |
Complex relations and collections |
with-filters.php |
Filtering, sorting, and pagination |
service-usage.php |
Service layer integration |
batch-processing.php |
Large dataset handling |
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Add tests for new features
- Ensure tests pass (
composer test) - Check code style (
composer cs:check) - Run static analysis (
composer phpstan) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
See CONTRIBUTING.md for detailed guidelines.
If you discover a security vulnerability, please email:
📧 galstyanrazmik1988@gmail.com
Do not create public issues for security vulnerabilities.
All security vulnerabilities will be promptly addressed.
See CHANGELOG.md for release history and migration guides.
- ✨ ManyToMany support (
belongsToMany) - ✨ Nested relations (
profile.company.country) - ✨ Query result caching (Redis, Memcached)
- ✨ Conditional relation loading
- ✨ Relation callbacks support
- ✨ GraphQL-like query syntax
- ✨ Polymorphic relation support
- ✨ Advanced filtering DSL
- ✨ Performance monitoring integration
- ✨ DQL integration
- ✨ Automatic query optimization
Want a feature? Open an issue or vote on existing ones!
The MIT License (MIT). See LICENSE for details.
Author: Razmik Galstyan
GitHub: @rgalstyan
Email: galstyanrazmik1988@gmail.com
LinkedIn: Razmik Galstyan
Inspired by Laravel Aggregated Queries and built to solve Doctrine's N+1 problem.
Built with ❤️ for the Symfony community.
- Laravel Aggregated Queries - Laravel version of this package
- Doctrine Issue #4762 - The N+1 problem we solve
- Doctrine ORM - The amazing ORM this bundle extends
- ⭐ Star the repo if you find it useful
- 🐛 Report bugs via GitHub Issues
- 💡 Request features via GitHub Issues
- 📖 Improve docs via Pull Requests
- 💬 Ask questions in GitHub Discussions
- 📣 Share with your team and on social media
Doctrine's ORM is powerful but has a well-known N+1 problem with collections that fetch="EAGER" doesn't solve (documented since 2015).
This bundle provides a clean, performant solution using modern SQL's JSON aggregation capabilities:
- ✅ Reduces queries from 5–15 down to 1
- ✅ Up to 7x performance improvement
- ✅ 90% less memory usage
- ✅ Zero configuration needed
- ✅ Works with existing Doctrine entities
Perfect for: APIs, dashboards, mobile backends, and any read-heavy Symfony application.
If this bundle saves you time and improves your app's performance, please:
- ⭐ Star the project on GitHub
- 📣 Share it with your team
- 💬 Write about your experience
- 🤝 Contribute improvements
Every star and contribution helps the project grow!
🚀 Quick Start • 💡 Examples • 🐛 Issues • 💬 Discussions
Made with ❤️ by Razmik Galstyan