bjerke/api-query-builder
Simplify API query handling in Laravel with a fluent query builder that turns request parameters into filtered, sorted, and paginated Eloquent queries. Reduce boilerplate and keep filtering logic consistent and reusable across your endpoints.
composer require bjerke/api-query-builder
php artisan vendor:publish --provider="bjerke\ApiQueryBuilder\ApiQueryBuilderServiceProvider"
Before writing a single controller, treat the package source as the spec. The README is four bullets; there is no documentation, no changelog beyond a July 2023 release, and zero dependents. Spend the first twenty minutes on three reads:
filter, sort, include, fields, limit — verify, don't assume) are the entire query grammar your clients can speak. Anything not present is unsupported, regardless of what a request sends.->apiQuery($request)). Read its body. That's the authoritative surface.Minimal wiring — confirm the exact property and method names against source:
// app/Models/User.php
protected array $apiFilterable = ['status', 'created_at', 'email'];
protected array $apiSortable = ['created_at', 'name', 'id'];
protected array $apiIncludable = ['orders', 'profile'];
// app/Http/Controllers/Api/UserController.php
public function index(Request $request)
{
return User::query()
->apiQuery($request)
->paginate((int) $request->query('per_page', 25));
}
First use case: one internal list endpoint. An admin users table or audit log behind a feature flag — not a customer-facing API. You want a grammar quirk to be a ticket, not an incident.
Define "installed correctly" as four passing checks:
GET /api/users?sort=-created_at // newest first
GET /api/users?filter[status]=active // filters as expected
GET /api/users?filter[password]=1 // ignored/rejected, never in SQL
GET /api/users?include=orders // eager-loads, no N+1
Verify the fourth with DB::listen or Telescope. If the third fails, stop everything — the allowlist isn't enforcing, and nothing else matters until it does.
Allowlist on the model, never at the call site. Fluent builders make inline allowlists tempting, but they drift the moment a second endpoint exists. Put the arrays on a base ApiModel or a Queryable trait and audit them in one place. A newly-migrated column should never silently become filterable just because it exists.
Scope before you build. The package composes Eloquent constraints; it does not own tenancy, soft deletes, or visibility. The order is a security property, not style:
User::query()
->whereBelongsTo($tenant) // authorization first
->apiQuery($request) // client-driven constraints second
->paginate();
If a future release starts emitting orWhere, a mis-ordered call widens past your tenancy scope. Scoping first is cheap insurance.
Force a deterministic tiebreaker. paginate() without an explicit orderBy produces unstable page boundaries — duplicate and missing rows, only under load. The package won't add one:
->orderByDesc('created_at')->orderByDesc('id')
Feature-flag each endpoint's adoption. Your rollback lever when a client reports ?sort=name suddenly ordering by email should be a config flip, not a hotfix:
if (config('features.api_query_builder.users')) {
return User::query()->apiQuery($request)->paginate();
}
return User::query()->paginate();
Reach for the escape hatch early. The first requirement that isn't a plain column comparison — filter[created_after], filter[q]=acme, filter[status][in]=a,b — don't bend the parser. Append a hand-written scope after the builder:
$query = Post::query()->apiQuery($request);
if ($after = $request->query('filter.created_after')) {
$query->where('created_at', '>=', Carbon::parse($after));
}
return $query->paginate();
Three of these in one sprint is your signal to fork.
Assert on SQL in tests, not just JSON. A response assertion passes for a wide band of queries. toSql() catches allowlist regressions in one line:
it('ignores disallowed filter columns', function () {
$sql = User::query()
->apiQuery(request(['filter' => ['password' => 'x']]))
->toSql();
expect($sql)->not->toContain('password');
});
Run that matrix — allowed filter, disallowed filter, bad sort, disallowed include — for every resource you expose.
Write the grammar docs yourself. A docs/api/querying.md listing per-endpoint supported filters, sorts, and includes. Client teams integrate against that file; it doubles as your checklist when adding a column.
The stale release is the real risk, not the API. July 2023 predates Laravel 11. Verify illuminate/* constraints in a throwaway branch against your target Laravel and PHP 8.2+ before planning anything. If they're stale, you are forking on day one — decide that consciously rather than discovering it mid-sprint.
Verify deny-by-default yourself. Anything parsing request parameters into Eloquent constraints is injection-adjacent. Two questions answered by reading source, not assumption: (a) does a missing allowlist entry fail open or closed, and (b) can an allowlisted include walk to a relation you didn't intend to expose? Test both by hand.
Includes are amplification, not just extra rows. ?include=orders.items.product is cheap to type and expensive to run. If the package doesn't cap include depth or count, cap it in your allowlist and confirm every includable FK is indexed.
Parsers wobble on empty values and coercion. filter[status]=, filter[id]=0, filter[active]=false, filter[created_at]=2024-02-30. Common failure modes: a truthy filter returning everything, or a silent all-rows result a client reads as "no matches." One test per edge case per resource; at least one will surprise you.
Debug the query, not the client. When a filter "doesn't work," the query is nearly always not what the client thinks it sent. Log SQL plus bindings for the failing request — it collapses most tickets to a single line. Keep a ?debug=sql toggle or a Telescope tab handy.
Pin exactly, and name an owner. "bjerke/api-query-builder": "1.2.*" — never ^1.0. Record the owning engineer in an ADR. When the next Laravel major lands and the package hasn't moved, someone must already know the compat PR is theirs, not upstream's.
Expect no plugin ecosystem. 26 stars, one maintainer, no dependents, no Stack Overflow footprint. Extension points are what you see in the source. Realistic options are wrapper scopes (cheap) or a fork (correct once you're running it on more than a resource or two).
Don't migrate off spatie/laravel-query-builder for this. Net-negative risk swap with no capability upside. The legitimate use of bjerke is as a readable reference implementation — you can vendor the parse-and-allowlist pattern into your own code and drop the dependency entirely.
Default to the incumbent for anything contractual. Internal admin tooling, prototypes, dashboards: this earns its keep. A documented external API contract: use Spatie, which has current Laravel support, an active maintainer, and a community to escalate to. Choose bjerke only when the stated reason is "we want a minimal implementation we own" — and you've budgeted the ownership that implies.
How can I help you explore Laravel packages today?