patienceman/filtan
Filtan is a Laravel package for adding filters to Eloquent queries, letting you define reusable filter classes and apply them to models or requests. It aims to keep query filtering clean and organized, though documentation is limited.
Filtan is a small, single-purpose package: it gives you a base filter class and a trait so that a controller can translate a request's query string into a constrained Eloquent builder without a pile of if ($request->has(...)) blocks.
composer require patienceman/filtan
php artisan vendor:publish --tag=filtan-config # only if a config file exists
Add the package's filterable trait to the models you want to expose filtering on. Most builds of packages like this use something like use HasFiltering; (check the trait name in the README — it is a one-line import, so confirming from the source is faster than guessing).
Create your first filter class. Filtan follows the now-conventional "filter object" shape: a class with a whitelist of allowed query params, each mapped to a method that mutates $this->builder.
namespace App\Filters;
use Patienceman\Filtan\Filter;
class UserFilter extends Filter
{
protected array $filters = ['name', 'email', 'status', 'created_after'];
protected function name(string $value): void
{
$this->builder->where('name', 'like', "%{$value}%");
}
protected function status(string $value): void
{
$this->builder->where('status', $value);
}
protected function createdAfter(string $value): void
{
$this->builder->where('created_at', '>=', $value);
}
}
public function index(Request $request)
{
return User::filter(new UserFilter($request))
->paginate(25);
}
The first use case worth wiring up is an admin index page: GET /users?name=jo&status=active&sort=-created_at. That single endpoint exercises whitelisting, LIKE matching, exact matching, and a date bound — enough to validate the package's ergonomics before you commit to it across the app.
Where to look first: the package's Filter base class source, not the README. With two stars and a single release (Nov 2024), the README will be thinner than the code, and the code is short enough to read in one sitting.
One filter class per resource, not per endpoint. UserFilter should serve both GET /users and any embedded listing (e.g. "users on this team"). Keep it model-scoped and let the controller add the relationship constraint:
$team->users()->filter(new UserFilter($request))->paginate();
Whitelist, then delegate. The $filters array is your security boundary. Anything not listed must be silently ignored — treat the query string as fully untrusted. If the package's naming convention is snake_case param → camelCase method, created_after maps to createdAfter(), which is the pattern used by Eloquent Filter and Spatie's QueryBuilder; verify against the source, because a mismatch here fails silently.
Compose rather than branch. For filters that only apply under certain conditions, return early rather than nesting logic:
protected function status(string $value): void
{
if (! in_array($value, ['active', 'invited', 'suspended'], true)) {
return; // ignore junk rather than 422 on a read endpoint
}
$this->builder->where('status', $value);
}
Relationship filters belong in the filter class, using whereHas, because that is where the query-shape knowledge lives:
protected function team(string $value): void
{
$this->builder->whereHas('teams', fn ($q) => $q->where('slug', $value));
}
Sorting is the usual companion. Filtan handles constraints; don't overload it. Either add an explicit sort filter with a hardcoded map (never orderBy($request->sort)), or pair it with a tiny dedicated sortable. The whitelist-map version:
protected function sort(string $value): void
{
$columns = ['name' => 'name', 'created_at' => 'created_at'];
$dir = str_starts_with($value, '-') ? 'desc' : 'asc';
$col = ltrim($value, '-');
if (isset($columns[$col])) {
$this->builder->orderBy($columns[$col], $dir);
}
}
Push validation into a FormRequest when you want 422s instead of silent ignores. The filter class stays about queries; the request class stays about shape:
class UserIndexRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in(['active', 'invited', 'suspended'])],
'created_after' => ['nullable', 'date'],
];
}
}
Eager-load in the controller, always. Filtan (like every filter package) only touches where/join — it will happily hide an N+1 that you then serialise:
User::with('teams')->filter(new UserFilter($request))->paginate(25);
Test filters directly against a builder, not through HTTP. A filter class is a pure function of (request params, builder), which makes it the cheapest thing in the app to test:
it('filters by status', function () {
User::factory()->create(['status' => 'active']);
User::factory()->create(['status' => 'invited']);
$request = Request::create('/users', 'GET', ['status' => 'active']);
$results = User::filter(new UserFilter($request))->get();
expect($results)->toHaveCount(1)
->and($results->first()->status)->toBe('active');
});
Read the source before you build on it. Two stars, zero dependents, one release in late 2024 — this is a solo-maintained package. Read src/ end to end (it will be a handful of files). The API surface you need to confirm: the trait name, whether the filter receives the Request or a plain array, and the param→method naming convention. Getting any of those wrong fails quietly — your filters just never fire.
Silent no-ops are the number-one debugging trap. If ?status=active appears to do nothing, the cause is almost always a whitelist/naming mismatch, not a broken query. Prove it by dumping the SQL from inside the filter method:
protected function status(string $value): void
{
$this->builder->where('status', $value);
ray($this->builder->toSql(), $this->builder->getBindings());
}
If your dump never prints, the method isn't being reached — fix the mapping, not the query.
Handle empty strings explicitly. ?name= yields '', and LIKE '%%' returns everything while looking like it "worked". Guard with if ($value === '') { return; } at the top of fuzzy-match filters. Same for ?status=null — PHP will hand you the string "null", not null.
Never interpolate a column name from the request. The sort example above is safe only because of the $columns map. $this->builder->orderBy($request->input('sort')) is an injection vector in Laravel, not a theoretical one. This applies to select, groupBy, and raw whereRaw fragments too.
Dates from the query string are strings. where('created_at', '>=', '2024-01-01') works by accident in MySQL and can behave differently under stricter modes or on Postgres. Wrap with Carbon::parse($value)->startOfDay() inside the filter so the filter class owns the semantics.
Booleans get stringified. ?active=false is the truthy string "false" in PHP. Use filter_var($value, FILTER_VALIDATE_BOOLEAN) or a Rule::in(['0','1']) before touching a boolean column.
Wrap it in your own base class. Because the package is unmaintained-looking, give yourself an exit ramp on day one:
namespace App\Filters;
use Patienceman\Filtan\Filter as BaseFilter;
abstract class Filter extends BaseFilter
{
// one place to swap implementations later
}
If the package stalls, you reimplement apply() against this base and delete one composer require. Ten minutes of work now versus a refactor across every filter class later.
Pin the version. "patienceman/filtan": "^1.0" with a committed composer.lock. With one release and no dependents, there is no community pressure testing upgrades for you.
Extension point to reach for: the constructor. If you need the authenticated user's tenant scope applied to every filtered query without repeating it in each filter method, do it once in your base class constructor or a boot() hook rather than in twenty filter classes. Related filters and global scopes compose fine — Filtan only adds where clauses to a builder you already control.
Don't expect pagination, sorting, includes, or field selection to come from this package. It is a filter whitelist and a builder mutator. If you need the fuller feature set (sparse fieldsets, ?include=, per-relation filters), the maturity gap versus Eloquent Filter or Spatie's QueryBuilder is real — evaluate those before adopting Filtan for a large surface area, and adopt Filtan where the filtering needs are genuinely simple.
How can I help you explore Laravel packages today?