Search by

mattsplat / dynamic-loading

mattsplat

Dynamically load undefined relationships

Package info

github.com/mattsplat/DynamicLoading

pkg:composer/mattsplat/dynamic-loading

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.0 2026-08-30 18:12 UTC

This package is auto-updated.

Last update: 2026-08-30 18:16:32 UTC


README

tests

Load relationships that were never defined on your models onto an existing Eloquent collection — in a single query — without foreign keys, relationship methods, or N+1 lookups.

dynamicLoad takes a collection you already have in memory, runs one sub‑query per model, stitches them together with UNION, executes them in one query per chunk of models (default 100), and attaches the results back to each model as a normal Eloquent relation you can access with $model->your_relation_name.

When the sub‑query conditions are the same for every model and there is a real linking column on the related table, use dynamicLoadWhereIn instead — it is a single whereIn query with no UNION.

Why

Eloquent's eager loading (with(), load()) requires a relationship method on the model and, usually, a foreign key. That is the right tool when the relationship is a stable part of your domain.

Sometimes you need something more ad‑hoc:

  • "the ranks this user does not have"
  • "the single most recent login per user"
  • "sibling rows that share an attribute but have no FK between them"
  • a relationship whose conditions depend on runtime data or the request

Defining a model method for each of these pollutes the model, and computing them per‑row is an N+1 query. dynamicLoad lets you express the relationship at the call site and still pay only one query.

Requirements

Package Version
PHP ^8.1 (Laravel 13 requires ^8.3)
illuminate/database ^10.0 | ^11.0 | ^12.0 | ^13.0

Tested against Laravel 10, 11, 12 and 13. Laravel auto‑discovers the service provider; no manual registration is needed.

Install

composer require mattsplat/dynamic-loading

API overview

The package registers two macros on Illuminate\Support\Collection (which Illuminate\Database\Eloquent\Collection extends), so they are available on any Eloquent result set.

dynamicLoad

Per‑model sub‑query, combined with UNION. Use when the sub‑query conditions vary per model.

Collection::dynamicLoad(
    string   $relation_name,
    Closure  $subQuery,
    ?string  $relation_key = null,
    ?string  $model_key    = null,
    bool     $single       = false,
    ?int     $chunkSize    = 100
): \Illuminate\Database\Eloquent\Collection
Parameter Required Default Description
$relation_name yes The name you will use to read the data back: $model->{$relation_name}. It only needs to exist here and at the point of access — no model method required.
$subQuery yes fn ($model) => Builder. Called once per model in the collection. Return an unexecuted query builder (Model::query()..., not ->get()). Reference $model to scope the query to that record.
$relation_key no "{snake_model}_{model_key}" — e.g. user_id The column/alias used to group the unioned rows back to their model. If your sub‑query does not already select a column with this name, the package injects "{model_key value} as {relation_key}" for you. Must be a plain column identifier ([A-Za-z_][A-Za-z0-9_]*).
$model_key no the model's primary key name (getKeyName()) The attribute read from each model ($model->{$model_key}) to (a) bind into the sub‑query and (b) match returned rows back to the model.
$single no false false → the relation is set to an Eloquent collection (has‑many style). true → the relation is set to a single model or null (has‑one / latest‑of‑many style).
$chunkSize no 100 Models per UNION query. A collection larger than this is split into several queries. Pass 0 or null for a single UNION regardless of size.

Return value: the same collection of models, with the new relation set on every model:

  • $single = false, matches found → an Illuminate\Database\Eloquent\Collection.
  • $single = false, no match → an empty Eloquent\Collection.
  • $single = true, match found → a single model.
  • $single = true, no match → null.

$model->relationLoaded($relation_name) is therefore reliably true afterwards.

dynamicLoadWhereIn

A single whereIn query, no UNION. Use when every model needs the same sub‑query conditions and the related table has a real column pointing back at the model key.

Collection::dynamicLoadWhereIn(
    string   $relation_name,
    Closure  $subQuery,     // fn (Illuminate\Support\Collection $keys) => Builder
    string   $foreign_key,  // column on the related table + the grouping key
    ?string  $model_key = null,
    bool     $single    = false
): \Illuminate\Database\Eloquent\Collection

$subQuery receives the collection of model‑key values and returns an unexecuted builder for the related model. The package adds ->whereIn($foreign_key, $keys) and groups the results by $foreign_key. The $single / no‑match semantics match dynamicLoad.

// one query: select * from "logins" where "user_id" in (1, 2, 3, ...) and "successful" = 1
$users = User::all()->dynamicLoadWhereIn(
    'successful_logins',
    fn ($keys) => Login::where('successful', true),
    'user_id'
);

How dynamicLoad works

For a collection of 4 users and this call:

$users->dynamicLoad(
    'r_ranks',
    fn ($m) => Rank::where('name', 'like', 'r%')->where('id', '!=', $m->rank_id)
);

the package builds one query per chunk of (by default) 100 users:

select *, ? as "user_id" from "ranks" where "name" like 'r%' and "id" != ?
  union select *, ? as "user_id" from "ranks" where "name" like 'r%' and "id" != ?
  union select *, ? as "user_id" from "ranks" where "name" like 'r%' and "id" != ?
  union select *, ? as "user_id" from "ranks" where "name" like 'r%' and "id" != ?
-- bindings: [1, 5, 2, 2, 3, 9, 4, 1]

executes it, groups the result by user_id (the $relation_key), and calls setRelation('r_ranks', ...) on each user with the rows whose user_id matches that user's id (the $model_key). The injected key is a bound parameter, not string‑interpolated SQL.

If the combined query fails, a MattSplat\DynamicLoading\Exceptions\DynamicLoadException is thrown (wrapping the underlying database exception).

Examples

1. Has‑many: ranks a user doesn't have

Schema

users            ranks
-----            -----
id               id
rank_id          name

There is no foreign key from ranks back to users, and "ranks starting with r that aren't mine" is not a real relationship — but you can still load it.

$users = User::all();

$users = $users->dynamicLoad(
    'r_ranks',
    fn ($m) => Rank::where('name', 'like', 'r%')->where('id', '!=', $m->rank_id)
);

$users->first()->r_ranks;   // Eloquent\Collection of Rank models

Result shape

$users[0] => [
    'id'      => 1,
    'rank_id' => 5,
    'r_ranks' => [
        ['id' => 6, 'name' => 'Ranger'],
        ['id' => 8, 'name' => 'Racer'],
    ],
]

2. Latest‑of‑many: the single most recent login per user

Schema

users            logins
-----            ------
id               id
                 user_id
                 created_at

A normal hasMany would pull every login row. Pass $single = true and a limit(1) sub‑query to get exactly one model per user in one query.

$users = User::all()->dynamicLoad(
    'latest_login',
    fn ($m) => Login::where('user_id', $m->id)->latest()->limit(1),
    null,   // $relation_key — let it default to user_id
    null,   // $model_key    — let it default to the primary key
    true    // $single
);

$users->first()->latest_login;          // a single Login model, or null
$users->first()->latest_login?->created_at;

Result shape

$users[0] => [
    'id'           => 1,
    'latest_login' => ['id' => 6,  'created_at' => '2020-01-20 10:10:00'],
]
$users[1] => [
    'id'           => 2,
    'latest_login' => ['id' => 78, 'created_at' => '2020-01-25 15:10:00'],
]

Users with no logins get latest_login === null.

3. "Next" row by an ordered attribute

Uses another already‑loaded relation inside the sub‑query.

$users = User::with('rank')->get()->dynamicLoad(
    'next_rank',
    fn ($m) => Rank::where('level', '>', $m->rank->level)
                   ->orderBy('level')
                   ->limit(1),
    null, null,
    true
);

$users->first()->next_rank;   // the immediately higher Rank, or null

4. Custom keys

When the attribute to match on isn't the primary key, or the sub‑query already selects its own linking column, pass the keys explicitly.

$orders = Order::all()->dynamicLoad(
    'same_day_orders',
    fn ($o) => Order::whereDate('created_at', $o->created_at->toDateString())
                    ->where('id', '!=', $o->id),
    'order_ref',      // $relation_key — the alias grouping rows back
    'reference'       // $model_key    — Order::$reference, not Order::$id
);

If your sub‑query already selects a column named $relation_key, the package detects it and does not inject an alias, so you can drive the grouping with a real column:

$users->dynamicLoad(
    'referrals',
    fn ($u) => Referral::select('referrals.*', 'referrer_id')
                       ->where('referrer_id', $u->id),
    'referrer_id',
    'id'
);

5. Chaining

dynamicLoad returns the collection, so calls chain:

$users = User::all()
    ->dynamicLoad('latest_login', fn ($m) => Login::where('user_id', $m->id)->latest()->limit(1), null, null, true)
    ->dynamicLoad('r_ranks',      fn ($m) => Rank::where('name', 'like', 'r%')->where('id', '!=', $m->rank_id));

Each dynamicLoad here is one query per chunk of 100 users (2 queries total for a collection of ≤ 100).

6. dynamicLoadWhereIn: same condition for everyone

When the condition doesn't depend on the individual model and the related table has a real foreign key, skip the UNION entirely.

Schema

users            posts
-----            -----
id               id
                 author_id
                 published_at
$users = User::all()->dynamicLoadWhereIn(
    'recent_posts',
    fn ($keys) => Post::whereNotNull('published_at')
                      ->where('published_at', '>=', now()->subMonth())
                      ->latest('published_at'),
    'author_id'          // column on posts + the key results are grouped by
);

$users->first()->recent_posts;   // Eloquent\Collection of Post models

One query:

select * from "posts"
where "published_at" is not null
  and "published_at" >= ?
  and "author_id" in (1, 2, 3, ...)
order by "published_at" desc

Add true as the 5th argument for the single‑model form:

$users = User::all()->dynamicLoadWhereIn(
    'latest_post',
    fn ($keys) => Post::latest('published_at'),
    'author_id',
    null,
    true            // $single → one Post or null per user
);

Notes & caveats

  • Return unexecuted builders from $subQuery (Model::where(...)), not results (->get(), ->first()).
  • Every dynamicLoad sub‑query is combined with UNION, so they must be column‑compatible. Prefer Model::query() / select('table.*') over selecting different columns per model.
  • $relation_key must be a plain column identifier; anything else throws a DynamicLoadException. The injected model‑key value is a bound parameter.
  • $single = true runs ->first() on the grouped rows in PHP — add limit(1) and an orderBy to your sub‑query so the row you get is the one you meant.
  • An empty source collection short‑circuits (no query) and returns an empty Eloquent\Collection.
  • A failing query throws MattSplat\DynamicLoading\Exceptions\DynamicLoadException (it is not swallowed).

Scaling

dynamicLoad issues one UNION query per $chunkSize models (default 100), so a 1,000‑model collection is 10 queries — still far better than the 1,000 of an N+1 loop, but not free. If your case fits dynamicLoadWhereIn (same conditions for all models, real linking column), prefer it: it is always a single query.

For true "top‑N rows per parent" at scale, a database LATERAL join (PostgreSQL, MySQL 8+) or Laravel's own hasOne()->ofMany() / subquery addSelect will outperform a wide UNION.

Database support

The package writes no dialect‑specific SQL — it only uses the query builder's union(), selectRaw() (with bound parameters) and whereIn(), which Laravel compiles for MySQL / MariaDB, PostgreSQL, SQLite and SQL Server. Notes:

  • CI currently runs against SQLite only; the other drivers rely on Laravel's grammars being correct.
  • SQLite caps a compound SELECT at 500 terms (SQLITE_MAX_COMPOUND_SELECT). The default $chunkSize of 100 stays well under it; keep that in mind if you raise it.
  • A sub‑query using ->limit() without ->orderBy() is invalid on SQL Server (its OFFSET/FETCH requires an order). Always pair limit with orderBy in $single sub‑queries — you want that anyway.

Testing

composer install
composer test        # or: vendor/bin/phpunit

The suite uses orchestra/testbench with an in‑memory SQLite database (tests/TestCase.php).

License

MIT