mattsplat / dynamic-loading
Dynamically load undefined relationships
Requires
- php: ^8.1
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.5|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-30 18:16:32 UTC
README
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 → anIlluminate\Database\Eloquent\Collection.$single = false, no match → an emptyEloquent\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
dynamicLoadsub‑query is combined withUNION, so they must be column‑compatible. PreferModel::query()/select('table.*')over selecting different columns per model. $relation_keymust be a plain column identifier; anything else throws aDynamicLoadException. The injected model‑key value is a bound parameter.$single = trueruns->first()on the grouped rows in PHP — addlimit(1)and anorderByto 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
SELECTat 500 terms (SQLITE_MAX_COMPOUND_SELECT). The default$chunkSizeof 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 (itsOFFSET/FETCHrequires an order). Always pairlimitwithorderByin$singlesub‑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