Reduce multi-relation Eloquent queries to a single optimized SQL statement using JSON aggregation.
Perfect for read-heavy APIs, dashboards, and admin panels where traditional eager loading generates too many queries.
Even with proper eager loading, Laravel generates one query per relation:
Partner::with(['profile', 'country', 'promocodes'])->get();Produces 4 separate queries:
SELECT * FROM partners
SELECT * FROM partner_profiles WHERE partner_id IN (...)
SELECT * FROM countries WHERE id IN (...)
SELECT * FROM partner_promocodes WHERE partner_id IN (...)Complex pages easily generate 5–15 queries, increasing:
- Database round-trips
- Response time
- Memory usage
- Server load
Transform multiple queries into one optimized SQL statement using JSON aggregation:
Partner::aggregatedQuery()
->withJsonRelation('profile')
->withJsonRelation('country')
->withJsonCollection('promocodes')
->get();Generates a single query:
SELECT base.*,
JSON_OBJECT('id', profile.id, 'name', profile.name) AS profile,
JSON_OBJECT('id', country.id, 'name', country.name) AS country,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'code', code))
FROM partner_promocodes WHERE partner_id = base.id) AS promocodes
FROM partners AS base
LEFT JOIN partner_profiles profile ON profile.partner_id = base.id
LEFT JOIN countries country ON country.id = base.country_idResult:
- ✅ 1 database round-trip instead of 4
- ✅ Up to 6x faster response time
- ✅ 90%+ less memory usage
- ✅ Consistent array output
Real benchmark on 2,000 partners with 4 relations (50 records fetched):
| Method | Time | Memory | Queries |
|---|---|---|---|
| Traditional Eloquent | 27.44ms | 2.06MB | 5 |
| Aggregated Query | 4.41ms | 0.18MB | 1 |
| Improvement | ⚡ 83.9% faster | 💾 91.3% less | 🔢 80% fewer |
At scale (10,000 API requests/day):
- 40,000 fewer database queries
- 3.8 minutes saved in response time
- 18.6GB less memory usage
| Component | Version |
|---|---|
| PHP | ^8.1 |
| Laravel | ^10.0 | ^11.0 | ^12.0 |
| MySQL | ^8.0 |
| PostgreSQL | ^12.0 |
composer require rgalstyan/laravel-aggregated-queriesuse Rgalstyan\LaravelAggregatedQueries\HasAggregatedQueries;
class Partner extends Model
{
use HasAggregatedQueries;
public function profile() { return $this->hasOne(PartnerProfile::class); }
public function country() { return $this->belongsTo(Country::class); }
public function promocodes() { return $this->hasMany(PartnerPromocode::class); }
}$partners = Partner::aggregatedQuery()
->withJsonRelation('profile', ['id', 'name', 'email'])
->withJsonRelation('country', ['id', 'name', 'code'])
->withJsonCollection('promocodes', ['id', 'code', 'discount'])
->withCount('promocodes')
->where('is_active', true)
->orderBy('created_at', 'desc')
->limit(50)
->get();foreach ($partners as $partner) {
echo $partner['name'];
echo $partner['profile']['email'] ?? 'N/A';
echo $partner['country']['name'];
echo "Promocodes: " . count($partner['promocodes']);
echo "Count: " . $partner['promocodes_count'];
}Output structure (guaranteed):
[
'id' => 1,
'name' => 'Partner A',
'is_active' => true,
'profile' => ['id' => 10, 'name' => 'John', 'email' => 'john@example.com'], // array or null
'country' => ['id' => 1, 'name' => 'USA', 'code' => 'US'], // array or null
'promocodes' => [ // always array, never null
['id' => 1, 'code' => 'SAVE10', 'discount' => 10],
['id' => 2, 'code' => 'SAVE20', 'discount' => 20],
],
'promocodes_count' => 2
]Already have complex query logic? Pass it as base:
$baseQuery = Partner::query()
->whereHas('profile', fn($q) => $q->where('verified', true))
->where('country_id', '!=', null)
->latest();
$partners = Partner::aggregatedQuery($baseQuery)
->withJsonRelation('profile')
->withJsonRelation('country')
->get();The base query becomes a subquery, preserving all your filters, scopes, and joins.
When using ['*'], the package automatically detects columns from model's $fillable:
Partner::aggregatedQuery()
->withJsonRelation('profile') // Auto-detects: ['id', 'partner_id', 'name', 'email', 'created_at', 'updated_at']
->get();No database metadata queries needed! Works with:
- Custom primary keys (
uuidinstead ofid) - Custom timestamp columns
- Soft deletes (
deleted_at)
For best performance, specify columns explicitly:
Partner::aggregatedQuery()
->withJsonRelation('profile', ['id', 'name', 'email']) // ✅ Fast
->withJsonRelation('country', ['id', 'name']) // ✅ Fast
->withJsonRelation('profile') // ⚠️ Slower (auto-detects columns)
->get();// Load single relation (belongsTo, hasOne)
->withJsonRelation(string $relation, array $columns = ['*'])
// Load collection (hasMany)
->withJsonCollection(string $relation, array $columns = ['*'])
// Count related records
->withCount(string $relation)->where(string $column, mixed $value)
->where(string $column, string $operator, mixed $value)
->whereIn(string $column, array $values)
->orderBy(string $column, string $direction = 'asc')
->limit(int $limit)
->offset(int $offset)->get() // Collection of arrays (default, fastest)
->get('array') // Same as above
->get('eloquent') // Hydrate into Eloquent models (not recommended)
->first() // Get first result
->paginate(int $perPage) // Laravel paginator->toSql() // Get generated SQL
->getBindings() // Get query bindings
->debug() // Log SQL + execution time- API endpoints with multiple relations
- Admin dashboards with complex data
- Mobile backends where latency matters
- Listings/tables with 3–10 relations
- Read-heavy services (90%+ reads)
- High-traffic applications needing DB optimization
- Write operations (use standard Eloquent)
- Model events/observers (results are arrays by default)
- Deep nested relations like
profile.company.country(not yet supported) - Polymorphic relations (
morphTo,morphMany) - Many-to-many (
belongsToMany)
Results are arrays, not Eloquent models (by default).
This means:
- ❌ No model events (
created,updated,deleted) - ❌ No observers
- ❌ No mutators/accessors
- ❌ Cannot call
save(),update(),delete()
Use for read operations only. For writes, use standard Eloquent.
| Feature | Always Returns |
|---|---|
withJsonRelation() |
array or null |
withJsonCollection() |
array (empty [] if no records) |
withCount() |
integer |
No surprises. No null collections. Consistent types.
For large exports, use chunks:
Partner::query()->chunkById(500, function ($partners) {
$ids = $partners->pluck('id');
$data = Partner::aggregatedQuery()
->withJsonRelation('country')
->withJsonCollection('promocodes')
->whereIn('id', $ids)
->get();
// Export to CSV, send to queue, etc.
});Do NOT use limit(5000) — chunk it instead!
Publish config file:
php artisan vendor:publish --tag=aggregated-queries-configconfig/aggregated-queries.php:
return [
// Maximum allowed limit (safety)
'max_limit' => 500,
// Column cache for models without $fillable
'column_cache' => [
'some_table' => ['id', 'name', 'created_at'],
],
];Currently not supported (planned for future versions):
- Nested relations (
profile.company.country) - Callbacks in relations (
withCount('posts', fn($q) => $q->published())) belongsToMany(many-to-many)morphTo/morphOne/morphMany- Query scopes via
__call - Automatic result caching
See /examples directory:
basic-usage.php- Simple queriesmultiple-relations.php- Complex relationswith-filters.php- Filtering and sortingpagination.php- Paginated resultsbatch-export.php- Chunk processing
composer install
# Run tests
composer test
# Run tests with coverage
composer test:coverage
# Static analysis
composer phpstan
# Code formatting
composer formatContributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new features
- Run
composer testandcomposer phpstan - Submit a pull request
If you discover a security vulnerability, please email:
📧 galstyanrazmik1988@gmail.com
Do not create public issues for security vulnerabilities.
See CHANGELOG.md for release history.
The MIT License (MIT). See LICENSE for details.
Author: Razmik Galstyan
GitHub: @rgalstyan
Email: galstyanrazmik1988@gmail.com
Built with ❤️ for the Laravel community.
- ⭐ Star the repo if you find it useful
- 🐛 Report bugs via GitHub Issues
- 💡 Feature requests welcome
- 📖 Improve docs via pull requests