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 Merged Relations Laravel Package

staudenmeir/laravel-merged-relations

Merge multiple Laravel Eloquent relations into a single relation that can be queried and eager loaded as one. Combine hasMany, belongsToMany, and morph relations into a unified result set, simplifying complex relationship handling in your models.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and scope the spike (under an hour).

composer require staudenmeir/laravel-merged-relations

There is no service provider, no config file to publish, and no migration. The entire integration surface is a trait on a model plus one method.

Read these two things, in this order:

  1. The package README — it is short, example-driven, and is the source of truth for the mergedRelation() factory signature. Copy the call verbatim into your first relation rather than transcribing it from a blog post; the argument shape is easy to get subtly wrong and every downstream error looks like a SQL error.
  2. vendor/staudenmeir/laravel-merged-relations/src — the trait and the single MergedRelation class. Reading MergedRelation tells you exactly what it delegates to Eloquent (get(), getEager(), addConstraints, existence-query construction) and therefore which features are genuinely supported versus which only appear to work.

If the README leaves an edge case ambiguous, the package's own test suite is better documentation than any tutorial — it covers eager loading, counts, and existence queries explicitly.

The one constraint that determines whether you can use it at all: every constituent relation must resolve to the same related model. You are merging multiple paths to one entity type, not multiple entity types.

First use case — the one to spike first. A User who has posts directly and also posts via teams they belong to:

use Staudenmeir\MergedRelations\HasMergedRelationships;

class User extends Model
{
    use HasMergedRelationships;

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    public function teamPosts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, Team::class);
    }

    // Read-only union of both sources, queryable like any relation.
    public function allPosts(): MergedRelation
    {
        return $this->mergedRelation(Post::class, 'posts', 'teamPosts');
    }
}

Then confirm you actually got a relation, not just an array:

$user->allPosts()->where('published', true)->orderByDesc('created_at')->paginate(20);
$user->load('allPosts.comments');
$user->loadCount('allPosts');

If ->toSql() returns a single statement with a union in it, you have integrated correctly. If it returns something you don't recognise, stop and read the generated SQL before writing another line — this is the whole debugging technique for this package.

Implementation Patterns

Name every merged relation allX(). The all prefix is a contract with the next developer: this is a read-only aggregate across sources, not a table-backed relationship. It also stops someone assuming create() works (it does not — see Gotchas).

Put all filtering in the constituent relations, not the merged one. The merged relation should be a thin union. Scopes, soft-delete handling, and tenant constraints belong on posts() and teamPosts(), where they are individually testable and reusable. A where chained onto allPosts() is fine for call-site filtering, but conditions that define what a post is should live upstream — otherwise the merged and unmerged access paths drift and you get plausible-but-divergent result sets.

Wrap it in a query object or repository method. This is the guardrail that survives framework upgrades and maintainer decisions:

final class UserFeedQuery
{
    public function for(User $user): LengthAwarePaginator
    {
        return $user->allPosts()
            ->where('published', true)
            ->with(['author', 'comments'])
            ->orderByDesc('created_at')
            ->paginate(25);
    }
}

Product code calls UserFeedQuery, not allPosts(). The package becomes an implementation detail of the read layer, swappable in an afternoon.

Lean on Eloquent parity rather than re-implementing it. The value proposition is that existing patterns keep working unchanged:

Post::whereHas('user.allPosts', fn ($q) => $q->where('published', true))->get();
User::withCount('allPosts')->having('all_posts_count', '>', 10)->get();
User::with(['allPosts' => fn ($q) => $q->latest()->limit(5)])->get();

If a query pattern you rely on does not survive the merge, that is a signal to keep the constituent relation — do not build a workaround layer on top.

Nest deliberately, not by default. Merged relations can contain merged relations. One level is usually a readable feed; three levels produces SQL nobody on the team can reason about or optimise. Treat depth as a review item.

Keep the constituent relations independently usable. $user->posts() and $user->teamPosts() should still be correct and used where a single source is intended. If every call site migrates to allPosts(), the merge is hiding a query that should have been a join or a view.

Standard test recipe — write these before rollout, one per merged relation:

it('merges both sources', fn () => /* assert a post from each source appears */);
it('does not leak cross-source rows', fn () => /* user A never sees user B's team posts */);
it('orders deterministically across sources', fn () => /* created_at ordering holds */);
it('paginates without dropping or duplicating rows', fn () => /* page 1 + page 2 == full set, no overlap */);
it('eager loads identically to lazy loading', fn () => /* ->with() result equals ->load() result */);
it('counts match the merged set', fn () => /* withCount equals ->count() */);

The cross-source leakage test is the one that catches the classic union bug inherited from hand-rolled code: a missing tenant constraint on the second branch.

Gotchas and Tips

It merges paths, not entity types. A "unified activity feed" of posts and comments and uploads is not what this does — those are different models. This merges posts() and teamPosts() into one Post relation. If your requirement is genuinely heterogeneous, you need a polymorphic table, a search index, or PHP-side merging; the package will not rescue that design, and trying to force it will waste a sprint.

Read-only, by construction. create(), save(), attach(), associate(), and delete() on a merged relation are meaningless — the relation cannot know which source is the target. Writes go through the constituent relations. Consider making this explicit in your query object's API so nobody tries.

Deterministic ordering needs a tiebreaker that works across tables. orderByDesc('created_at') alone gives unstable pagination when timestamps collide, and id is not a valid global tiebreaker because IDs overlap between sources. Pick a genuinely monotonic column across both branches, or accept that a few rows may shuffle between pages and document it.

Duplicates are possible and are your responsibility. If one post is reachable through two sources (a user's own post and their team's post), the union may return it twice. Test for it. distinct() fixes the result set but changes the count query and can be expensive — decide explicitly rather than discovering it in production.

ORDER BY … LIMIT over a UNION is not free. MySQL and Postgres materialise unions into a temp result before applying the outer sort and limit, which means the indexes on your sort column in each branch do the work, and the outer sort may still filesort. Capture EXPLAIN on production-sized data for every merged relation. Keep each branch narrow (few columns, tight where), index the foreign keys of every branch, and prefer simplePaginate() over paginate() for deep feeds — the count query wraps the whole union in a subquery and is often the most expensive statement on the page.

Global scopes and soft deletes apply to the related model once, not per branch. Per-branch conditions (published, tenant_id, deleted_at variants) must live inside the constituent relations. This is the single most common source of "merged relation returns more rows than the two relations combined."

Eager loading with different keys per branch is worth verifying explicitly. Branches that join on different foreign columns are handled, but the matching step is where subtle mis-grouping appears. Run ->with('allPosts') and ->load('allPosts') in a test and assert the collections are identical — not just the same size.

A limit() on a constituent relation is not a portable sub-limit. If you need "the 5 most recent from each source," that is not a union of two limited queries in most databases. Model it as two separate queries merged in PHP, or accept a single limited union.

Pin the minor version and put this on the framework-upgrade checklist. The package reaches into Relation internals and existence-query construction, none of which is covered by Laravel's public API contract. Every Laravel major is a required compatibility release on the package side (v1.11 was a Laravel 13 support bump with no API change — the healthiest possible shape, but still a hard dependency on the upgrade critical path). Treat it as blast-radius code, not inert vendor code, and run the per-relation tests on the upgrade branch.

Debugging is a SQL problem, not a package problem. $user->allPosts()->toSql() and ->getBindings() are your primary tools; DB::listen() while hitting a page catches N+1s faster than the debug bar because it shows the expanded union. If a merged relation misbehaves, split it: run each constituent relation individually, confirm both are correct in isolation, then diff against the merged output. Almost every bug is either a constraint that should have been upstream, or a duplicate that should have been deduplicated.

Single-maintainer dependency. ~185 stars, one maintainer, thin institutional knowledge. That is acceptable for a read-layer utility, but only if you keep it behind a named query object and write the tests. Also write down why each merged relation exists and what it replaced — otherwise a future maintainer cannot tell whether deleting it is a refactor or a regression.

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.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package