matthenning/eloquent-api-filter
A Laravel package for turning request query parameters into Eloquent API filters, helping keep controllers clean and expose flexible, readable filtering for API endpoints with minimal configuration.
This is a small, single-purpose package: it turns query-string parameters into Eloquent where clauses so you do not hand-write a filter method for every index endpoint. Treat it as a thin convenience layer, not a full query-builder abstraction.
Minimal setup:
composer require matthenning/eloquent-api-filter
Then, in the model you want to expose:
use Matthenning\EloquentApiFilter\Traits\Filterable; // verify exact namespace in vendor/
class User extends Model
{
use Filterable;
}
And in the controller:
public function index(Request $request)
{
return User::query()
->filter($request->query())
->paginate();
}
The exact trait/scope names should be confirmed against the package README and vendor/matthenning/eloquent-api-filter/src before wiring it into a real endpoint. Where to look first:
README.md for the supported query-string syntax.src/ for the trait, service provider, and any config file.config/ after publishing, if the package exposes one:
php artisan vendor:publish --provider="Matthenning\EloquentApiFilter\ServiceProvider"
First practical use case: an internal admin table or prototype API where clients need ad-hoc filtering:
GET /api/users?filter[name]=Ada&filter[active]=1
GET /api/orders?filter[status]=paid&filter[created_at][gte]=2024-01-01
Start with one model and one endpoint. Confirm the generated SQL with ->toSql() before exposing it to clients.
Controller-level filtering with an allowlist. Do not pass the entire request blindly. Even if the package supports it, validate and constrain which columns can be filtered:
public function index(Request $request)
{
$request->validate([
'filter' => ['array'],
'filter.name' => ['string', 'max:255'],
'filter.active' => ['boolean'],
]);
return User::query()
->filter($request->only('filter'))
->paginate(25);
}
If the package supports per-model allowed filters, define them on the model rather than in every controller. That keeps the contract close to the schema.
Compose with existing query scopes. Apply authorization and tenancy scopes first, then filtering, then pagination:
return Order::query()
->whereBelongsTo($request->user()->team)
->filter($request->query('filter', []))
->latest()
->paginate();
This ordering matters: filtering should never be able to escape a tenant or policy scope.
FormRequest for filter validation. Put allowed filter keys and value types in a FilterUsersRequest. This is also the right place to normalize booleans and date strings before they reach the database.
Pair with sorting and pagination, not replace them. Keep sorting explicit and allowlisted:
$query = User::query()->filter($request->query('filter', []));
$query->when($request->sort, function ($q, $sort) {
$q->orderBy(in_array($sort, ['name', 'created_at']) ? $sort : 'created_at');
});
return $query->paginate();
Use it for read-only index endpoints. The package is designed around filtering. Keep writes, bulk exports, and reporting queries on explicit, typed query objects where you control the SQL shape.
Custom filter extension points. If the package exposes custom filter classes or a resolver callback, use those for domain-specific filters such as filter[search]=... or filter[overdue]=1, rather than stuffing raw column filters into the URL.
Feature-test the endpoint contract. Assert that a filter changes the result set and that a disallowed filter is ignored or rejected:
$this->getJson('/api/users?filter[active]=1')
->assertOk()
->assertJsonCount(3, 'data');
"0", "false", and "" are easy to mishandle. Cast dates, booleans, and integers explicitly before filtering.filter[created_at][gte] and filter[status][]=paid can arrive in unexpected shapes. Validate the array structure, not just the top-level filter key.distinct() or whereHas() if the generated SQL joins.filter scope, namespace collisions and double-filtering are likely. Pick one approach per endpoint.composer.json and avoid relying on undocumented behavior.->toSql() and ->getBindings() or Laravel Telescope to confirm what the package actually generates. Do not assume the query-string syntax maps one-to-one to where clauses.How can I help you explore Laravel packages today?