inani/lazy-belongs-to-many
Laravel package that adds a lazy-loading belongsToMany relationship, letting you defer large many-to-many queries until the collection is actually accessed. Keeps models light while still supporting pivot data, eager loading, and standard relationship methods.
Install and pin
composer require inani/lazy-belongs-to-many
The package is small (24 stars, no dependents) and unlikely to be actively maintained, so immediately pin the exact version in composer.json and check what Laravel releases it actually resolves against. Run composer why-not laravel/framework if install fails on a modern Laravel — you will likely need --ignore-platform-reqs or a fork. If you fork, point Composer at your VCS repo via a repositories entry rather than editing vendor/.
Where to look first
README.md — the only usage documentation that exists; read it end-to-end, it is short.src/ — the trait (the thing you use in your model) and the relation class. Read the relation class before trusting the README; the README is the spec, src/ is the truth.composer.json — require block tells you the maximum Laravel/PHP version this was written for. Compare against your app.tests/ if present — the fastest way to see the intended attach/detach/sync semantics and which methods are not implemented.Core idea
Instead of a pivot table, the relation stores related model keys in a single column on the owning model (JSON or delimited string, depending on how the package is configured). The trait gives your model a relation factory that behaves like a lightweight belongsToMany:
use Inani\LazyBelongsToMany\HasLazyBelongsToMany;
class Post extends Model
{
use HasLazyBelongsToMany;
public function tags()
{
return $this->lazyBelongsToMany(Tag::class); // column defaults to tags_ids / configurable
}
}
Migration side:
Schema::table('posts', function (Blueprint $table) {
$table->json('tags_ids')->nullable(); // or ->text() if the package expects a string
});
Also cast the column so PHP gets an array, not a string:
protected $casts = ['tags_ids' => 'array'];
First use case
The one that justifies the package: a small, read-heavy, low-cardinality set of IDs on a model — tags, categories, feature flags, "related posts" — where a pivot table feels like overhead and you never need pivot attributes. Anything else, use Laravel's native belongsToMany.
Read path — always eager load
The whole point is avoiding a join, but you still pay N+1 if you don't eager load. Use with('tags') like normal if the relation class implements EagerLoadable correctly; verify with DB::listen() that a list of 100 posts produces ~2 queries, not 101. If eager loading is broken in your version, load manually:
$posts = Post::paginate(20);
$tags = Tag::whereIn('id', $posts->pluck('tags_ids')->flatten()->unique())->get()->keyBy('id');
$posts->each(fn ($p) => $p->setRelation('tags', collect($p->tags_ids)->map(fn ($id) => $tags->get($id))->filter()->values()));
Write path — wrap in a transaction with a row lock
Any implementation that reads the column, mutates the array in PHP, and writes it back is a lost-update race under concurrency. Never call attach()/detach() outside a transaction if two requests can touch the same row:
DB::transaction(function () use ($post, $tagId) {
$post = Post::whereKey($post->getKey())->lockForUpdate()->first();
$post->tags()->attach($tagId);
});
For high-write models, prefer a dedicated action/service class over scattering attach() calls through controllers — you will want one place to add dedupe, locking, and event dispatch.
Custom relation definitions
Keep the method names conventional (tags(), categories()) so whereHas, with, and load keep working as developers expect. If the package lets you configure the storage column or delimiter per relation, do it explicitly at the call site rather than relying on a global config default — future readers won't know where the column name came from.
Querying from the inverse side
There is no pivot table, so Tag::whereHas('posts') will not exist unless you build it. The standard workaround is a scope using a JSON/FIND_IN_SET-style condition:
// MySQL JSON column
Tag::whereRaw('JSON_CONTAINS(posts.tags_ids, ?)', [json_encode($tag->id)]);
Isolate these in a query scope or repository so the raw SQL lives in one file and the storage format is swappable later.
Integration tips
belongsToMany elsewhere; the package only replaces one side.updated) to trigger cache invalidation or search reindexing — you will not get pivot events.updated_at, since the relation has no pivot timestamps to observe.'tags_ids' => Tag::factory()->count(3)->create()->pluck('id')->all()) — faster and avoids depending on the package's write path being correct.No referential integrity. The database cannot enforce foreign keys on a JSON/CSV column. Deleting a Tag leaves dangling IDs. Add a cleanup job, a Tag::deleted observer that strips the ID from every owner row, or filter orphans on read (->filter() after mapping). Decide which one and write it down.
No pivot features. No withPivot, no wherePivot, no pivot timestamps, no pivot object on results, no pivotAttached/pivotDetached events. Any existing code or package that listens for those will silently do nothing. Grep your codebase for pivot before adopting.
Method coverage is partial. Small packages typically implement attach, detach, sync, and the relation getter, but not syncWithoutDetaching, toggle, updateExistingPivot, or wherePivot. Read the relation class and assert what exists in tests before building on a method you assume is there.
Delimiter/format fragility. If the column is a delimited string, an ID list like 1,2,3 and a query using LIKE '%2%' matches 12, 20, and 102. Confirm whether the package uses JSON containment or FIND_IN_SET; if it's LIKE, treat it as a bug and either switch to a JSON column or write your own scope with FIND_IN_SET(id, column). Also worry about column length: VARCHAR(255) overflows once you have ~40 IDs.
Portability. JSON_CONTAINS and FIND_IN_SET are MySQL-flavored. Postgres (jsonb @>, ANY), SQLite (json_each), and SQL Server behave differently. If your CI runs SQLite and production runs MySQL, the relation may pass tests and fail in prod — test against the production engine.
Ordering is not guaranteed. JSON arrays preserve order in practice but the package may not, and Postgres json (vs jsonb) does not. Don't rely on relation order; sort after loading.
Unmaintained-package risk. With no dependents, there is no community pressure to fix issues, and no guarantee of Laravel 11/12 compatibility. Budget for a fork: keep it in packages/ with a path repository, add a test suite around the three or four methods you actually use, and treat it as internal code. If it ever becomes load-bearing, the migration path back to belongsToMany is a pivot table plus a backfill job — design the column name and accessor so that swap is mechanical.
Debugging checklist when a relation returns nothing or everything: (1) dump the raw column value with getRawOriginal() to confirm the cast isn't mangling it; (2) DB::listen() to see the generated SQL and whether IDs are bound as strings vs ints; (3) confirm the related model's table/key name — the package may assume id and ignore $primaryKey; (4) check $fillable/$guarded if writes seem to no-op.
Extension point worth taking. If the package exposes the relation class, subclass it to add wherePivot-like filters, dedupe on attach, and orphan filtering on read. That is usually a smaller job than replacing the package and gives you a single place to fix the sharp edges above.
How can I help you explore Laravel packages today?