laravel-expansions/query-control-model
Query Control Model is a Laravel package that adds controllable query behaviour to Eloquent models, letting you define, restrict, and reuse model queries through clean, consistent APIs. It helps keep filtering and data access predictable across your application.
This package is a thin convenience layer over Eloquent's with() and withCount() — it lets you pass relations as a comma-separated string (or array) instead of hand-building arrays everywhere. Start here:
with / withCount values were split on commas, so require ^1.0.2 rather than ^1.0.0:
composer require <vendor>/<package>:"^1.0.2"
tests/ is the most trustworthy spec for exactly which input shapes are supported (string with commas, arrays, nested relations, closures).config/<package>.php) and publish it only if you need to change defaults: php artisan vendor:publish --tag=<package>-config.// Before
Post::with(['author', 'comments'])->withCount(['comments'])->paginate();
// After
Post::with('author,comments')->withCount('comments')->paginate();
The win is small per call site but compounds in codebases where relation lists are assembled from request filters, config, or service parameters.
Prefer arrays for static lists, strings for dynamic ones. Arrays are self-documenting and immune to parsing ambiguity; comma strings shine when the list arrives from a query param, config value, or an admin-editable setting. Keep the string form at the boundary (request/config) and normalize to arrays once.
Normalize once at the edge of your application. Don't scatter string parsing across controllers. A small helper or form request that returns array keeps eager-loading logic testable and makes the v1.0.2 semantics explicit in your own code:
$relations = collect(explode(',', $request->string('include')))
->map(fn ($r) => trim($r))
->filter()
->all();
Post::with($relations)->withCount($countRelations)->paginate();
Use closures for constrained eager loads. These are the case most likely to brush against the comma-parsing fix — a closure body should never be stringified into a relation list:
Post::with(['comments' => fn ($q) => $q->where('approved', true)])
->withCount(['comments as approved_comments_count' => fn ($q) => $q->where('approved', true)])
->get();
Alias withCount deliberately. withCount('comments as total_comments') produces total_comments in the serialized payload; standardize aliases per resource so API contracts don't drift between endpoints.
Keep it consistent across repositories/API resources. If you adopt the package, adopt it everywhere — mixing raw with([...]) and package string calls in the same layer makes grep-based audits of eager loading much harder.
Pair with pagination and resources. Eager-loaded counts are computed in the same query, so ->withCount(...)->paginate(25) then mapping through an ApiResource is the cleanest path for list endpoints.
with / withCount values were exploded on commas unconditionally, which was a mistake: any value legitimately containing a comma was silently split into bogus relation names. After upgrading, double-check any call site where you passed a string that contained a comma for a reason other than separating relations — behavior may now differ from what you empirically relied on.RelationNotFoundException on unknown relations, so a mis-split string usually surfaces as a runtime exception rather than a wrong result. If you see a relation name that looks like a fragment of another string, suspect leftover parsing assumptions.'author, comments' — verify the package trims segments. If it doesn't, ->map('trim') your list before passing it, or you'll get a RelationNotFoundException for ' comments'.comments.author) are a separate concern from comma splitting; ensure your own normalization doesn't accidentally split on other delimiters.with, but duplicate aliases in withCount can produce confusing column names — dedupe your input list.DB::listen(), Laravel Telescope, or Model::preventLazyLoading() in non-production will tell you instantly whether the eager load actually happened. A string that parsed to nothing looks identical to a string that worked, until you count queries.with() without an allow-list — it's an information-disclosure vector.composer.lock moved to 1.0.2, run your eager-loading tests, and read the diff of the parsing method before trusting it in a hot path.How can I help you explore Laravel packages today?