liam-wiltshire/laravel-jit-loader
Laravel JIT Loader adds just-in-time relationship loading to Eloquent, helping eliminate N+1 queries without manually eager-loading every relation with with(). It loads relationships only when needed, keeping model access simple and query-efficient.
Install
composer require liam-wiltshire/laravel-jit-loader
There is nothing to publish and nothing to register if you're on Laravel 5.5+ package discovery. The whole package is a single trait.
Wire up your first model
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use LiamWiltshire\LaravelJitLoader\JitLoader;
class Post extends Model
{
use JitLoader;
protected $jit = ['author', 'comments'];
}
That's the entire setup. Any query that hydrates Post models — Post::all(), Post::where(...)->get(), Post::paginate(), route model binding — will now come back with author and comments already attached.
Where to look first
Open vendor/liam-wiltshire/laravel-jit-loader/src/JitLoader.php. It's a few dozen lines and knowing the exact hook point is what separates "it works" from "why did my query count triple". Confirm:
relationLoaded() before eager loading (matters when you also call with() explicitly).First real use case
The classic admin index page:
// Controller — no with() anywhere
$posts = Post::latest()->paginate(25);
// resources/views/posts/index.blade.php
@foreach ($posts as $post)
{{ $post->author->name }} {{-- 1 query, not 25 --}}
@endforeach
Before: 26 queries. After: 3. Verify with DB::enableQueryLog() or Telescope.
Don't start everywhere. Pick two or three models that appear in list views and are read far more often than written. Measure before and after.
Treat $jit as documentation of a model's default read shape. If a relation is rendered on 90% of the screens that touch the model, it belongs in $jit. If it's shown on a detail page only, it does not.
class Product extends Model
{
use JitLoader;
// shown on every card, everywhere
protected $jit = ['brand', 'primaryImage'];
}
Detail pages that need reviews, variants, supplier should keep using explicit with():
$product = Product::with('reviews.user', 'variants')->findOrFail($id);
The two mechanisms compose: $jit covers the baseline, with() covers the specifics of one screen.
The value proposition is that relationship decisions move out of controllers and repositories and into the model. Push in that direction consistently — don't add $jit and keep the same with() calls in every controller, or you'll double up.
Repository pattern still applies cleanly:
class PostRepository
{
public function published(): LengthAwarePaginator
{
// no with() needed; $jit covers it
return Post::where('published', true)->latest()->paginate();
}
public function withComments(): Builder
{
// override/extend when a screen genuinely needs more
return Post::with('comments.replies');
}
}
$jit is per-model, so a related model can declare its own:
class Comment extends Model
{
use JitLoader;
protected $jit = ['author'];
}
class Post extends Model
{
use JitLoader;
protected $jit = ['comments'];
}
Be deliberate about this — a Post list now loads comments and each comment's author. That can be the right answer (comment list UI) or a silent performance regression (post card only needs comments_count). Watch the query count when you add a nested $jit; it applies everywhere the inner model is hydrated, including from inside eager loads.
This package and Model::preventLazyLoading() are complementary:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
With $jit doing its job, common relations never trip the guard. Relations you forgot to declare still throw, which is exactly the signal you want. Use this combination in local dev and CI — it surfaces gaps in your $jit declarations immediately.
Because the package works invisibly, a broken $jit (typo, removed relation) still returns correct data — just with N+1s. Assert on queries:
public function test_post_index_has_no_n_plus_one(): void
{
Post::factory()->count(10)->hasComments(2)->create();
DB::enableQueryLog();
$posts = Post::latest()->take(10)->get();
$posts->each(fn ($p) => $p->author->name);
$posts->each(fn ($p) => $p->comments->count());
$this->assertLessThanOrEqual(4, count(DB::getQueryLog()));
}
Run this whenever someone edits $jit.
Incremental rollout works well:
preventLazyLoading on.If something goes wrong on a single model, remove the trait from that one class; nothing else depends on it.
$jit fires on every hydration, including:
Post::count()-style aggregates — usually harmless, but check whether the package skips them.Post::find($id) in a background job that only needs id and status.each() loops on huge collections.If you can't find a built-in opt-out in the trait source, the pragmatic escape hatches are: a subclass with an empty $jit, a query-builder macro, or dropping the select() down to what the JIT loader needs.
select() with a narrow column list breaksThis is the sharpest edge:
Post::select('id', 'title')->get(); // $jit wants author_id, user_id, etc.
Eager loading a belongsTo requires the foreign key on the parent; a hasMany requires the parent's primary key. Restricting columns silently produces null relations or SQLSTATE[42S22]: Column not found errors, and the stack trace points at the eager-load query, not at your select(). Always include the keys the JIT loader needs, or don't narrow the select on that query.
If Post::$jit = ['author'] and User::$jit = ['posts'], and the package does not guard against already-loaded relations, you can get runaway eager loading. Whether this actually recurses depends on the hook implementation (one level of eager loading is usually safe), but it is the first thing to test when you add $jit to both sides of a relationship. Check the trait source for a relationLoaded() / depth guard before shipping mutual declarations.
protected $jit = ['auther']; throws RelationNotFoundException on the first query that hydrates the model — often in an unrelated test or a queued job. Mitigations:
$model->{ $relation }() for each entry in $jit to assert the method exists.$jit.$jit is inheritedSubclasses inherit the parent's $jit. If you have STI-style hierarchies or User/AdminUser extends User, either override $jit explicitly in the child or accept the parent's list.
Adding $jit changes toArray() / toJson() / API resource output — relations that were previously absent now appear. That's a breaking change for API consumers. Audit JsonResource classes and any place that returns a model directly from a controller.
Last release is November 2020, so it targets the Laravel 6–8 era. Before adopting:
composer.json constraints and run the suite on your actual Laravel version.$jit is declared, but verify).Illuminate\Database\Eloquent\Builder::lazy(), chunkById(), or cursor(). Streaming APIs that hydrate one model at a time are the most likely to behave differently — measure query counts on a lazy()/cursor() path specifically.->toSql() shows only the base query; eager loads are separate queries.DB::listen() or DB::enableQueryLog() is the reliable way to see what JIT added.$jit is loading a heavy relation (large hasMany, or a relation with its own with() chain) on a screen that never displays it.$jit in a child class for context-specific behavior.with() per query — explicit with() should win or merge; verify no duplicate eager loads by watching the query log.$jit relations apply as normal, so a soft-deleted author comes back as null, not an exception. Handle with ?-> in views.with() in the repository — the package is a convenience, not a framework.How can I help you explore Laravel packages today?