ircmaxell/filterus
Filterus is a lightweight PHP library for filtering and validating data with composable, object-oriented filters. It handles strings, numbers, arrays, emails, URLs, regex, ranges, and defaults, with clear error reporting for safer input handling.
Filterus is a standalone coercion/validation library, not a Laravel package — there is no service provider, no config file, no Blade directives. You install it and call it directly.
composer require ircmaxell/filterus
Everything hangs off one static factory class: Filterus\Filter. Each call returns a filter object implementing Filterus\FilterInterface, which exposes two methods:
filter($value) — coerces and returns the cleaned value, or false on failure.validate($value) — boolean check, no coercion.Look first at:
src/Filterus/Filter.php — the factory methods and the fluent modifiers (chain, optional, default, min, max).src/Filterus/FilterInterface.php — the two-method contract you'll implement for custom filters.src/Filterus/Filter/ — concrete filters (IntFilter, StringFilter, MapFilter, ListFilter, RegexFilter, EmailFilter, etc.). Skim MapFilter and ListFilter first; they're the ones you'll compose.First use case — clean an incoming request payload into a typed array before it reaches a service or DTO:
use Filterus\Filter;
$schema = Filter::map([
'name' => Filter::string(1, 100),
'age' => Filter::int(0, 120),
'email' => Filter::email(),
'tags' => Filter::list(Filter::string(1, 30), 0, 10)->optional(),
]);
$clean = $schema->filter($request->all());
if ($clean === false) {
// at least one field failed; see Gotchas for how to find which
}
Note that filter() signals failure with false, not an exception — that shapes everything else about how you'll use this.
Schema maps as a DTO/coercion layer. The highest-value pattern is building one Filter::map() per endpoint or command and treating it as your input boundary. Laravel's validator confirms shape and produces messages; Filterus produces types. A common split is: FormRequest handles authorization + user-facing messages, then the action pipes $request->validated() through a Filterus map to coerce "3" into 3 and drop noise before constructing a value object.
final class CreateUserData
{
public static function fromArray(array $input): self
{
$clean = Filter::map([
'name' => Filter::string(1, 100),
'age' => Filter::int(18, 120)->optional(),
'admin' => Filter::boolean()->default(false),
])->filter($input);
if ($clean === false) {
throw new ValidationException('Invalid user payload');
}
return new self($clean['name'], $clean['age'] ?? null, $clean['admin']);
}
}
Chaining for layered rules. chain() applies filters in sequence, which lets you compose primitives instead of hunting for a bespoke filter:
$slug = Filter::string(1, 80)
->chain(Filter::regex('/^[a-z0-9-]+$/'));
Use this for "must be a string and match a format" without writing a custom class.
Lists vs. uniform arrays. Filter::list($filter, $min, $max) filters each element of an indexed array and returns a re-indexed list. Filter::array($min, $max, $elementFilter) is for array-shaped values where you also care about element filtering. Reach for list() for JSON arrays and array() when the count bounds matter more than keys.
Optional vs. default. They solve different problems: optional() permits the key/value to be absent, while default($v) substitutes a value when nothing is supplied. Use optional() for genuinely nullable fields and default() for flags and settings. Don't stack both expecting one behavior.
Unions with pool(). When an endpoint accepts one of several shapes (e.g. an ID that's either an int or a UUID string), Filter::pool([...]) tries candidates in order. Keep the candidate list short and ordered most-specific-first; a permissive Filter::string() at the front will swallow everything.
Custom filters as extension points. For domain formats (money, phone, SKU), implement FilterInterface (or extend the abstract base) and keep filter()/validate() pure and side-effect free:
final class SkuFilter implements \Filterus\FilterInterface
{
public function filter($value)
{
if (!is_string($value) || !preg_match('/^[A-Z]{3}-\d{4}$/', $value)) {
return false;
}
return $value;
}
public function validate($value): bool
{
return $this->filter($value) !== false;
}
}
Then register them in a small factory or container binding so controllers never new a filter inline.
Hoist schemas out of loops. Filter objects are cheap but not free; build the schema once per request (a property, a singleton binding, or a static in the DTO) rather than inside foreach over rows.
false is overloaded. filter() returns false for failure, but a valid boolean filter can legitimately return false, and an int coercion can return 0 (which is falsy). Never branch on if (!$result). Compare === false, and when ambiguity is possible, call validate() first, then filter() only on success."12abc" and 12.9 may both coerce into something you didn't intend. If you need strictness, chain a regex() or put Laravel's integer/string rules in front and let Filterus only normalize.map() returns a single false with no field-level detail. If you need messages, loop the schema yourself and run $filter->validate($input[$key] ?? null) per field to build an error bag. This is the biggest ergonomic gap versus Laravel's validator.$errors. Filterus is not a validation framework. Do not try to replace FormRequests with it; it has no concept of attribute names, translations, or HTTP status codes.chain(), optional(), and default() are easy to assume mutate in place. Always assign the return value ($f = Filter::int()->optional();) rather than relying on the original object changing — verify against your installed version's behavior.map(). Confirm what your version does with keys not present in the schema before you rely on it. Don't assume unknown input is silently dropped or passed through — assert it in a test, because either behavior can be a security-relevant surprise.optional() and default() interact with missing keys, not just nulls. A key present with null and a key absent can take different paths. Test both, especially for JSON request bodies where null is common./^...$/) and return false on no-match — same overloaded-false trap as above.composer.json and keep Filterus usage isolated behind your own DTO/factory layer so replacing it later (with filter_var, Laravel rules, or a modern serializer) is a one-file change.false, bisect it: Filter::map() over a single key at a time. Because there's no error reporting, isolating the failing branch is faster than reasoning about the whole map.How can I help you explore Laravel packages today?