zing/laravel-query-builder
A Laravel package for building cleaner, reusable Eloquent queries with composable filters, sorting, and relationship helpers—keeping controllers and models concise while making complex database queries easier to organize and maintain.
This package is a maintained fork of spatie/laravel-query-builder (same namespace conventions, same public API), so the fastest way to get productive is to treat the Spatie docs as your reference and verify deltas against this repo's README and src/.
Install
composer require zing/laravel-query-builder
php artisan vendor:publish --provider="Zing\QueryBuilder\QueryBuilderServiceProvider" --tag=config
Confirm the service provider is auto-discovered (Laravel 5.5+) — if not, register it in config/app.php.
Where to look first
README.md — the fork's documented deltas from Spatie (PHP/Laravel version bumps, added filter types, bug fixes).config/query-builder.php — the contract for parameters (filter, sort, include, fields), count param, and the custom filter class namespace. This is the file you will actually edit in anger.src/QueryBuilder.php — the for(), allowedFilters(), allowedSorts(), allowedIncludes(), allowedFields() surface.tests/ — the cheapest way to confirm behavior that differs from Spatie.First use case — a paginated index endpoint:
// app/Http/Controllers/Api/UserController.php
public function index()
{
$users = QueryBuilder::for(User::class)
->allowedFilters(['name', 'email'])
->allowedSorts(['name', 'created_at'])
->allowedIncludes(['posts', 'team'])
->defaultSort('-created_at')
->paginate(25)
->withQueryString();
return UserResource::collection($users);
}
Hit /api/users?filter[name]=jon&sort=-created_at&include=posts&page=2 and you have the whole happy path. Build this one endpoint end-to-end before touching custom filters — it exercises config, whitelisting, and pagination together.
Centralize the builder in a Form Request or a dedicated method. Controllers that inline allowedFilters() chains become unreadable at ~8 filters. A buildQuery() method on the request, or a QueryBuilder factory on the model, keeps it testable:
// app/Http/Requests/IndexUserRequest.php
public function query(): QueryBuilder
{
return QueryBuilder::for(User::class)
->allowedFilters($this->filters())
->allowedSorts(['name', 'created_at'])
->allowedIncludes(['posts'])
->defaultSort('-created_at');
}
Filter types are the main lever. AllowedFilter::exact(), ::partial(), ::scope(), ::beginsWith(), ::scope(), ::trashed(), and ::custom() cover most needs; ::exact() is the safe default for IDs and enums, ::partial() for free text. Prefer ::scope() over custom filter classes when the constraint is a where clause — the logic stays on the model where it belongs and can be reused outside HTTP.
Custom filters for anything non-trivial. For date ranges, geo-radius, or JSON column queries, implement Zing\QueryBuilder\Filters\Filter (interface signature: __invoke(Builder $query, $value, string $property)), register the namespace in config, and reference it by class:
->allowedFilters([
AllowedFilter::custom('created_between', new CreatedBetweenFilter),
])
Keep filters single-purpose and throw InvalidQueryException on malformed input so the 400 surfaces automatically.
Sparse fieldsets + includes together. allowedFields(['id', 'name', 'email']) pairs with allowedIncludes() for GraphQL-ish clients, but note that fields and include are separate parameter families — document both in your API contract or clients will silently get full models back.
Chain from an existing builder, not just a class. QueryBuilder::for(User::where('active', true)) lets you layer tenant/authorization scoping before the request-driven clauses. This is the cleanest place to enforce where('team_id', auth()->user()->team_id) — do it here, never inside an allowed filter.
Sorting on relationship columns requires an explicit join; the package sorts columns on the base table only. Either add the join before ->allowedSorts(), or expose a scopeSortByX() and call it from allowedSorts(AllowedSort::custom(...)).
Test the query string, not the internals. $this->getJson('/api/users?filter[name]=x&sort=-id')->assertOk() catches whitelist typos, which are the #1 failure mode. Also assert the negative case (?filter[password]=... is ignored) at least once per endpoint.
Reuse across resources. A QueryBuilder instance can be serialized through pagination into an API Resource; call ->withQueryString() on the paginator so next/prev links survive the filter set.
composer.json and read the changelog before composer update; if you hit a bug or need long-term support, spatie/laravel-query-builder is the upstream and swapping back is usually a one-line namespace change. Decide early which you're standardizing on across the codebase.filter[foo] is not an error — it's ignored. This makes a typo in allowedFilters() look like a broken client. Log or assert on unknown parameters in non-production if your clients are external.InvalidQueryException is thrown for known-but-invalid values, not unknown ones. Register a handler to return 400 with the offending parameter rather than a 500.?filter[name]= may produce LIKE '%%' (harmless) or an exact match on '' depending on filter type. Normalize in a middleware or use ->removeEmptyFilters() if the fork exposes it.include is the cheapest way to DOS yourself. Unbounded nested includes (include=posts.comments.author) generate deep eager loads. Constrain with closures on the relationship or cap depth; allowedIncludes accepts constrained builders for exactly this.config/query-builder.php is cached, changing the custom filter namespace or parameter names won't take effect until php artisan config:clear. This is the most common "it works locally" report.allowedFields/select when using sparse fieldsets.allowedFilters(). Build the allow-list from a constant or config, never from $request->all(). The package's security model assumes a developer-authored whitelist.->toSql() after the chain, plus ->getBindings(), is faster than reading the source. If a filter isn't applying, dump both — it's almost always a whitelist mismatch or an empty-value short-circuit.Filter classes, a subclassed QueryBuilder (override for() to inject a default scope), and the published config's parameters map (rename filter/sort/include to match an existing API contract you can't change).How can I help you explore Laravel packages today?