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

Lazy Belongs To Many Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. README.md — the only usage documentation that exists; read it end-to-end, it is short.
  2. 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.
  3. composer.json — require block tells you the maximum Laravel/PHP version this was written for. Compare against your app.
  4. 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.


Implementation Patterns

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

  • Combine with native relations freely: the owning model can have a real belongsToMany elsewhere; the package only replaces one side.
  • Use model observers/events on the owning model (updated) to trigger cache invalidation or search reindexing — you will not get pivot events.
  • If you cache rendered fragments, key the cache on the owning model's updated_at, since the relation has no pivot timestamps to observe.
  • In factories/tests, seed by setting the column directly ('tags_ids' => Tag::factory()->count(3)->create()->pluck('id')->all()) — faster and avoids depending on the package's write path being correct.

Gotchas and Tips

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.

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.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib