austinw/laravel-union-paginator
Laravel Union Paginator lets you paginate the combined results of multiple Eloquent queries as a single dataset. Merge different models or filtered queries with UNION, keep consistent ordering and page sizes, and return a standard Laravel paginator for Blade or API output.
Add the package and pull in the migration guide before touching code:
composer require austinw/laravel-union-paginator
This is a major release: if you're on 2.x, read MIGRATION.md first — the paginator's where*/orderBy* semantics changed fundamentally (see Gotchas), $unionQuery/$scopes/$transformers are gone, and the empty service provider was removed.
Where to look first:
README for the fluent entry point — you build a paginator from a list of model classes, then call paginate() / simplePaginate() / cursorPaginate().MIGRATION.md for the 2.x → 3.x diff (removed public API, applyScope() replacing whereHas(), the type column rule).src/ for applyScope(), setSelectedColumns(), toBase(), when()/unless() — these are the extension points you'll actually touch.First use case — the canonical one is a mixed-activity feed (e.g. Post, Comment, Like shown in one chronological list with a single paginator and one page count). Register the models, set the shared selected columns, order by the timestamp, paginate:
$feed = UnionPaginator::make([Post::class, Comment::class, Like::class])
->setSelectedColumns(['id', 'created_at']) // do NOT include `type`
->orderByDesc('created_at')
->paginate(20);
Requires Laravel 12 or 13 and PHP 8.2–8.5 (CI covers both DB engines and the full PHP range).
Register once, paginate anywhere. The typical shape is a small factory/service that returns a configured paginator for a given feed, then controllers just call the terminal method. Keep the model list and setSelectedColumns() in one place so the type-last invariant isn't duplicated.
Choose the terminal method by cost profile:
paginate() — full COUNT over the union. Fine for small/medium unions, needed when you must show "page X of Y".simplePaginate() — no COUNT over the union. This is the default choice for large feeds; you get next/prev only.cursorPaginate() — cursors come from the raw union rows, so they keep working even when transformers rewrite rows or models are skipped. Best for infinite-scroll APIs and deep pages.applyScope() is the escape hatch. With where*/orderBy* now running on a derived table, Eloquent-only constraints (whereHas(), eager-load constraints, global-scope-style filters) can't be forwarded. Use applyScope() with a closure to push a constraint into the per-model query:
->applyScope(fn ($query) => $query->whereHas('author', fn ($q) => $q->where('active', true)))
Keep scopes idempotent and cheap — they run per model in the union.
Stable pagination is free now. type and id tie-breakers are appended to any order automatically, so orderByDesc('created_at') alone won't shuffle rows with identical timestamps across pages. You can stop hand-appending ->orderBy('id'), but be aware it changes the final SQL ordering for existing code (harmless, but explainable if a test asserts exact SQL).
Compose paginators conditionally with when() / unless() instead of if blocks — e.g. only apply a date filter when the request has one, or only order by a user-selected column when it's in an allowlist.
Drop to toBase() when you need the underlying query. Use it for debugging (dump SQL/bindings), for wrapping the union in an aggregate you don't want to paginate, or for exporting rows. It returns the base query, not a paginator — terminal methods aren't available on it.
Transformers are now closure-based only. The public $transformers array is gone. If you were mutating it, move the transformation into the mapping layer around the paginated result (or a resource collection). Keep in mind rows whose model can't be loaded are now skipped rather than returned as null, so downstream code no longer needs null-guards but does need to tolerate a shorter page than the requested per-page size.
Multi-key models work. Non-id primary keys load correctly now, but the paginator still exposes the key as id — alias/select the real key as id in setSelectedColumns() if your consumers expect that name.
The big one: where*/orderBy* apply to every model. They run on a derived table wrapping the whole union. That means a where('status', 'published') intended for posts also filters comments and likes — and if those tables lack the column, you get a SQL error. Filter per-model with applyScope(), not with paginator-level where.
Eloquent-only methods throw. whereHas() and friends on the paginator will raise an exception by design. This is the most likely 2.x regression you'll hit; grep for whereHas( on paginator chains during the upgrade.
type is always appended last. If setSelectedColumns() includes type, you'll get a duplicate column or a driver-level error. Remove it from your column lists. If you were relying on the old type placement in SELECT, re-check anything that does positional SELECT access (raw DB reads, CSV exports).
Connections must match. Models on different database connections throw. If you were unioning a tenants/reporting model on a second connection, you need to consolidate onto one connection or query them separately and merge in PHP.
Every configured model must be registered. Unregistered model types now throw in every configuration method, not just at paginate time — a typo in a class-string fails fast, which is good, but lazy ::class construction inside loops can blow up earlier than before.
Key column is assumed to be id for the tie-breaker and the selected key. Models with non-id keys load fine, but the paginator's own id/type tie-breaker logic still assumes the convention — alias as id in setSelectedColumns().
Timestamp-less models are safe. CREATED_AT = null models work, including on Postgres — previously this broke on Postgres due to the timestamp cast/ordering. If you unioned audit/log models without timestamps, this is a reason to upgrade.
type escaping is a bound parameter now. This fixes MySQL with NO_BACKSLASH_ESCAPES and removes driver-specific quoting. Related: don't build raw SQL against the union expecting the old string-literal type; use toBase() bindings.
Debugging tips:
toBase()->toSql() and getBindings() to see exactly how your scopes and paginator-level where/order composed.orderBy columns that contain NULLs and duplicates; the type/id tie-breakers make it stable, but ordering direction must be consistent.Extension points to prefer: applyScope() (per-model constraints), setSelectedColumns() (shape of the union), toBase() (raw query access), when()/unless() (conditional config). Everything else that was public in 2.x ($unionQuery, $scopes, $transformers, prepareUnionQuery(), hasScope(), getScopesFor()) is removed — if you subclassed the paginator to reach into these, that's a rewrite, not a rename.
How can I help you explore Laravel packages today?