Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Purity Laravel Package

abbasudo/laravel-purity

Laravel Purity is an elegant filtering and sorting package for Laravel Eloquent. Add filter() to any query and let frontend users filter and sort results through simple URL query string parameters, with no extra boilerplate.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm the surface area.

composer require abbasudo/laravel-purity
php artisan vendor:publish   # pick the Purity config tag from the list

There is nothing to migrate and no service you must register by hand on modern Laravel; the provider is auto-discovered. On Laravel 11/12/13 confirm the constraint resolved cleanly — the package tracks framework releases closely (v3.3.8 added Laravel 12, v3.3.9 added Laravel 13), so pin a version you have tested rather than floating.

Read these three things first, in this order:

  1. The docs site (abbasudo.github.io/laravel-purity) — the README is a teaser only.
  2. The generated config/purity.php — the query-string keys (filters, sorts), default sorting, and silent-mode defaults live here. This file is the contract with your frontend.
  3. The Filters/ directory in vendor/abbasudo/laravel-purity/src — the operator list is defined by class names, and reading them is faster than trusting the docs.

First use case — a resource index endpoint.

Route::get('/api/posts', fn () => Post::query()
    ->filter(['title', 'status', 'created_at'])
    ->sort(['title', 'created_at'])
    ->paginate(25));
GET /api/posts?filters[title][$contains]=purity&filters[status][$eq]=published&sorts=-created_at

That is the entire pitch: no if ($request->has('title')) ladder in the controller. The moment it works, the second task is to move the allowlist out of the controller into a dedicated filter class (php artisan make:filter PostFilter if the package ships the stub, otherwise hand-write a class extending the package's base Filter) and attach it:

Post::query()->filter(PostFilter::class)->sort(PostSort::class)->paginate();

Do this on day one, not later. Inline allowlists rot fast once three endpoints share a model.

Implementation Patterns

Allowlist at the class level, not the call site. The class form is where $fields, $rename, $ignore, $default, and $silent live. Renaming is the pattern that keeps your public grammar clean while your schema evolves:

class PostFilter extends Filter
{
    public array $fields = ['title', 'status', 'created_at', 'author.name'];
    public array $rename = ['created_at' => 'published_on', 'author.name' => 'author'];
}

The frontend now sends ?filters[published_on][$gte]=2025-01-01 and never learns your column names. Treat $rename as your public API versioning tool.

Operators you will actually use. Unqualified filters default to equality. The working set: $eq, $ne, $lt, $lte, $gt, $gte, $contains, $startsWith, $endsWith, $like, $notLike, $in, $notIn, $between, $notBetween, $null, $notNull, plus $and/$or for grouping multiple conditions on the same field. $contains compiles per-driver — that is the portable choice for "search box" behaviour; reach for raw $like only when you want the driver's native case semantics.

Filtering relations. Dot notation (filters[author.name][$eq]=abbas) compiles to an existence constraint, so you get posts whose author matches, not posts with a partially-loaded author. The relation method must declare a return type (BelongsTo, HasMany, …) or the package cannot resolve it — this is documented and it is a common source of "it silently does nothing".

Sorting. ?sorts=title,-created_at — comma-separated, - prefix for descending. Multi-column sorting is the reason to prefer this over a single sort key; make sure the frontend always sends the array form so you don't support two grammars.

Livewire / Inertia / form-driven UIs. Don't scrape the URL. Pass the payload explicitly and keep the same filter class:

Post::query()->filter(PostFilter::class, $this->filters)->sort(PostSort::class, $this->sorts);

This is the same code path as HTTP, so the allowlist is enforced identically — which is exactly what you want when a Livewire component starts accepting property values from the browser.

Wrap it in a shared scope or base controller. Once two or three endpoints do this, add a scopeListable-style helper or a small IndexRequest that maps validated request keys onto the filter payload. Keep Purity as the translator; keep your own layer as the policy.

Debugging workflow. dd(request()->query()), then ->filter(...)->toSql() and ->getBindings(). Ninety percent of "why is nothing filtered" is a key mismatch between the frontend's filters and the config's filter key, or an allowlist entry that doesn't match the renamed public name.

Gotchas and Tips

Query string only. Purity reads from the request query string, deliberately (v2.1 hardened this so it ignores body/file input). A JSON:API or GraphQL client POSTing {"filters": {...}} in the body will get unfiltered results. Either put filters in the URL or pass the array explicitly.

silent decides between a 500 and a silent no-op. With silent off (the default in most setups), an unknown field or bad operator throws — which is good in tests and terrible in production if unhandled. Choose deliberately: silent mode on for public search endpoints, off plus an exception handler that returns 422 for internal/admin APIs. Do not leave it to chance; the behaviour changes what your frontend must tolerate.

LIKE semantics differ by driver. $contains on MySQL (default CI collation) and PostgreSQL (LIKE is case-sensitive) behave differently; the package has iterated on this several times (v3.3.4, v3.3.8). If case-insensitive search is a product requirement, verify on your actual driver, or use a driver-specific custom filter. Same story for null ordering — nulls-last sorting was added and then made opt-in (v3.1.0), so check the config before promising deterministic ordering to a designer.

Column names are only safe when allowlisted. Unrestricted filter() lets a client name any column, including ones you didn't intend to expose (and qualified names like users.password were explicitly enabled back in v2.1 — useful for joins, dangerous as a default). In production, always pass an array or a filter class. Never ship Post::filter() bare on a public route.

Nested boolean filters need grouping. $and/$or had grouping bugs fixed in v3.0.1 and v2.0.1; deep nesting is the least-tested surface. Keep client-side filter trees shallow (one level of $or), and write an integration test that asserts counts, not just SQL strings.

Pagination order-of-operations. filter()->sort()->paginate() — not the reverse. If you paginate() first, the count query is built before sort/filter and you get wrong totals. Also note that filter() applied after with() constrains the parent query; it does not prune the eager-loaded relation data.

Composability is fine, but check yours. Purity mutates the builder you hand it, so it composes with policies, global scopes, and soft deletes without special handling. It does not know about your tenancy: if you rely on filters[tenant_id], someone will find it. Add a $ignore entry or exclude it from $fields and rely on a global scope instead.

Caching keys must include the query string. Cache::remember('posts:'.request()->getQueryString(), ...) — an easy miss that returns one user's filtered list to the next.

Performance. $contains compiles to LIKE '%term%', which is unindexable. Fine at 10k rows, painful at 1M. For search boxes on large tables, register a custom filter that hits a full-text index instead, and keep Purity for the cheap equality/range work where it shines.

Extension points to remember when you outgrow it: custom filters (implement the package's filter interface with an operator name and an apply method, then register the class), custom sort classes, and swapping the resolver class entirely (v3.3.0) if you need to change operator dispatch or error handling globally. Prefer these over forking — the package releases frequently and a fork will drift.

Test the grammar, not the package. Your suite should assert GET /api/posts?filters[...] returns the right rows, and that ?filters[password][$contains]= is rejected. Those two tests are the entire risk surface of adopting this dependency.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode