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.
simplePaginate() (no COUNT over the union) and cursorPaginate() (cursors from raw union rows, compatible with transformers and skipped models) let you choose between accurate page counts and feed latency — a user-facing decision about "Page 1 of N" vs. infinite scroll.type/id tie-breakers guarantee stable pages (no duplicate or missing rows between page loads), and skipped/unloadable models are dropped rather than surfacing as null — a decision about whether feeds can tolerate dangling references.Adopt when:
paginate(), simplePaginate(), or cursorPaginate() semantics rather than hand-rolled UNION queries plus manual LengthAwarePaginator assembly.applyScope() rather than Eloquent-only constraints. whereHas(), eager-loading-style constraints, and similar Eloquent methods are not forwarded and will throw; only query-builder where*/orderBy* methods are.type/id tie-breakers) or cursor pagination that survives transformers and skipped rows — historically the hard part of union pagination.id, setSelectedColumns() must not include type (appended last), and models with non-id keys or CREATED_AT = null now load correctly.Look elsewhere when:
MIGRATION.md; removed service provider, public $unionQuery/$scopes/$transformers, prepareUnionQuery(), hasScope(), getScopesFor()).whereHas, withCount constraints) inside the union — this is an explicit non-goal; you must refactor to applyScope().where*/orderBy* to every model at paginate time in any order, which is a deliberate simplification you must accept.simplePaginate() deliberately skips COUNT, and paginate()'s COUNT over a derived table can be costly.For executives: This release turns a fragile, in-house pattern into a maintained, production-grade capability. It lets us ship cross-model feeds — activity streams, unified search, moderation queues — as a single paginated list, without each team rebuilding union logic. The v3 release adds cursor pagination and guaranteed stable page ordering, which directly reduces "missing/duplicated row" bugs users notice, and it is CI-tested against three databases and four PHP versions, lowering our operational risk. The cost is a breaking upgrade tied to Laravel 12/13, so it should be sequenced with our framework upgrade rather than treated as an isolated dependency bump.
For engineering: v3.0.0 is a rewrite of the filtering model. where*/orderBy* on the paginator now run against a derived table over the union and apply to every model at paginate time; Eloquent-only methods like whereHas() now throw — port those to applyScope(). The key is fixed to id, type must not appear in setSelectedColumns() (it is appended last), unregistered model types and cross-connection models throw, and unloadable rows are skipped instead of returned as null. Public $unionQuery/$scopes/$transformers, prepareUnionQuery(), hasScope(), and getScopesFor() are gone — audit for subclassing or reflection before upgrading. In exchange we get simplePaginate() and cursorPaginate() (cursor values derived from raw union rows, so transformers and skipped models behave), type/id tie-breakers for stable pages, toBase(), when()/unless(), correct handling of non-id keys and timestamp-less models (including Postgres), and type as a bound parameter (fixes MySQL NO_BACKSLASH_ESCAPES). Quality bar: PHPStan level 8, CI on SQLite/MySQL 8.4/Postgres 17, PHP 8.2–8.5.
Recommendation: Adopt on Laravel 12/13 projects with a cross-model feed requirement; budget the v2→v3 migration (MIGRATION.md) as real work, primarily around scope refactors and removal of public extension points.
How can I help you explore Laravel packages today?