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

Custom Relation Laravel Package

culturegr/custom-relation

Easily define custom relationships between Eloquent models, like connecting users to permissions through roles. Includes an Artisan generator and full support for eager-loading, making complex multi-table joins clean and reusable.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm compatibility first. culturegr/custom-relation is a single Composer dependency with no config file, no migrations, and no runtime services.

composer require culturegr/custom-relation

Service-provider auto-discovery handles Laravel 5.5+; only add CultureGr\CustomRelation\CustomRelationServiceProvider::class to config/app.php if you have discovery disabled. Before writing any code, check composer.json's Laravel/PHP constraints against your app — the release history shows rolling drops of majors (PHP 7 and Laravel 7/8 were removed in the v1.1–v1.2 line), so an older LTS may already be unsupported. Pin explicitly ("culturegr/custom-relation": "1.5.*") and commit the lockfile. The NOASSERTION license is a blocker for commercial redistribution — clear it with legal before it lands on main, not after.

Where to look first. Three things only:

  1. app/Eloquent/CustomRelations/ — the generator's output directory, and the only place relationship logic should live.
  2. The parent model — the HasCustomRelation trait plus one method per relation.
  3. The three lifecycle methods on the generated class: addConstraints(), addEagerConstraints(), match(). Everything else in the package is scaffolding around these.

First use case: the ACL hop. The canonical case is a multi-hop traversal that hasManyThrough and belongsToMany cannot express — User → Role → Permission, where a user's effective permissions are the union across all their roles. Native relations can get you $user->roles and $role->permissions, but there is no native relation for "permissions belonging to this user through any of their roles."

php artisan make:relation UserPermissionRelation

You get a skeletal class extending CultureGr\CustomRelation\CustomRelation with $this->query (the related Permission builder) and $this->parent (the User instance) pre-wired. Fill in the joins and matching, then expose it on the model:

use CultureGr\CustomRelation\HasCustomRelation;

class User extends Model
{
    use HasCustomRelation;

    public function permissions(): UserPermissionRelation
    {
        return $this->relatesTo(Permission::class, UserPermissionRelation::class);
    }
}

It is immediately usable exactly like a native relation — as a method, a dynamic property, and through with() / load():

User::find($id)->permissions()->where('name', 'like', 'billing.%')->get();
User::find($id)->permissions;
User::with('permissions')->get();
$user->load('permissions');

The first working version should take under a day. The trap is that a working version and a production version are different things — see Gotchas.


Implementation Patterns

Separate the "constrain" concern from the "decorate" concern. The README's reference implementation does all join work in addConstraints() and then re-does the relationship logic in addEagerConstraints() and again in match(). That duplication is the main source of drift. A cleaner habit: put the join plumbing in one private method, and let the lifecycle methods compose it.

class UserPermissionRelation extends CustomRelation
{
    private function joinPath(): void
    {
        $this->query
            ->join('permission_role', 'permission_role.permission_id', '=', 'permissions.id')
            ->join('roles', 'permission_role.role_id', '=', 'roles.id')
            ->join('role_user', 'role_user.role_id', '=', 'roles.id')
            ->select('permissions.*')
            ->distinct();
    }

    public function addConstraints()
    {
        $this->joinPath();

        if ($this->parent->exists) {
            $this->query->where('role_user.user_id', $this->parent->getKey());
        }
    }
}

Use $this->parent->exists rather than !is_null($this->parent->getAttribute('id')). Both work for the eager-loading path (the parent passed to relatesTo during with() is a fresh, unsaved instance), but exists states the intent and survives models with manually assigned keys.

select() and distinct() are not optional. A three-way join on id columns will clobber permissions.* with role_user.id unless you select('permissions.*'). And any user with two roles sharing a permission gets that permission row twice. distinct() handles it at the DB; a unique('id') in match() is the belt-and-braces alternative.

Rewrite match() into a single-pass index. The README filters the full result collection once per parent, which is O(parents × results) in PHP on top of deep hydration. Build the lookup once:

public function match(array $users, Collection $results, $relation)
{
    $byUser = [];

    foreach ($results as $permission) {
        foreach ($permission->roles as $role) {
            foreach ($role->users as $user) {
                $byUser[$user->id][$permission->id] = $permission;
            }
        }
    }

    foreach ($users as $user) {
        $user->setRelation(
            $relation,
            new Collection(array_values($byUser[$user->getKey()] ?? []))
        );
    }

    return $users;
}

This is the single highest-value change you can make over the documented example. Same behaviour, one pass.

Avoid ->with() inside addEagerConstraints(). The README chains ->with('roles.users') there to feed match(). That eagerly hydrates every role and every user attached to every role — potentially the whole user table — to answer a question about one permission set. Better: carry the correlation key through the join so match() needs no nested hydration.

public function addEagerConstraints(array $users)
{
    $this->query
        ->whereIn('role_user.user_id', collect($users)->pluck('id'))
        ->addSelect('role_user.user_id as pivot_user_id');
}

Then match() keys directly off $permission->pivot_user_id. If you keep the nested-with approach, scope it: ->with('roles:id', 'roles.users:id') at minimum.

Parameterise relations via the class, not the call site. relatesTo() takes the related class and the relation class. When you need variants ("active permissions", "direct permissions only"), do not branch inside a single relation class on runtime state — create a second, small relation class per variant. It keeps each class readable, keeps IDE jump-to-definition useful, and keeps match() correct, which is where branching gets dangerous.

Route new call sites through the relation; do not retrofit working queries. The migration path that works is additive: implement one relation, use it in new code, and leave existing manual joins behind their current repository methods. Replace an old query only when you can assert equivalence — load the fixture set, run the old query body and $user->permissions, and assert the two ID sets are identical. Each swap becomes a small, revertible PR.

Test the relation, not the query. Custom relations have no whereHas, so any test that exercises the relation is also your only coverage of its join logic. Minimum bar for every relation class:

public function test_permissions_are_eager_loaded_without_n_plus_one()
{
    $users = User::with('permissions')->get();

    foreach ($users as $user) {
        $this->assertTrue($user->relationLoaded('permissions'));
    }
}

public function test_matches_lazy_loaded_results()
{
    $lazy = User::find($id)->permissions()->pluck('id')->sort()->values();
    $eager = User::with('permissions')->find($id)->permissions->pluck('id')->sort()->values();

    $this->assertEquals($lazy, $eager);
}

That second test is the one that catches match() bugs, which are otherwise invisible — the relation returns something, just the wrong thing.

Useful integration points. Because the relation is chainable, ->permissions()->count() works for cheap existence checks; can()/Gate policies can read $user->permissions once per request and hit a Collection::contains; and API resources should read the relation only after an explicit load(), never lazily inside a loop.


Gotchas and Tips

match() must return $users. It is easy to write a clean early-return and forget the happy path. An array return type on the method is the cheapest guard.

Soft deletes on intermediate tables are invisible to you. Joining roles and permission_role is raw SQL — SoftDeletes scopes apply to the related model (Permission), not to the pivot tables you joined. If roles has deleted_at, you are returning permissions through deleted roles. Add ->whereNull('roles.deleted_at') explicitly. Same story for any global scope on intermediate models — you get none of them for free.

Global scopes on the related model do apply. Permission's own global scopes run, which is usually what you want but occasionally produces a confusing empty result. When a relation "returns nothing" and the SQL looks right, check the related model's booted().

Ambiguous columns will bite on the second relation. The first relation in a codebase uses where('id', ...) and works. The second one, joined to the same tables, throws Column 'id' in where clause is ambiguous. Always qualify: permissions.id, role_user.user_id. Same for orderBy.

Ordering is not automatic. addConstraints() with no orderBy returns rows in whatever order the DB chooses, which means $user->permissions differs between lazy and eager paths — and match() will happily preserve the difference. Fix it in the relation: ->orderBy('permissions.name').

The native toolkit is missing. This is the friction point you will actually hit. Custom relations do not give you:

  • constraint closures in eager loading — with(['permissions' => fn ($q) => $q->where(...)])
  • whereHas('permissions') / has()
  • withCount('permissions')
  • pagination on the relation
  • orWhereHas, doesntHave

If a feature request needs any of these, the honest answer is a scoped query object or a repository method, not a stretched custom relation. Decide this up front rather than discovering it mid-sprint. ->permissions()->count() and ->permissions()->where(...)->get() do work, because you are chaining on the builder before execution — lean on those.

Chained constraints leak into match(). If a caller adds ->permissions()->where('name', 'x')->get(), they get filtered results. But with('permissions') does not accept those constraints, so the two paths diverge silently. Document per relation whether it is eager-loadable in isolation or only usefully lazy.

Deduplicate before you count. $user->permissions->count() on a joined relation without distinct() or unique('id') over-reports when a user holds two roles granting the same permission. This is the most common "the numbers are wrong" bug in ACL implementations.

Cache invalidation is yours. Nothing in the package knows when you attach a role. If you cache $user->permissions, invalidate on role attach/detach, permission attach/detach, and role-permission edits — four touchpoints. Easier: cache the computed permission names array under a versioned key, or don't cache.

make:relation scaffolds, it does not decide. The generated class is a starting point with empty methods. Running it and filling the bodies from the README is how the O(parents × results) match() and the whole-table with('roles.users') reach production. Have one reviewed reference implementation per codebase that everyone copies from, rather than copying from GitHub.

Framework-internal coupling is a real upgrade tax. Custom relation classes hook into Eloquent's relation lifecycle (addConstraints / addEagerConstraints / match), which is stable but not a public contract. Budget a smoke test of every custom relation on each Laravel major upgrade, and add the package to your dependency-review checklist. The maintainer does track majors (v1.3 → v1.5 shipped Laravel 11, 12, 13), but always with a lag — expect a brief window after each framework release where you are unpinned or unsupported.

Adoption risk is small and mitigable. 18 stars, zero dependents, single maintainer, no security disclosure path. The mitigating factor is that the surface is a trait plus one class per relation — small enough that vendoring a fork internally is a realistic contingency rather than a rewrite. Name an owner for that fork before you depend on a second relation, not after.

Extension points worth knowing. $this->query is a full Eloquent builder, so anything you can do to a query you can do here — whereIn, whereNull, subqueries, limit. $this->parent is the live model, so parent-scoped logic (tenant IDs, team scoping, feature flags on the model) is available in addConstraints(). That is the whole extension surface, and it is enough for everything except the missing native methods above.

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