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 Has Many Merged Laravel Package

korridor/laravel-has-many-merged

Adds a hasManyMerged relationship to Laravel Eloquent, letting you combine several hasMany relations across models into one query-backed collection with sorting, limits, and eager loading. Ideal for unified feeds, timelines, and activity lists.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up (5 minutes, no config).

composer require korridor/laravel-has-many-merged

There is no service provider, no config file, no migration, no publish step. The entire package is a trait plus a relation class. Pull the trait into the model that owns the relation and declare the relation like any native one:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Korridor\LaravelHasManyMerged\HasManyMerged;

class User extends Model
{
    use HasManyMerged;

    public function posts()
    {
        return $this->hasManyMerged(Post::class, ['author_id', 'editor_id']);
    }
}

The second argument is the array of foreign keys on the related table that all point back at this parent. That is the whole idea: one relation name, N foreign-key columns.

First use case to try. The canonical one is "rows this user is attached to in more than one capacity" — authored or edited posts, assigned or watched tickets, owned or shared documents. In tinker:

$user = User::with('posts')->first();
$user->posts;                                  // merged collection
$user->posts()->where('status', 'published')->count();
User::withCount('posts')->first()->posts_count;

// Prove it's a real relation, not a collection helper:
$user->posts()->toSql();

Where to look first.

  1. vendor/korridor/laravel-has-many-merged/src/ — read the relation class end to end. It is small, and knowing exactly what query shape it builds is the difference between using it confidently and guessing.
  2. The README examples for with(), whereHas(), withCount(), withSum() — these are the four integration points that matter in day-to-day work.
  3. The changelog, for the versions you care about: aggregates + whereHas were fixed in 0.0.3, PHPStan generics landed in 1.0.1, Laravel 12 support in 1.2.0. Nothing has been removed since, so the API you read about in any release note is current.

Do not go looking for a config file or an extension registry. There isn't one, and that is a feature: your removal path is deleting one method and one use statement.

Implementation Patterns

Treat the relation method as the single source of truth

Do not scatter ->where() and ->orderBy() across controllers. Bake the default shape into one place and let callers add constraints on top:

public function posts()
{
    return $this->hasManyMerged(Post::class, ['author_id', 'editor_id'])
        ->where('posts.status', '!=', 'draft')
        ->orderByDesc('posts.published_at');
}

// Callers just constrain further:
$user->posts()->where('posts.type', 'tutorial')->take(20)->get();

This is the pattern that actually removes duplication — the thing you're replacing (collect() + merge() + sortBy() copy-pasted in three controllers) becomes one method.

Unified timeline / activity feed

The highest-value day-to-day pattern: one relation that flattens "things linked to this account" into a single ordered list for an API resource.

// app/Http/Resources/CustomerTimelineResource.php
return [
    'id'    => $this->id,
    'type'  => class_basename($this),
    'title' => $this->title,
    'at'    => $this->occurred_at?->toIso8601String(),
];
$customer = Customer::with(['events' => fn ($q) => $q->take(50)])->findOrFail($id);
return CustomerTimelineResource::collection($customer->events);

Because eager loading works, a list of 25 customers each with a merged timeline is one extra query, not 25.

Eager loading, aggregates, and whereHas — the four you'll use daily

// Eager load with a constraint (one query, not N)
User::with(['posts' => fn ($q) => $q->latest()->limit(5)])->paginate(25);

// Aggregates
User::withCount('posts')->get();
User::withSum('posts', 'views')->get();

// Filter parents by the merged set
User::whereHas('posts', fn ($q) => $q->where('posts.type', 'tutorial'))->get();

// Inverse-style checks without loading
if ($user->posts()->exists()) { /* ... */ }

whereHas is the one people forget. It lets you drive a whole listing screen ("customers with at least one published post") without ever loading the relation.

Constraint composition via related-model scopes

Keep merge logic in the model and filtering in scopes on the related model, so both the merged relation and any direct Post::query() share behaviour:

// Post model
public function scopeVisibleTo(Builder $q, User $user): Builder
{
    return $q->where(/* ... */);
}

// User model
public function visiblePosts()
{
    return $this->hasManyMerged(Post::class, ['author_id', 'editor_id'])
        ->visibleTo($this);
}

Ordering across heterogeneous columns

If the merged rows don't share a sortable column (e.g. published_at on one path, updated_at on another), normalise at query time rather than in PHP:

return $this->hasManyMerged(Item::class, ['owner_id', 'reviewer_id'])
    ->orderByRaw('COALESCE(items.published_at, items.updated_at) DESC');

Sorting in the database is the reason to use this package instead of collect()->merge()->sortBy(), so never fall back to a collection sort on a merged set you just paged.

Pagination and big sets

// Per-parent pagination: works, because the relation is scoped to the parent
$user->posts()->paginate(15);

But note what this is not: it is not a global feed across all parents. If the screen needs "all merged rows for everyone", that's a top-level query (a UNION view or a shared table), not this relation. Enforce a rule in review: any merged set that could plausibly exceed ~10k rows must arrive with a DB-side where, a limit, or a whereHas on the parent query — never ->get() on an unconstrained merged relation.

Testing pattern

One test per relation, asserting both the shape and the count:

it('merges authored and edited posts without duplicates', function () {
    $user = User::factory()->create();
    Post::factory()->for($user, 'author')->create();
    Post::factory()->for($user, 'editor')->create();

    expect($user->posts)->toHaveCount(2);
    expect($user->posts()->count())->toBe(2);
});

Also add a query-count assertion around the eager-loaded listing (DB::enableQueryLog() / assertQueryCount) — the value proposition of the package is partly that it doesn't regress your N+1 posture, and that's easy to lose when someone swaps with('posts') for $user->posts in a Blade loop.

Keep the exit ramp shallow

Wrap the relation behind a name that isn't the package's:

public function activityItems()  // not "posts"
{
    return $this->hasManyMerged(...);
}

If the package is ever abandoned or you hit a limitation, you replace the body of one method with a union query or a hasMany over a view. Do not let the package-specific call leak into controllers, resources, or services.

Gotchas and Tips

Scope is hasMany only. Not belongsToMany, not hasOne, not hasManyThrough chains, not deeply nested relations. The hard constraint is worth writing into your conventions doc — otherwise the first engineer who assumes it merges arbitrary relations files it as a bug.

Verify your duplicate semantics before you trust a count. If a related row can satisfy more than one of your foreign keys (same post, user is both author and editor), confirm with a test whether you get one row or two. Whatever the answer, put a number in a UI only after the test exists — this is exactly the class of bug that made the hand-rolled merges inconsistent in the first place.

withCount counts rows, not matches. A merged count is "distinct-ish related rows", not "author count + editor count". If a product owner expects the sum of two independent counts, the merged relation will produce a smaller number and you'll be explaining it in a bug report.

Order is not free. An unconstrained merged relation has no guaranteed ordering. Always orderBy in the relation method or at the call site, and remember ordering by an unqualified column is ambiguous once you add joins — qualify it (posts.created_at).

Write paths are ambiguous. $user->posts()->create([...]) has to pick one of several foreign keys, and association helpers (save, associate) assume a single FK. Don't route writes through the merged relation — use the concrete relation ($user->authoredPosts()->create(...)) or Post::create([...]) with an explicit FK. Merged relations are read-side.

Index every foreign key column. The relation resolves to a query that constrains the parent across multiple FK columns. Without individual indexes on each, larger parent sets degrade fast. Add the indexes at the same time you add the relation, not after the first slow-page report.

Eager loading scales with parent count, not row count. User::with('posts')->paginate(100) produces one query with a large whereIn over 100 keys across N columns. That's fine, but it is not a constant-cost operation — measure it when the parent page size grows.

Soft deletes and global scopes apply. The relation runs through the related model's normal query builder, so SoftDeletes, global scopes, and $with all behave as you'd expect. If rows are "missing", check the related model's global scopes before you blame the package.

Static analysis. Larastan/PHPStan generics were added in 1.0.1. If you skip the docblock you'll get Model|Collection mush in your IDE and in CI. Add the generic return annotation from the README. Note the package ships a trait and a relation class with closely related names — if importing both clashes, drop the native return type on the relation method and carry it in the docblock instead.

Debugging is a one-liner. $user->posts()->toSql() plus DB::listen(fn ($q) => logger($q->sql)) tells you everything. Do this during the spike, before the relation reaches a latency-sensitive endpoint. There are no published benchmarks for this package, so your own measurement on production-shaped data is the only benchmark that counts.

Supply chain: treat it as vendored. ~90 stars, near-zero public dependents, a single primary maintainer. That's fine for a small, MIT, shallow dependency — but pin the exact version, name a fork owner, and tie the "is upstream alive?" review to your next Laravel upgrade ticket rather than a calendar reminder. Exit criterion: no matching major release within ~60 days of your upgrade → fork or inline. The code is small enough that forking is a realistic afternoon, not a crisis.

Extension point is the relation class, not a config. If you need behaviour the package doesn't offer, subclass the relation class and return your subclass from your trait's wrapper method — or write the union query yourself. There is no macro/hook surface; don't go hunting for one.

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.
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table