Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Union Paginator Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. README for the fluent entry point — you build a paginator from a list of model classes, then call paginate() / simplePaginate() / cursorPaginate().
  2. MIGRATION.md for the 2.x → 3.x diff (removed public API, applyScope() replacing whereHas(), the type column rule).
  3. 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).

Implementation Patterns

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.

Gotchas and Tips

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:

  • Dump toBase()->toSql() and getBindings() to see exactly how your scopes and paginator-level where/order composed.
  • If a page returns fewer rows than expected, it's usually skipped models (deleted/soft-deleted rows whose model can't be resolved). This is expected — don't compensate by looping.
  • For cursor pagination, test with orderBy columns that contain NULLs and duplicates; the type/id tie-breakers make it stable, but ordering direction must be consistent.
  • Run the test suite against MySQL/Postgres locally if you support both — CI does, but the derived-table behavior differs subtly from SQLite.

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels