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.
Architecture fit. The package solves one specific problem: presenting heterogeneous Eloquent models (e.g. Posts, Videos, Photos) as a single, consistently-ordered, paginated feed. v3.0.0 re-architects the core around a derived table over the union — where*/orderBy* are forwarded and applied once, over the union, at paginate time, in any call order. That is a real correctness improvement: per-model filter/order application was the class of bug where a filter silently applied to some models and not others. The new type/id tie-breakers give deterministic page boundaries, which is the minimum bar for any feed surface.
Fit is good where you have 3+ tables that must share one paginator, one search, one sort. Fit is poor where you have a single table (use native pagination), or where the union spans different database connections — that now throws explicitly, which rules out read/write-split and sharded topologies for the unioned models.
Integration feasibility. Small API surface, and v3 is a call-site-shaped migration rather than a data migration. The work is an inventory exercise: every use of the removed members ($unionQuery, $scopes, $transformers, prepareUnionQuery(), hasScope(), getScopesFor()), every Eloquent-only filter (whereHas() and friends) that must convert to applyScope(), every setSelectedColumns() call that must stop including type, and every model that must now be explicitly registered. A published MIGRATION.md is a meaningful de-risking signal — it suggests the maintainer expects and supports upgrades rather than treating majors as disposable.
Technical risk.
null. Better ergonomics, but COUNT over the union will no longer equal rendered rows. Pagination metadata becomes advisory — total, page counts, and "page N of M" UI must not be trusted for analytics or for loop-termination logic.Key questions.
id primary keys, and are those keys unique across types? The type/id tie-breaker only guarantees stable pages if the pair is unique.CREATED_AT = null or non-standard timestamp columns? (Fixed here, but verify on our Postgres version.)cursorPaginate()?total or page counts?Stack fit. Laravel 12+, PHP 8.2+, single connection per union, MySQL 8.4 or Postgres 17. All three tested drivers are now in CI, so the class of driver-specific escaping regression fixed here (MySQL NO_BACKSLASH_ESCAPES) is now covered rather than discovered. Anything outside that matrix — MariaDB, MySQL 5.7, SQL Server — should be treated as unsupported regardless of what Composer resolves.
Migration path.
whereHas, whereRelation) to applyScope().type from every setSelectedColumns() argument and confirm every unioned model is registered.id keys, null-timestamp models, skipped-model behaviour, and ordering stability across pages.Compatibility. The realistic blocker is Composer, not code: a package pinning illuminate/* to 12/13 will conflict with anything else in the tree still on 11. Resolve the framework upgrade in isolation first so the dependency graph failure is unambiguous.
Sequencing. Laravel 12 upgrade → package upgrade on branch → canary on a low-traffic feed with real cardinality → production. Adopt simplePaginate() by default for anything user-facing and reserve offset pagination for admin surfaces where page counts are actually displayed.
Maintenance. Net simpler internally — the derived-table design consolidates ordering and filtering into one place, and the empty service provider is gone. But any in-house wrapper built on the removed public members needs rework, and the project's cadence says to expect another breaking major. Budget for it as recurring cost, not a one-off.
Support. No vendor; GitHub issues and a single maintainer. Mitigate by keeping the upgrade contained behind your own thin adapter so a fork is a contained change rather than a hunt through call sites. The codebase's enforced PHPStan 8 and multi-driver CI make a fork genuinely tractable.
Scaling. simplePaginate() removes the COUNT over the union — that is the single biggest lever, since the count forces a full union evaluation on every request. cursorPaginate() is the right answer for deep feeds and infinite scroll: keyset pagination sourced from raw union rows avoids the O(offset) scan inherent to a derived table. Offset pagination should be reserved for shallow or internal surfaces.
Failure modes to instrument.
whereHas() call — BadMethodCallException under load. Cover every filter path in tests; the throw is only useful if it fires in CI.type included in selected columns — ambiguity or SQL error. Enforce with a test on every paginator construction.id across types — silently unstable pages. Verify composite uniqueness rather than assuming it.type only; hand-written SQL in a transformer still carries its own escaping risk.Ramp-up. Low for a Laravel-fluent engineer: the API is a day's reading plus MIGRATION.md. End-to-end, budget one to two sprints including the Laravel upgrade and test hardening. Two durable artifacts worth writing on day one: a documented convention that applyScope() is the sanctioned escape hatch for anything Eloquent-only, and a lint or test rule that fails on whereHas() called against a paginator instance.
How can I help you explore Laravel packages today?