stancl/laravel-hasmanywithinverse
Laravel's hasMany leaves the inverse relation unset, so accessing the parent from each child triggers extra queries. This package adds hasManyWithinverse, which hydrates the inverse automatically—so $child->parent is already loaded without N+1.
archtechx/laravel-hasmanywithinverse adds a hasMany variant that also hydrates the child model's inverse relation in memory. Instead of:
$post = $user->posts()->create(['title' => 'Hello']);
$post->author; // triggers a fresh SELECT
you get a Post whose author relation is already set to the $user instance — no extra query, and the two objects share identity (mutating $user->name after the fact is visible through $post->author).
Install
composer require archtechx/laravel-hasmanywithinverse
Version notes for this release (v1.4.2)
^1.4.1 and hold off on the upgrade until you move to L9+.Where to look first
ArchTech\HasManyWithInverse\HasManyWithInverse) — it extends Illuminate\Database\Eloquent\Relations\HasMany, so everything you know about hasMany still applies; the only difference is the constructor argument for the inverse name and the overridden create/save paths.hasManyWithInverse() builder method on your model.First use case
Replace the hasMany you reach for most often in write paths — typically a user/author producing content in a controller or action:
use ArchTech\HasManyWithInverse\HasManyWithInverse;
class User extends Model
{
public function posts(): HasManyWithInverse
{
return $this->hasManyWithInverse(Post::class, 'author');
}
}
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
The second argument is the name of the inverse relationship method on the child model, not the foreign key column. Get that name wrong and you will silently set a relation nobody reads.
The highest-value use is the "create through the authenticated user, then render" flow:
public function store(StorePostRequest $request): PostResource
{
$post = $request->user()->posts()->create($request->validated());
// $post->author is already the authenticated user — no query,
// and PostResource can embed 'author' without a lazy-load hit.
return PostResource::make($post);
}
This pairs especially well with Model::preventLazyLoading() in AppServiceProvider, because a resource that references $post->author would otherwise blow up in non-production environments.
Factory has() / for() helpers route through saveMany/createMany on the relation, so the inverse is populated during seeding too:
User::factory()->hasPosts(3)->create();
If you have model observers or booted() hooks that read $post->author, this avoids the N+1 you would otherwise introduce in the seeder.
hasManyWithInverse composes normally with saveMany, createMany, save, make, and create — every child produced through the relation object gets its inverse set:
$user->posts()->createMany([
['title' => 'A'],
['title' => 'B'],
]);
// each Post has ->author loaded
Nested relations work, but each level needs its own hasManyWithInverse declaration — a plain hasMany in the middle of the chain stops the propagation there.
Assert on the relation being loaded, not just equal:
$post = $user->posts()->create(['title' => 'Hello']);
$this->assertTrue($post->relationLoaded('author'));
$this->assertTrue($user->is($post->author));
$this->assertSame(0, DB::connection()->getQueryLog() ? ... : 0);
The identity assertion is the point: it is the same PHP object, so it is a cheap and precise regression test if you later swap the relation back to a plain hasMany.
hasManyWithInverse solves the write side only. Listing endpoints still need with('author') / load('author') — do not let the package lull you into dropping eager loads on index queries.
Because the relation class is a HasMany subclass, declaration swaps are behaviourally additive. Migrate a couple of hot relations first, run your test suite with preventLazyLoading enabled in a non-production environment, and treat any new lazy-load exceptions as a signal you found a code path that was previously paying for a silent extra query.
Laravel 8 is gone. v1.4.2 raises the floor. Composer will happily resolve ^1.4.2 in a project that still has an L8 app in a monorepo sibling — check composer why-not if you see resolution failures on a shared lockfile. L8 users should pin 1.4.1.
firstOrCreate / updateOrCreate / firstOrNew bypass the relation's create(). These go through the query builder and save() on a fresh model instance rather than the relation's create(), so the inverse may not be set. If you depend on the in-memory inverse, route through create() explicitly, or re-check the relation class in your installed version before assuming coverage. Same caution applies to forceCreate.
Direct instantiation is invisible to the package. Post::create([...]) outside a relation object has nothing to infer an inverse from. The package only helps when the parent is known at call time.
The inverse name must match the child's relationship method exactly. A typo does not throw — setRelation() accepts any key. You get a model with a loaded relation nobody asks for, and the real inverse still lazy-loads. Add the assertion from the testing section to at least one test per migration.
refresh(), fresh(), and withoutRelations() discard it. All three rebuild or strip relation state. If a job or listener calls $post->refresh() before reading author, you are back to a query.
Queue serialisation does not carry it. Models are re-fetched by ID in the worker, so the inverse is not restored. Only rely on this within a single request/command execution.
Watch for chaperone() overlap. Recent Laravel versions ship a native chaperone() on HasMany that solves the same problem. If you are planning an upgrade past L10, audit these call sites — you may be able to delete the trait usage and drop the dependency entirely, which is usually preferable to keeping a third-party shim for a framework feature.
Extension point. If you need to handle more creation paths (firstOrCreate and friends), the cleanest hook is your own subclass of HasManyWithInverse overriding those methods and returning it from your trait's hasManyWithInverse() — do not patch the vendor directory, and do not try to bolt this onto a global model observer, which loses the parent context that makes the trick work.
Debugging checklist when the inverse is unexpectedly missing: (1) was the child created through the relation object, (2) does the inverse method name match the second argument, (3) did anything call refresh()/fresh() in between, (4) is the model crossing a queue boundary.
How can I help you explore Laravel packages today?