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 Many To Many Self Relationship Laravel Package

kingmaker/laravel-many-to-many-self-relationship

A Laravel Eloquent package for many-to-many self-referential relationships like followers/following, friends, or related models. It provides a reusable trait for managing pivot records, inverse relations, and attach/detach-style operations cleanly.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package and confirm how it registers in your app:

composer require kingmaker/laravel-many-to-many-self-relationship

There is typically no service provider publishing needed for small relationship packages, but check the README and vendor/kingmaker/laravel-many-to-many-self-relationship/src first — the trait namespace and the relation factory method name are the two things you need to verify before writing any model code. Look at the package's own tests for the canonical usage example; they are usually more up to date than the README.

Step 1 — create the pivot table. A symmetric relation needs exactly one row per pair, not two:

Schema::create('friends', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('friend_id')->constrained('users')->cascadeOnDelete();
    $table->timestamps();
    $table->unique(['user_id', 'friend_id']); // prevents duplicate pairs
});

Step 2 — add the trait to the model:

use Kingmaker\...\ManyToManySelf; // verify exact namespace in vendor/

class User extends Authenticatable
{
    use ManyToManySelf;

    public function friends()
    {
        return $this->manyToManySelf(User::class, 'friends');
    }
}

Step 3 — first use case. The classic one is a friendship/connection graph: $alice->friends()->attach($bob) and then $bob->friends already contains $alice without a second insert or a reverse relation method. If you only need a directional relation (followers, likes, referrals), you do not need this package — use plain belongsToMany with two relation methods.

A second strong first use case is sibling entities in the same table: related categories, cross-sell products, "works with" items — anywhere you'd otherwise write belongsToMany(SameModel::class, ...) twice and union() the results in every query.

Implementation Patterns

Reciprocal attach/detach at the write layer. Keep all writes inside a small service or action class, because the semantics of "attach" are the whole point of the package:

class ConnectUsers
{
    public function handle(User $a, User $b): void
    {
        abort_if($a->is($b), 422, 'Cannot connect a user to themselves');

        $a->friends()->attach($b);   // package writes/looks up the single pivot row
    }
}

Reads then work symmetrically from either side: $b->friends()->where('users.id', $a->id)->exists() is true.

Eager loading both directions. Because the association is two-way, one relation method covers both user_id and friend_id lookups — the package resolves that in SQL for you. Eager load with User::with('friends') and, where useful, constrain the pivot:

$user->friends()
     ->withPivot('created_at')
     ->wherePivot('created_at', '>', now()->subDays(30))
     ->get();

Combining with scopes. Add business scopes on the related model (scopeActive, scopeVerified) and chain them through the relation; the query builder is a normal Eloquent builder underneath, so standard Eloquent constraints apply. Avoid putting union() or raw SQL in the relation itself — keep that inside dedicated query classes.

Integration with policies and request validation. Since the relation is symmetric, a policy check like $user->can('view', $profile) can lean on $user->friends()->whereKey($profile->user_id)->exists() instead of checking two direction columns. Mirror this in FormRequest rules using Rule::exists.

Blade / API resources. Treat it like any other BelongsToMany — $user->friends->pluck('name'), FriendResource::collection(...). No special serialization layer is needed.

When to wrap, when not to. If the model has only one self-relation, define it directly on the model. If you have multiple (friends, colleagues, blocked users), keep each in its own relation method and put shared invariants (no duplicates, no self-links, cascade cleanup) in a trait on your side rather than duplicating logic.

Gotchas and Tips

The relation must be symmetric by definition. Users often reach for this package for "followers" or "likes" and get burned: those are directional. Asymmetric relations need a single belongsToMany with two methods; symmetric ones need a single row and the package's logic. Decide this up front — retrofitting is painful.

Duplicate pivots and SQLSTATE[23000]. Without a unique index, calling attach() twice creates duplicate pairs and your friend list shows the same person twice. Add the unique(['user_id', 'friend_id']) early, and normalize pair ordering in your service so (1, 2) and (2, 1) can never coexist.

Self-relations slip through. Nothing stops $user->friends()->attach($user). Guard in the action layer and consider a check constraint or where('user_id', '!=', DB::raw('friend_id')) in the relation if your database supports it.

Cascade deletes. Foreign keys on both user_id and friend_id must cascade, otherwise deleting a user leaves orphaned pivot rows and the relation silently returns stale IDs. Test a delete in a feature test with RefreshDatabase.

withPivot must be declared explicitly. If you store attributes like status, accepted_at, or created_at on the pivot, they won't appear on related models unless you add ->withPivot(...) and ->withTimestamps(). This is the most common "why is my API response missing data" bug with any belongsToMany.

sync() semantics. sync() on a symmetric relation does what you expect only if your app consistently inserts pairs with a canonical order. If you allow both (1,2) and (2,1), sync() will treat them as distinct rows and detach the wrong ones. Pick a canonical ordering convention (e.g., always min(id) as user_id) and enforce it.

Debugging queries. The package builds a query combining both columns; when results look wrong, dump the SQL with ->toSql() or DB::listen() rather than guessing. Also verify the package's assumption about the pivot column names — if you deviate from user_id/friend_id, re-read the source, not just the README.

Extension points. Most small packages of this shape expose table name, foreign pivot key, and related key as arguments (and possibly a config file for the model class). Check config/ in the vendor directory and the constructor signature of the relation class. If your app uses UUIDs or non-incrementing keys, verify the package has no int-only assumptions in its SQL.

Testing. Use SQLite in-memory with RefreshDatabase; composite unique indexes work there, but be careful with SQLite's limited ALTER TABLE support if you add columns later. Add one test asserting reciprocal visibility from both sides — that's the invariant worth protecting against package upgrades.

Version pinning. For an 18-star package, pin a version in composer.json and read the diff before upgrading; there is no large ecosystem to catch regressions. If the package becomes unmaintained, the migration path back to two belongsToMany methods plus a union() scope is small — keep your service layer thin so that swap stays cheap.

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.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core