rgalstyan / symfony-aggregated-queries
Reduce multi-relation Doctrine queries to a single SQL statement using JSON aggregation. Solves Doctrine's N+1 problem.
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 2
Watchers: 0
Forks: 0
Open Issues: 0
Type:symfony-bundle
pkg:composer/rgalstyan/symfony-aggregated-queries
Requires
- php: ^8.1
- doctrine/dbal: ^3.6 || ^4.0
- doctrine/orm: ^2.14 || ^3.0
- symfony/config: ^6.0 || ^7.0
- symfony/dependency-injection: ^6.0 || ^7.0
- symfony/http-kernel: ^6.0 || ^7.0
- symfony/yaml: ^6.0 || ^7.0
Requires (Dev)
- doctrine/doctrine-bundle: ^2.10
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^10.5 || ^11.0
- symfony/framework-bundle: ^6.0 || ^7.0
- symfony/phpunit-bridge: ^6.0 || ^7.0
README
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.
π₯ The Problem
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
β¨ The Solution
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_id
Result:
- β 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
π Performance
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.
Reproducible benchmark project
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.
π Requirements
| Component | Version |
|---|---|
| PHP | ^8.1 |
| Symfony | ^6.0 | ^7.0 |
| Doctrine ORM | ^2.14 | ^3.0 |
| MySQL | ^8.0 |
| PostgreSQL | ^12.0 |
π¦ Installation
1. Install via Composer
composer require rgalstyan/symfony-aggregated-queries
2. Enable the Bundle
If you're using Symfony Flex, the bundle is automatically registered.
Otherwise, add to config/bundles.php:
return [ // ... Rgalstyan\SymfonyAggregatedQueries\Bundle\SymfonyAggregatedQueriesBundle::class => ['all' => true], ];
3. (Optional) Configure
Create config/packages/aggregated_queries.yaml:
aggregated_queries: enabled: true debug: '%kernel.debug%' max_relations: 15 default_hydrator: 'array' # array|entity
π Quick Start
1. Add trait to your repository
<?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(); } }
2. Define your entities
<?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... }
3. Use in your service
<?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); } }
4. Use in controller
<?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); } }
5. Response structure (guaranteed)
[
[
'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
],
// ...
]
π‘ Advanced Usage
Direct Service Usage
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(); } }
Filtering and Sorting
$partners = $partnerRepository->aggregatedQuery() ->withJsonRelation('profile') ->where('status', 'active') ->where('countryId', 5) ->whereIn('typeId', [1, 2, 3]) ->orderBy('name', 'ASC') ->limit(100) ->offset(50) ->getResult();
Collections (OneToMany)
$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], ] ]
Multiple Counts
$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 ]
π API Reference
Loading Relations
// Load single relation (ManyToOne, OneToOne) ->withJsonRelation(string $relation, array $columns = []) // Load collection (OneToMany) ->withJsonCollection(string $relation, array $columns = []) // Count related records ->withCount(string $relation)
Query Filters
->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)
Execution
->getResult() // array (default, fastest) ->getResult('array') // Same as above ->getResult('entity') // Hydrate into Doctrine entities (slower) ->getOneOrNullResult() // Get first result or null
Debugging
->toSql() // Get generated SQL ->getParameters() // Get query parameters ->debug() // Enable debug logging
β When to Use
Perfect for:
- β 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
β οΈ Not suitable for:
- β 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)
π Important Constraints
Read-Only by Design
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.
Data Shape Guarantees
| 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.
π¦ Batch Processing
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!
βοΈ Configuration Reference
# 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: false
β οΈ Limitations (v1.0)
Currently 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.
π Comparison: Traditional vs Aggregated
Traditional Doctrine
// 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
Aggregated Queries
// 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
π§ͺ Testing
# 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 check
π§ Troubleshooting
"AggregatedQueryBuilder not injected"
Cause: 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']
"Relation 'xyz' not found"
Cause: Typo in relation name or relation not defined in entity.
Solution: Check entity's #[ORM\ManyToOne], #[ORM\OneToMany], etc. attributes.
Slow performance
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
π Examples
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 |
π€ Contributing
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.
π Security
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.
π Changelog
See CHANGELOG.md for release history and migration guides.
πΊοΈ Roadmap
v1.1 (Q1 2025)
- β¨ ManyToMany support (
belongsToMany) - β¨ Nested relations (
profile.company.country) - β¨ Query result caching (Redis, Memcached)
- β¨ Conditional relation loading
- β¨ Relation callbacks support
v2.0 (Q2-Q3 2025)
- β¨ 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!
π License
The MIT License (MIT). See LICENSE for details.
π¨βπ» Credits
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.
π Related Projects
- 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
π¬ Support
- β 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
π― Why This Bundle Exists
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.
π Show Your Support
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