imanghafoori/eloquent-relativity
Laravel package that lets you declare Eloquent relationships without importing related model classes. Relativity infers the target model from the method name, so hasMany, hasOne, belongsTo and belongsToMany calls stay concise and your models keep fewer use statements.
Install and wire up
composer require imanghafoori/eloquent-relativity
The package is tiny and does not require a service provider registration on Laravel 5.5+ (auto-discovery). Publish the config only if you need to change the relatives namespace:
php artisan vendor:publish --tag=relativity
Where to look first (in this order)
Relative base class — this is what your relation definitions extend, and it proxies Eloquent's hasMany / hasOne / belongsTo / morphMany builders, so anything you write today inside a model works inside a relative.HasRelatives trait — the one-line opt-in added to models. Understanding its __get/__call resolution tells you exactly how a relation name maps to a relative method.App\Relatives), which controls where the package looks for {Model}Relative classes.First use case: shrink a bloated model
Pick the model with the most relation methods — usually User. Move one relation out and verify the public API is unchanged:
// before
class User extends Model
{
public function posts() { return $this->hasMany(Post::class); }
public function profile() { return $this->hasOne(Profile::class); }
}
// after
use Imanghafoori\Relativity\HasRelatives;
class User extends Model
{
use HasRelatives;
}
namespace App\Relatives;
use Imanghafoori\Relativity\Relative;
use App\Models\Post;
class UserRelative extends Relative
{
public function posts()
{
return $this->hasMany(Post::class);
}
}
Then confirm the two things every existing caller relies on still work:
$user->posts; // lazy-loaded collection
$user->posts()->where('published', true)->get(); // query builder chain
User::with('posts')->get(); // eager loading
If those three pass, the refactor is behaviourally transparent. Do one relation at a time; this is not a big-bang migration.
Relatives as the only place that knows the graph
The real payoff is that User no longer imports Post, Profile, Comment, etc. The relative class becomes the single seam where the object graph is declared. That makes it the natural place to swap implementations later — for tests, for a feature-flagged model, or for renaming a domain model without touching the consumer.
Keep this discipline: a relative should contain only relation definitions. Scopes, accessors, and business logic stay on the model (or move to dedicated query/action classes). Otherwise you've just relocated the bloat.
Everything Eloquent does keeps working — treat relations identically
Because resolution happens at access time, the following are unaffected and should be part of your test suite during migration:
User::with('posts.comments')->find($id); // nested eager loading
User::has('posts')->get(); // whereHas / has
User::whereHas('posts', fn ($q) => $q->where('published', true))->get();
$user->loadMissing('posts');
$user->posts()->create([...]); // relation writes
$user->posts()->sync([...]); // for many-to-many relatives
Convention vs explicit registration
The default resolution is naming-convention based (App\Models\User → App\Relatives\UserRelative). For atypical namespaces or domain modules, register the mapping explicitly in config rather than relying on the convention — explicit beats a silent "relation not found" at runtime. Always run php artisan config:clear after changing the namespace config, and config:cache in production.
Organise relatives by domain, not by model count
If you have hundreds of models, mirror your domain folders:
app/Relatives/Billing/InvoiceRelative.php
app/Relatives/Support/TicketRelative.php
This keeps the decoupling benefit real — you can reason about a slice of the graph without opening model files.
Testing and fakes
The clean integration point is the relative class. In tests, bind a test relative (or override the config mapping) to point at a lightweight or in-memory model, and the calling code is none the wiser:
// tests/TestCase.php
config(['relativity.namespace' => 'Tests\\Relatives']);
For integration tests, keep using RefreshDatabase and assertDatabaseHas — nothing about the persistence layer changes.
Incremental adoption
Adopt per model, per aggregate root. Start with models that have the most relations and the fewest inbound dependencies. Leave leaf models alone; the churn isn't worth it. A good rule of thumb: extract when a model exceeds ~15 relation methods or when relation definitions dominate the file's line count.
Your IDE and static analysis go blind
This is the single biggest day-to-day cost. $user->posts is now resolved magically, so PhpStorm and Larastan will flag undefined properties and lose autocompletion on relation queries. Mitigations:
@property-read Collection<int, Post> $posts and @method \Illuminate\Database\Eloquent\Relations\HasMany posts() docblocks to the model.barryvdh/laravel-ide-helper will not generate these for you — it reads the model file, which no longer contains the relation. Write them by hand or generate them from the relatives directory with a small custom command.Name collisions resolve ambiguously
A relative method named the same as a real model method, attribute, or accessor will behave unpredictably — the trait's magic __get competes with Eloquent's own accessor resolution. Namespace relation names clearly (posts, not count) and grep the model for collisions before moving anything.
Resolution failures are runtime, not compile-time
If the relative class or method is missing, you get an exception at query time — often deep inside a controller, sometimes only in production paths your tests don't cover. Smoke-test every relation you move with a one-line feature test:
public function test_user_relations_resolve(): void
{
$user = User::factory()->hasPosts(1)->create();
$this->assertNotNull($user->posts);
}
Lazy loading guards still apply
Model::preventLazyLoading() (and preventAccessingMissingAttributes()) hooks into Eloquent's relation resolution, so the package respects them. Good — but it means a relation you moved from a model to a relative will still throw LazyLoadingViolationException if you forget to eager load. That's a feature, not a bug; keep the guard on in non-production.
Config cache is the usual "it worked locally" culprit
Changing the relatives namespace and forgetting php artisan config:cache in deploy is the most common failure mode. Add it to your deploy script alongside the other cache commands.
Migration order matters for whereHas chains
If you move posts on User but Post still declares comments inline, nested with('posts.comments') works fine. But if you later move comments to PostRelative, re-run your nested eager-load tests — that's the level at which resolution regressions hide.
Extension points
The package is intentionally small: the Relative base class, the HasRelatives trait, and the config namespace. If you need behaviour beyond that (custom resolution rules, class maps, per-tenant relative namespaces), extend the base class or swap the config binding — do not patch the vendor folder. The surface area is small enough that a thin wrapper in your app is cheaper than a fork.
Maturity / risk assessment
148 stars, zero dependents, last release April 2024, single maintainer. It's a clean, focused idea with a tiny codebase you can read end-to-end in an afternoon — that's the mitigating factor. For a long-lived production codebase, either vendor it into a packages/ directory you control or make the abstraction yours (a thin interface + resolver) so you're never blocked on upstream. It's best adopted as a convention-plus-small-helper rather than as load-bearing infrastructure, and it's genuinely useful on teams large enough that model files have become merge-conflict hotspots.
How can I help you explore Laravel packages today?