Skip to content

Reduce multi-relation Doctrine queries to a single SQL statement using JSON aggregation. Solves Doctrine's N+1 problem.

License

Notifications You must be signed in to change notification settings

rgalstyan/symfony-aggregated-queries

Repository files navigation

Symfony Aggregated Queries

Latest Version Total Downloads Tests License

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:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Add tests for new features
  4. Ensure tests pass (composer test)
  5. Check code style (composer cs:check)
  6. Run static analysis (composer phpstan)
  7. Commit your changes (git commit -m 'Add amazing feature')
  8. Push to the branch (git push origin feature/amazing-feature)
  9. 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


💬 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!


About

Reduce multi-relation Doctrine queries to a single SQL statement using JSON aggregation. Solves Doctrine's N+1 problem.

Resources

License

Contributing

Stars

Watchers

Forks

Packages

No packages published

Languages