austinw/laravel-union-paginator
| Install | |
|---|---|
composer require austinw/laravel-union-paginator |
|
| Latest Version: | v3.0.0 |
| PHP: | ^8.2 |
| Laravel: | 12 · 13 |
| Older releases: | 10 · 11 |
| License: | MIT |
| Last Updated: | Oct 22, 2026 |
| Links: | GitHub · Packagist |
Laravel Union Paginator
Paginate several Eloquent models as one list — an activity feed of posts, comments and users, for example — with a single SQL UNION, and get the models back in order.
use AustinW\UnionPaginator\UnionPaginator;
$feed = UnionPaginator::forModels([Post::class, Comment::class])
->latest()
->paginate(20);
foreach ($feed as $item) {
// $item is a Post or a Comment model, newest first.
}
Installation
composer require austinw/laravel-union-paginator
Requires PHP 8.2+ and Laravel 12 or 13. Upgrading from 2.x? See the migration guide.
How it works
- Each model becomes one
SELECTof four columns:id(the primary key),created_at,updated_atandtype(the model's class name). Global scopes and soft deletes apply. - The selects are combined with
UNIONand wrapped in a derived table,union_paginator. - Your
whereandorderBycalls run on that derived table, so they apply to every model. - The page of rows is turned back into models with one
findMany()per model type.
Filtering and ordering the whole union
Query builder where* and orderBy* methods, latest(), oldest(), reorder() and inRandomOrder() are forwarded to the union. They can only see the union's columns (id, created_at, updated_at, type, plus any you select).
UnionPaginator::forModels([Post::class, Comment::class])
->where('created_at', '>=', now()->subWeek())
->whereIn('type', [Post::class, Comment::class])
->orderByDesc('created_at')
->paginate();
When you order, type and id are appended as tie-breakers so pages stay stable when sort values are equal. Without an order, the database decides the row order — add one if the order matters.
Calls can come in any order: the query is built when you paginate. when() and unless() receive the paginator:
$paginator->when($onlyPosts, fn ($paginator) => $paginator->where('type', Post::class));
where(fn ($query) => ...) closures receive a plain query builder for the derived table, not an Eloquent builder.
Constraining one model
Use applyScope() for conditions on a single model, including relationship queries such as whereHas():
$paginator->applyScope(Post::class, fn ($query) => $query->where('published', true))
->applyScope(Comment::class, fn ($query) => $query->whereHas('post'));
The callback receives that model's Eloquent builder. Call applyScope() more than once to add more conditions.
Pagination styles
$paginator->paginate(15); // LengthAwarePaginator, with a total
$paginator->simplePaginate(15); // next/previous only, skips the COUNT over the union
$paginator->cursorPaginate(15); // cursor pagination for large or infinite feeds
Cursor pagination needs column orders on non-null columns. type and id are appended automatically, so ->latest()->cursorPaginate() works as long as created_at is never null. Raw (orderByRaw), random and expression orders throw, because Laravel cannot build a cursor from them.
Loading models
By default each page runs one findMany() per model type. To eager load or customise the query, use fetchModelsUsing():
$paginator->fetchModelsUsing(Post::class, fn (array $ids) => Post::with('author')->findMany($ids));
The callback receives the keys on the current page and returns the models. Rows whose model is not returned (for example, deleted between the two queries) are left out of the page.
Raw rows
Skip model loading and get the union rows (stdClass) instead:
$paginator->preventModelRetrieval()->paginate();
Transforming results
$paginator->transformResultsFor(Post::class, fn (Post $post) => [
'title' => $post->title,
'date' => $post->created_at->toDateString(),
]);
The callback receives the model, or the raw row when model retrieval is prevented. A later call for the same model replaces the earlier one.
Custom columns
Replace a model's default select with setSelectedColumns(). Columns in a UNION are matched by position, so every model must select the same number of columns in the same order. Alias the key as id. The paginator always adds type as the last column.
$paginator
->setSelectedColumns(Post::class, ['posts.id as id', 'posts.published_at as created_at', 'posts.title as title'])
->setSelectedColumns(Comment::class, ['comments.id as id', 'comments.created_at', 'comments.body as title']);
Models without timestamp columns should set const CREATED_AT = null; and const UPDATED_AT = null; — the paginator then selects NULL for them.
Limitations
- All models must use the same database connection.
- Primary keys must have compatible types across models (Postgres will not union an integer with a UUID). Cast them with
setSelectedColumns()if needed.
Testing
composer test
composer analyse
CI runs the suite on PHP 8.2–8.5 with Laravel 12 and 13 on SQLite, plus MySQL 8.4 and Postgres 17.
License
MIT. See LICENSE.md.
Related Packages
Combines data from multiple models into a single unified query using SQL unions,...
Version History
| Version | Released | PHP | Laravel | License |
|---|---|---|---|---|
| v3.0.0 | ^8.2 | ^12.0| | MIT | |
| v2.3.0 | ^8.2 | ^10.0| | MIT | |
| v2.2.4 | ^8.2 | ^10.0| | MIT | |
| v2.2.3 | ^8.2 | ^10.0| | MIT | |
| v2.2.2 | ^8.2 | ^10.0| | MIT | |
| v2.2.1 | ^8.2 | ^10.0| | MIT | |
| v2.2.0 | ^8.2 | ^10.0| | MIT | |
| v2.1.1 | ^8.2 | ^10.0| | MIT | |
| v2.1.0 | ^8.2 | ^10.0| | MIT | |
| v2.0.0 | ^8.2 | ^10.0| | MIT | |
| 1.0.1 | ^8.2 | ^10.0| | MIT | |
| 1.0.0 | ^8.2 | ^10.0| | MIT |