jurager/filterable
jurager/filterable adds reusable, declarative filtering to Laravel Eloquent models. Define allowed filters, apply them from request data, and keep complex query logic out of controllers for cleaner, safer APIs and admin listings.
composer require jurager/filterable; the service provider is auto-discovered on Laravel 9+.php artisan vendor:publish --tag=filterable. Read through config/filterable.php first — it defines the query keys (sort, search key, delimiter for relations, etc.) and is the single source of truth for everything below.Filterable trait to the model you want to expose, and declare an explicit whitelist of filterable columns. Never rely on the implicit "all columns" behaviour in production.use Jurager\Filterable\Traits\Filterable;
class Post extends Model
{
use Filterable;
protected array $filterable = ['id', 'title', 'status', 'created_at'];
protected array $sortable = ['id', 'created_at', 'title'];
}
public function index(Request $request)
{
return PostResource::collection(
Post::query()->filter($request->query())->paginate(25)
);
}
->toSql() and ->getBindings() before you trust the output; it is the fastest way to confirm which clauses the package actually generated.Whitelist per resource, not per model. If two endpoints expose the same model with different permissions, define a base model with the trait and extend it (or override the filterable/sortable accessors) so each controller gets its own surface area. This keeps "admin can filter by internal_notes" out of the public endpoint.
Normalise request input before it reaches the scope. Query strings are messy — trailing commas, empty values, arrays passed as scalars. A FormRequest with array/string rules and a prepareForValidation() that drops empty values makes the filter behaviour predictable and gives you a place to log rejected keys.
Relation filters are the high-value feature; use them deliberately. Filtering on comments.author_id (via the configured relation delimiter) compiles down to whereHas. Pair it with with() for the columns you actually render, otherwise you trade a correct query for an N+1 on serialisation. For heavy relations, prefer a pre-computed column or a dedicated scope over a nested relation filter.
Range filters belong on date and numeric columns. from/to style parameters map naturally to created_at and price ranges. Index those columns (created_at almost always, plus whatever your dashboards slice on) — the package builds the WHERE, it does not add indexes for you.
Compose with scopes rather than replacing them. filter() returns an Eloquent builder, so ordering is preserved: apply tenancy scopes (->forTenant($id)) before filter(), and let the filter layer only narrow within the already-authorised set. Treat filter parameters as untrusted selection, never as authorisation.
Sorting is a separate whitelist. Keep $sortable narrower than $filterable and always append a deterministic tiebreaker (->orderBy('id')) so paginated results don't shift between pages when the sort column has duplicates.
Encapsulate recurring filter sets. For dashboards reused across endpoints, extract a small PostFilters class that takes validated input and returns a builder, so the whitelist lives next to the tests rather than inside controllers.
Test at the query level, not the response level. Assert on $query->toSql()/bindings for the tricky cases (relation filters, ranges, multiple values) — response tests will pass even when a filter is silently ignored, which is the most common failure mode.
?statuss=published) returns unfiltered results. Log rejected keys in development, or assert the full filter set in feature tests.$filterable is an information leak. Filtering by a column reveals its values (binary search by id, email, is_admin). Whitelist by hand and review it in code review like you would a policy.whereHas cost. Deep relation chains generate correlated subqueries. Watch the query log on realistic data volumes and cap the relation depth you allow in the config.LIKE semantics differ per driver. MySQL's default collation makes LIKE case-insensitive, PostgreSQL's does not, and wildcards in user input are not escaped for you. Normalise case in the request or use the driver-appropriate operator if you need parity across environments.config/filterable.php, run php artisan config:clear locally and remember config:cache in production — a stale cached config makes parameter-name changes appear to do nothing.scopeFilter, a filter request key, or a package that owns ?sort=, resolve the conflict in config rather than monkey-patching the trait; the scope name and query keys are the intended extension points.?status= often means "no filter" to a user and "match empty string" to a query builder. Decide explicitly in prepareForValidation() and keep it consistent.where closures for the handful of filters that need real logic. Reading the trait source is worth ten minutes — it is small, and knowing exactly which builder methods it calls stops most "why is my filter ignored" debugging.^1.9 and skim the diff for 1.9.0/1.9.1 if you are upgrading from 1.8.x, since trait and config changes in minors are the realistic source of surprises here.How can I help you explore Laravel packages today?