stepovenko/eloquent-filter-sort-pagination
stepovenko/eloquent-filter-sort-pagination provides Laravel developers with reusable Eloquent helpers for filtering, sorting, and paginating query results. It aims to simplify request-driven list endpoints and reduce repetitive query boilerplate.
Install and confirm what you actually got. Version 1.0.9 is a patch release whose notes are only a diff link (1.0.8...1.0.9), so there is no documented feature or breaking change to design around. Treat this as a maintenance bump: pull it in, run your test suite, and diff the source between the two tags if you depend on internals.
composer require stepovenko/eloquent-filter-sort-pagination
The package name describes the whole contract: it turns request query parameters into Eloquent filter, orderBy, and paginate calls. There is no prior assessment and no published usage data for this package, so start from the repository README rather than from assumption.
Where to look first, in order:
composer.json → extra.laravel.providers / extra.laravel.aliases. If the provider is not auto-discovered you must register it in bootstrap/providers.php (Laravel 11+) or config/app.php.config/ in the package, published via php artisan vendor:publish. This is usually where the allowed filter keys, default sort, and per_page cap live.?filter[name]=x, ?filter[name][like]=x, or ?name=x.HasFilterSortPagination-style trait or a query-builder macro).First use case — an index endpoint with server-side table state:
public function index(IndexUsersRequest $request)
{
return UserResource::collection(
User::query()->filterSortPaginate($request)
);
}
Verify the happy path end-to-end (filter applied, sort direction honoured, meta.total correct) before layering anything else on.
Always gate behind a FormRequest. The single highest-value pattern with any query-parameter-driven package is validating the payload before it reaches the query builder. Whitelist both the keys and the operators, not just the values:
class IndexUsersRequest extends FormRequest
{
public function rules(): array
{
return [
'filter' => ['array'],
'filter.name' => ['nullable', 'string', 'max:255'],
'filter.status' => ['nullable', Rule::in(['active', 'invited'])],
'sort' => ['nullable', 'string', 'max:64'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
];
}
}
If the package exposes an allow-list in config, populate it from the same source of truth as the validation rules so the two cannot drift.
Keep the controller thin, push business filters down. For anything beyond a column equality — date ranges, scope-based filters, permission-aware filtering — prefer a named filter class or a local Eloquent scope over stringly-typed query params. That keeps the logic testable and stops the parameter grammar from becoming a public API you can never change.
Layer authorization before pagination. Filters that hide rows the user may not see (tenant ID, soft-deleted records, draft state) must be applied to the base query, not appended after the package does its work — otherwise total and last_page leak row counts across tenants.
Pair with API Resources. Return ResourceCollection so pagination metadata (current_page, last_page, total, per_page) is serialised consistently and the frontend gets a stable contract independent of the package's internal paginator shape.
Test the query, not the JSON. Assert with assertDatabaseCount plus explicit result-set comparisons for filter and sort permutations. A handful of data-provider-driven tests over the filter grammar will catch far more regressions than snapshotting responses. Re-run them on this 1.0.9 bump specifically — that is the entire value of the check.
For exports and reports, reuse the same filtered/sorted query but swap paginate() for cursor()/chunk() so a 10k-row CSV export doesn't allocate 10k models.
Unvalidated query params are the primary risk. Any package that reads request()->all() into a query builder can be coaxed into ordering by an unindexed or non-existent column, or into a LIKE '%…%' full scan. Confirm the package whitelists columns itself; if it does not, whitelist in the FormRequest and, where possible, intersect against Schema::getColumnListing().
Empty and default-valued parameters. Frontends frequently send ?filter[name]=&sort=&per_page= — empty strings rather than absent keys. Decide explicitly whether your validation normalises these away (ConvertEmptyStringsToNull middleware helps) or whether the package treats '' as a real filter. This is the most common "it works locally, breaks in the SPA" bug.
Sorting by a relationship. sort=company.name requires a join or a subquery. Most packages of this shape do not do that for you, and a naive implementation produces ambiguous-column SQL errors or N+1 queries. Handle relationship sorts explicitly with a leftJoin + qualified column, or a correlated subquery for orderBy.
total on complex queries. paginate() runs a separate count(*). Joins that fan out rows, or groupBy + having, will inflate or break the total. Reach for simplePaginate() when you don't need the total, and test the count assertion separately from the page-content assertion.
Parameter naming collisions. sort and page and per_page sit in the global request namespace. If your form or a third-party JS component also posts page, you will silently get the wrong page. Namespace aggressively if the package allows a configurable prefix.
Debugging technique. When a filter "does nothing", stop the query instead of guessing:
$q = User::query()->filterSortPaginate($request);
dd($q->toSql(), $q->getBindings());
If the bindings array is empty, the package never saw the parameter — that's a request/validation problem, not a query problem. If bindings are present but results are wrong, it's the grammar. Splitting the diagnosis that way saves most of the debugging time.
Watch the per_page ceiling. Without a hard cap a client can request per_page=100000 and take down the app. Enforce max:100 in validation and clamp in the package config if it offers one.
Extension points to prefer over forking. Local model scopes, a custom filter class registered with the package, and a thin repository/service layer around the call site. Version 1.0.9 is a patch on a 1.x line, so minor-version upgrades are likely safe — but pinning to ^1.0 and avoiding reflection into package internals keeps you off the fork treadmill.
On this release specifically: no changelog detail exists beyond the compare link, so verify behaviour empirically. Run your filter/sort/pagination tests against 1.0.8 and 1.0.9, and skim git log 1.0.8..1.0.9 for changes to the query-building class. A patch bump touching SQL generation is worth five minutes of diligence.
How can I help you explore Laravel packages today?