tarranjones / laravel-pagination-aggregates
Attach aggregate metadata to Laravel paginated responses
Package info
github.com/tarranjones/laravel-pagination-aggregates
pkg:composer/tarranjones/laravel-pagination-aggregates
Requires
- php: ^8.4
- illuminate/database: ^12.0 || ^13.0
- illuminate/pagination: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- driftingly/rector-laravel: ^2.0
- larastan/larastan: ^3.13
- laravel/pint: ^1.24
- nunomaduro/collision: ^8.6
- orchestra/testbench: ^10.9 || ^11.0
- pestphp/pest: ^4.4 || ^5.0
- pestphp/pest-plugin-laravel: ^4.1 || ^5.0
- phpstan/phpstan: ^2.1
- rector/rector: ^2.0
- roave/security-advisories: dev-latest
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v2.0.0
- v1.0.0
- dev-chore/packagist-auto-publish
- dev-perf/single-row-subqueries
- dev-chore/maintenance
- dev-feat/declare-aggregates-on-builder
- dev-chore/prepare-2.0.0
- dev-fix/grouped-base-aggregates
- dev-chore/review-laravel-13-tooling
- dev-claude/plan-improvements-aY4XU
- dev-claude/analyze-test-coverage-3XQKf
- dev-refactor/rename-macros
- dev-refactor/aggregate-idempotent-guard
- dev-refactor/agg-prefix-and-exists-fix
- dev-feat/aggregate-method
- dev-fix/total-count-constraint-check
This package is auto-updated.
Last update: 2026-11-01 20:52:30 UTC
README
Attach aggregate data (count, sum, max, min, avg, exists) to Laravel paginated responses — computed across the full result set, not just the current page.
Why
- Add rollups alongside paginated data while keeping the native paginator shape.
- Works with length-aware, simple, and cursor pagination.
- Returns Laravel's own paginators, fully populated: API resources, Blade and
through()work unchanged. - Aggregation happens in the database: only the current page of models is ever loaded into PHP.
- Relation aggregates compute correct global values — no average-of-averages bug.
Requirements
- PHP ^8.4 (tested on 8.4 and 8.5; 8.6 tested as pre-release)
- Laravel 12 or 13
Installation
composer require tarranjones/laravel-pagination-aggregates
Quick start
Relation attribute counts by type
Count a related model's records broken down by a status or type attribute. Each entry in the array becomes a separate aggregate keyed by its alias:
use App\Models\Post; use Illuminate\Database\Eloquent\Builder; $posts = Post::query() ->withAggCount([ 'comments as approved_comments' => fn (Builder $q) => $q->where('status', 'approved'), 'comments as pending_comments' => fn (Builder $q) => $q->where('status', 'pending'), 'comments as rejected_comments' => fn (Builder $q) => $q->where('status', 'rejected'), ]) ->paginateWithAggregates(20);
{
"data": [...],
"aggregates": {
"approved_comments": 42,
"pending_comments": 15,
"rejected_comments": 8
}
}
Table attribute counts by type
Count rows in the paginated table itself broken down by a column value. The 'as alias' key scopes each count to its constraint — all are batched into a single query:
use App\Models\Order; use Illuminate\Database\Eloquent\Builder; $orders = Order::query() ->withAggCount([ 'as pending' => fn (Builder $q) => $q->where('status', 'pending'), 'as processing' => fn (Builder $q) => $q->where('status', 'processing'), 'as shipped' => fn (Builder $q) => $q->where('status', 'shipped'), 'as delivered' => fn (Builder $q) => $q->where('status', 'delivered'), ]) ->paginateWithAggregates(25);
{
"data": [...],
"aggregates": {
"pending": 23,
"processing": 18,
"shipped": 31,
"delivered": 76
}
}
Usage
The paginator methods are prefixed
withAgg…(withAggCount(),withAggSum(), …) because they compute one value across the whole result set. Eloquent's ownwithCount()/withSum()add a value per row, and you can still use those on the query before paginating.
Pagination macros
Declare aggregates with the withAgg*() methods on the query, then paginate with one of three macros:
Order::query()->paginateWithAggregates($perPage); // LengthAwarePaginator Order::query()->simplePaginateWithAggregates($perPage); // Paginator (no total count) Order::query()->cursorPaginateWithAggregates($perPage); // CursorPaginator
The paginators are Laravel's own (LengthAwarePaginator, Paginator, CursorPaginator subclasses), fully populated when returned. The aggregates are available as $paginator->aggregates and are added to toArray() / toJson() / API resource output under aggregates.
Reading aggregates in PHP
$paginator = Order::query() ->withAggCount([ 'as pending' => fn (Builder $q) => $q->where('status', 'pending'), 'as delivered' => fn (Builder $q) => $q->where('status', 'delivered'), ]) ->paginateWithAggregates(25); $pending = $paginator->aggregates['pending']; $delivered = $paginator->aggregates['delivered'];
Aggregates are declared on the query, so they follow it through the rest of the chain: where() / scopes added after withAgg*() still apply, and a cloned query carries its own copy.
Base query aggregates
Aggregate over the paginator's own base query — any where, scope, or date filter applied to the builder is automatically reflected in the totals.
Comment::query() ->withAggCount() // aggregates['count'] — total rows ->withAggMax('votes') // aggregates['max_votes'] ->withAggMin('votes') // aggregates['min_votes'] ->withAggSum('votes') // aggregates['sum_votes'] ->withAggAvg('votes') // aggregates['avg_votes'] ->withAggExists() ->paginateWithAggregates(20); // aggregates['exists'] — true if any rows match
withAggCount()optimization: When used withpaginateWithAggregates, a basewithAggCount()is reused as the paginator'stotal, saving the separateCOUNT(*)query thatLengthAwarePaginatornormally fires. Two queries instead of three.
Custom alias
Use 'column as alias' to control the output key:
->withAggSum('total as revenue') // aggregates['revenue'] ->withAggMax('id as latest_id') // aggregates['latest_id'] ->withAggExists('as has_results') // aggregates['has_results']
Constrained base aggregates
Use the array form ['as alias' => fn] to apply a constraint to a base aggregate. Multiple entries are batched into a single query:
Order::query() ->withAggCount([ 'as pending' => fn (Builder $q) => $q->where('status', 'pending'), 'as processing' => fn (Builder $q) => $q->where('status', 'processing'), 'as shipped' => fn (Builder $q) => $q->where('status', 'shipped'), 'as delivered' => fn (Builder $q) => $q->where('status', 'delivered'), ]) ->paginateWithAggregates(25);
The same syntax works for all numeric aggregates — pass the column as the second argument:
->withAggSum(['as high_value' => fn (Builder $q) => $q->where('total', '>', 1000)], 'total') ->withAggMax(['as todays_max' => fn (Builder $q) => $q->whereDate('created_at', today())], 'total')
Each unique constraint becomes its own single-row subquery; aggregates that share a constraint are batched into the same one. All subqueries are combined into one query:
SELECT * FROM (SELECT COUNT(*) AS `pending` FROM `orders` WHERE `status` = 'pending') AS `agg_orders` CROSS JOIN (SELECT COUNT(*) AS `processing` FROM `orders` WHERE `status` = 'processing') AS `agg_orders_2` -- etc.
Relation aggregates
Aggregate over related models by passing a relation name alongside a column:
Post::query() ->withAggCount('comments') // aggregates['comments_count'] ->withAggMax('comments', 'votes') // aggregates['comments_max_votes'] ->withAggMin('comments', 'votes') // aggregates['comments_min_votes'] ->withAggSum('comments', 'votes') // aggregates['comments_sum_votes'] ->withAggAvg('comments', 'votes') // aggregates['comments_avg_votes'] ->withAggExists('comments') ->paginateWithAggregates(15); // aggregates['comments_exists']
Multiple relation names can be passed as separate arguments or as an array:
->withAggCount('comments', 'tags') // variadic — two separate aggregates ->withAggCount(['comments', 'tags']) // equivalent array form
Results represent the full result set — even when viewing page 3, the aggregates cover all pages.
Custom alias
Pass 'relation as alias' to rename the output key:
->withAggMax('comments as top_vote', 'votes') // aggregates['top_vote'] ->withAggCount('comments as total_replies') // aggregates['total_replies']
Closure constraint
Pass a closure as the array value to scope the relation aggregate query:
use Illuminate\Database\Eloquent\Builder; ->withAggCount([ 'comments as approved_comments' => fn (Builder $q) => $q->where('status', 'approved'), 'comments as pending_comments' => fn (Builder $q) => $q->where('status', 'pending'), ])
Combine alias and constraint in a single array key for numeric aggregates:
->withAggSum( ['comments as approved_vote_total' => fn (Builder $q) => $q->where('status', 'approved')], 'votes', )
Mixing base and relation aggregates
Base query and relation aggregates can be combined freely on the same paginator:
Post::query() ->withAggCount() // total posts (also used as paginator total) ->withAggMax('id as latest_id') // max post ID in the result set ->withAggSum('comments', 'votes') // total votes across all comments ->withAggCount('comments') ->paginateWithAggregates(15); // total comment count
Grouped, distinct and union queries
For groupBy(), having(), distinct() and union() queries, aggregates are computed over the rows the paginator returns, across all pages: one row per group, distinct value or union row.
Order::query() ->selectRaw('status, SUM(quantity) AS units') ->groupBy('status') ->withAggCount() // number of status groups ->withAggSum('units') // sum of the selected `units` column across all groups ->withAggMax('units') ->paginateWithAggregates(10); // largest group
- Base aggregates take a column of the query's result, such as a selected alias like
units. A qualified name such asorders.quantityresolves to the result columnquantity. - Constraint closures (
['as paid' => fn ($q) => $q->where(...)]) are applied to the query before grouping. - Relation aggregates are computed for each result row and then rolled up, so the query must select the columns the relation uses (for example
post_idfor apost()relation, oridforcomments()). - The paginator
totalcomes from Laravel's own count query.
SQL reference
All aggregates are computed in one query that returns one row: every group of aggregates becomes a single-row subquery, and the subqueries are combined with CROSS JOIN. (A query with a single group is run directly.) Only the current page of models is ever loaded into PHP.
Single base aggregate — scalar query
Comment::query()->withAggSum('votes')->paginateWithAggregates(20);
SELECT SUM(`votes`) AS `sum_votes`, COUNT(*) AS `__paginator_total` FROM `comments`
The paginator total is computed in the same query (or taken from a base withAggCount()), so Laravel's separate COUNT(*) is skipped.
Base aggregates with different constraints
Order::query() ->withAggCount([ 'as pending' => fn (Builder $q) => $q->where('status', 'pending'), 'as shipped' => fn (Builder $q) => $q->where('status', 'shipped'), ]) ->paginateWithAggregates(25);
SELECT * FROM (SELECT COUNT(*) AS `pending` FROM `orders` WHERE `status` = 'pending') AS `agg_orders` CROSS JOIN (SELECT COUNT(*) AS `shipped` FROM `orders` WHERE `status` = 'shipped') AS `agg_orders_2`
HasOne / HasMany / MorphOne / MorphMany
Related rows are aggregated directly, restricted to the parents in the base query (and to the parent's morph type), so an index on the foreign key is used:
Post::query()->where('published', true)->withAggCount('comments')->withAggMax('comments', 'votes')->paginateWithAggregates(15);
SELECT COUNT(*) AS `comments_count`, MAX(`votes`) AS `comments_max_votes` FROM `comments` WHERE `comments`.`post_id` IN (SELECT `posts`.`id` FROM `posts` WHERE `published` = 1)
BelongsTo, BelongsToMany and other relation types
Other relations use Laravel's correlated withAggregate() subqueries — one value per base row — rolled up with SUM / MIN / MAX:
SELECT SUM(`agg_rows`.`tags_count`) AS `tags_count` FROM (SELECT 1 AS _agg_row, (SELECT COUNT(*) FROM `tags` INNER JOIN `post_tag` ... WHERE `posts`.`id` = `post_tag`.`post_id`) AS `tags_count` FROM `posts`) AS `agg_rows`
Averages are always a global SUM / COUNT across the full result set, never an average of averages.
Notes
- Aggregates reflect the full base query, not just the current page.
- Values have the same PHP types on every database:
count→int,exists→bool,sum→int|float,avg→float|null.max/minare returned as the database gives them (they may be dates or strings). - Tested on SQLite, MySQL 8.4, MariaDB 11.4 and PostgreSQL 17. SQL Server is not tested and may not work.
- When the base query returns no rows, aggregates return
0for count/sum,falsefor exists, andnullfor max/min/avg.
Testing
composer test # Pest composer analyse # PHPStan (Larastan) composer lint # Pint --test, Rector --dry-run, PHPStan composer check # lint + test
Release
Pushing a v* tag (or publishing a GitHub release with a new tag) runs the release workflow:
- the full test matrix,
- a GitHub release with the matching
CHANGELOG.mdsection as its notes, - Publish to Packagist: updates the package, or creates it if it is not on Packagist yet.
The Packagist job needs two repository secrets (Settings → Secrets and variables → Actions):
PACKAGIST_USERNAME: your Packagist username.PACKAGIST_TOKEN: your Packagist main API token (needed to create the package; updates also accept the safe token).
To publish without cutting a release (for example, the first time), run the release workflow manually from the Actions tab; only the tests and the Packagist job run.
Contributing
Issues and pull requests are welcome. Please include tests for new behavior.
License
MIT