sehrgut/eloquery
eloquery is a small Laravel package that adds convenient helpers for building Eloquent queries, aimed at reducing boilerplate when filtering, sorting, and composing database queries in your models.
Read this first: sehrgut/eloquery is archived, has 4 stars and zero dependents. Treat it as a small reference implementation of the "query string → Eloquent builder" pattern rather than infrastructure you build a product on. If you are choosing today, spatie/laravel-query-builder is the maintained equivalent with the same mental model. Use eloquery if you inherited it, if you need to read its source to copy the idea, or if you're evaluating it for a legacy codebase.
Minimal steps:
composer require sehrgut/eloquery. Because it's archived, expect the illuminate/* constraint to lag your Laravel version. --ignore-platform-reqs will get it installed and then blow up at runtime. If it doesn't resolve cleanly, fork it or vendor the src/ directory into app/Support/Eloquery — the package is small enough that this is a realistic option and removes the supply-chain risk entirely.README.md → config/eloquery.php (this is where the query-parameter names and per-model allowlists live) → the single entry-point class in src/. The whole package is a few hundred lines; you can read all of it in one sitting, which is the main thing it has going for it.php artisan vendor:publish --tag=eloquery (check the README for the exact tag in your pinned version).// routes/api.php
Route::get('/posts', [PostController::class, 'index']);
// PostController
public function index(Request $request)
{
$query = Post::query();
// Entry point — confirm the exact class/method name in the README
// for the version you pinned; this is the one API you must get right.
$query = Eloquery::apply($query, $request->query());
return PostResource::collection($query->paginate());
}
Target request:
GET /posts?filter[status]=published
&filter[author.id]=3
&sort=-created_at
&include=author,tags
&paginate=25
&search=laravel
First use case: one read-only index endpoint on a model with one or two relations. Do not roll this out across the whole API before you've seen how it handles a relation filter and an include — those are the two places this class of package always has sharp edges.
Centralize the call in a base controller or a form request. Every endpoint should go through the same one-liner so there's a single place to add auth checks and parameter clamping later:
abstract class ApiController extends Controller
{
protected function query(string $model, Request $request): Builder
{
return Eloquery::apply($model::query(), $request->query());
}
}
Whitelist, don't trust. The config's per-model allowlists are the security boundary. Enumerate permitted filters, sorts and includes explicitly per model. If the package falls back to "anything goes" when a model has no entry, add the entry. This is the single highest-value configuration work you'll do with it.
Filter on relations with dot notation, but verify the join strategy. filter[author.id]=3 typically becomes a whereHas or a join depending on the implementation. Confirm which, because it changes your indexes: whereHas on a large table without an index on the foreign key will be the slowest thing in your API.
Keep scopes and authorization in front of the package, not behind it.
$query = Post::query()->where('tenant_id', $request->user()->tenant_id);
$query = Eloquery::apply($query, $request->query());
// Includes are a data-exposure surface — gate them.
$this->authorize('viewAny', Post::class);
Add custom filters as classes if the package supports a filter registry. Anything non-trivial (filter[created_after]=2024-01-01, a filter[status][in][]=... array) belongs in a dedicated class rather than inline controller code, so it's unit-testable in isolation.
Test through HTTP, not through the builder. The contract is the query string, so test the query string:
$this->getJson('/api/posts?filter[status]=draft&sort=-id')
->assertOk()
->assertJsonPath('data.0.status', 'draft');
Include a negative test for every allowlist: ?filter[password]=x must be ignored, not 500.
Pagination defaults. Set the default page size in config and clamp the client-supplied paginate value at the controller boundary — do not rely on the package to enforce a maximum.
spatie/laravel-query-builder — the query-string grammar is close enough that the migration is mostly mechanical.include is a DoS vector. ?include=posts.comments.author.posts.comments will happily produce a query storm. Restrict includes to a whitelist and cap the depth in config.sort=-created_at against a query that joins posts and authors produces Column 'created_at' in order clause is ambiguous. Qualify sortable columns as posts.created_at in the allowlist, or the package's sort implementation will fail only on the endpoints that use joins — i.e. in production, not in your tests.- to desc and anything else to asc, rather than interpolating the raw value. If it interpolates, you have SQL injection through orderByRaw. Grep the source for orderByRaw and DB::raw before shipping.paginate() envelope (data + meta) or a custom resource envelope. Retrofitting this later is a breaking change for every client.search behaves differently on MySQL vs Postgres vs SQLite. If the package uses LIKE '%term%', it cannot use an index — fine at 10k rows, a problem at 1M. Plan to replace search with Scout or Postgres tsvector when you outgrow it.->toSql() and ->getBindings() right after the package call, not after paginate() (which rewrites the query for the count). For the count query specifically, DB::listen() is easier than trying to reconstruct it.filter to where to match an existing API contract rather than changing your frontend. Beyond that, custom filter classes (if present) are the intended extension point; subclassing the builder is a last resort because it will not survive a fork.How can I help you explore Laravel packages today?