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.
Architecture fit. The package sits in a clean seam that already exists in every Laravel API: model allowlist declarations plus a chainable call in the controller, composing on top of Illuminate\Database\Eloquent\Builder rather than replacing it. That is the strongest architectural property here — it does not introduce a new query language, a schema layer, or a runtime the team has to learn. Tenancy scoping, soft deletes, policies, and pagination stay ours; the package only contributes client-driven constraints. For a TPM, the relevant framing is that this is a thin convention over a pattern we already write by hand, not a platform commitment.
Integration feasibility. Feasible in an afternoon for a single endpoint: composer require, publish config, declare three arrays on a model, chain one method. The blocker is not effort, it is the absence of a spec. There is effectively no documentation, one maintainer, 26 stars, and zero dependents — the published config and the source are the specification. Two facts must be established from source before any planning is credible: (a) whether a model with no declared allowlist fails open or closed, and (b) whether an allowlisted include can traverse into relations we never intended to expose. Those are not implementation details; they determine whether the package is safe to point at production data at all.
Technical risk. Three risks, ranked:
illuminate/* constraints are stale, we are forking on day one — a decision that must be made consciously, with budget, rather than discovered mid-sprint.?filter[password]=1 is rejected or ignored, and never let the grammar become permissive-by-omission.There is no capability upside that justifies migrating off spatie/laravel-query-builder — that would be a net-negative risk swap. The legitimate cases are a greenfield minimal implementation we intend to own, or cribbing the parse-and-allowlist pattern into our own code.
Key questions to close before commitment.
illuminate/* constraints resolve on our target Laravel and PHP 8.2+? (Throwaway branch, composer update --dry-run.)filter[status]=, filter[id]=0, filter[active]=false, invalid dates? A truthy filter returning all rows is a client-visible correctness bug.Stack fit. Good, with a caveat about scope. It fits any Laravel list endpoint that needs column filters, a single sort, and eager-loaded relations — admin tables, audit logs, dashboards, internal search. It does not fit surfaces needing relationship filters, sparse fieldsets, cursor pagination, or strict default-deny semantics. Match the endpoint class to the package's actual capability before promising a roadmap date.
Migration path. There is no migration — this is additive greenfield instrumentation on endpoints that today hand-roll filtering. Do not refactor existing endpoints onto it. The concrete path:
?sort=-created_at orders newest first, ?filter[status]=active filters, ?filter[password]=1 is rejected/ignored, ?include=orders eager-loads with no N+1 confirmed via DB::listen or Telescope. If the third fails, stop.Queryable trait so they are audited in one place rather than drifting per call site.Compatibility. Verify, don't assume. Pin exactly ("1.2.*", never ^1.0) and record the pin in an ADR with a named owner. If the constraints are stale on day one, the decision becomes "vendor and own" — which is a legitimate choice, but it must be scoped and staffed as such, not framed as a dependency addition.
Sequencing. Compatibility spike → allowlist/include security verification → pilot endpoint behind a feature flag → documented grammar (docs/api/querying.md, per-endpoint supported filters, sorts, includes) → second endpoint → decide on fork vs. wrapper scopes. The documentation step is not optional polish: with no upstream docs, the grammar we publish is the API contract, and client teams integrate against our file, not the package.
Two ordering rules to bake into review checklists: authorization scoping (whereBelongsTo($tenant)) must precede apiQuery($request) — order is a security property, not style — and pagination must have a deterministic tiebreaker (orderByDesc('created_at')->orderByDesc('id')), because the package will not add one and unstable page boundaries surface only under load.
Rollback lever. Each endpoint's adoption sits behind a config flag. The response to "sort=name is suddenly ordering by email" is a flip, not a hotfix.
Maintenance. The realistic operating model is vendored code we maintain, not a dependency that receives upstream fixes. Budget accordingly: a named owner, an ADR, a pinned version, and an explicit trigger for forking. The codebase is small and readable, which caps the cost — but "small and readable" is not the same as "zero." The moment the first non-trivial filter requirement lands (filter[created_after], filter[q]=acme, filter[status][in]=a,b), the correct move is a hand-written scope appended after the builder, not bending the parser. Three such escapes in a sprint is the fork signal.
Support. Support load shifts from "write filter logic" to "explain the grammar" plus "debug why a filter returned unexpected rows." The second category is cheap to resolve if we instrument it: log SQL plus bindings for failing requests, keep a ?debug=sql toggle or Telescope tab. The first is cheap only if the grammar is documented — which is on us. Expect no community leverage; there is no Stack Overflow footprint to escalate into. Weigh this against the alternative: bespoke per-endpoint parameters produce more integration tickets and slower partner onboarding, so a documented uniform grammar is a net support-load reduction if the docs exist.
Scaling. Two dimensions:
?include=orders.items.product is trivial to type and expensive to run. Cap include depth and count in the allowlist, and confirm every includable foreign key is indexed. This is ours to enforce; the package will not do it for us.Failure modes to plan for.
| Failure | Symptom | Mitigation |
|---|---|---|
| Allowlist permissive by omission | Disallowed column filterable in production | Verify from source pre-merge; toSql() test asserting password never appears |
| Include traversal | Relation exposed that was never intended | Manual traversal test; cap depth/count |
| Unstable pagination | Duplicate/missing rows under load | Explicit deterministic tiebreaker |
| Parser coercion | filter[active]=false returns everything |
One edge-case test per resource per empty/coerced value |
| Ordering regression | Mis-ordered orWhere widens past tenant scope |
Scope-before-build in review checklist |
| Stale dependency | Breaks on next Laravel major | Exact pin, named owner, ADR |
Ramp-up. Low for engineers who already know Eloquent — one trait, one chained call, three arrays. The real ramp-up cost is organizational: the team must internalize deny-by-default as our responsibility, not the library's, and adopt SQL-level assertions (toSql(), not just JSON) as the test standard for every exposed resource. Run the allowlist matrix — allowed filter, disallowed filter, bad sort, disallowed include — per resource.
Recommendation. A bounded pilot on one internal list endpoint, behind a flag, with a named owner, a pinned version, a written compatibility answer, and documented grammar. Upside is measured in saved engineering weeks per surface; downside is capped at reverting a flag. Default to spatie/laravel-query-builder for anything public, customer-facing, or contractually governed — zero dependents and a stale cadence are continuity risks we should not place on an external contract.
How can I help you explore Laravel packages today?