aw-studio / laravel-model-index
Filterable, sortable, searchable index listing for laravel models
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.19
- orchestra/testbench: ^9.9|^10.0|^11.0
- pestphp/pest: ^3.7|^4.0|^5.0
- spatie/laravel-ray: ^1.39
This package is auto-updated.
Last update: 2026-08-20 14:35:05 UTC
README
Filterable, sortable, searchable and paginated index endpoints for Eloquent models, driven entirely by query parameters.
return Product::index() ->filterable(['name', 'price', 'category_id']) ->sortable(['name', 'price', 'created_at']) ->searchable(['name', 'sku']) ->maxPerPage(100) ->get();
GET /api/products?filter[price][$lte]=100&filter[name][$containsi]=shirt&sort=-created_at&page=2&perPage=25
Contents
- Requirements
- Installation
- Return values
- Filtering
- Sorting
- Searching
- Pagination
- Builder reference
- Securing a public endpoint
- Frontend client
- Testing
Requirements
| Supported | |
|---|---|
| PHP | 8.2+ (Laravel 13 itself requires 8.3+) |
| Laravel | 11.x, 12.x, 13.x |
CI covers PHP 8.2/8.3/8.4 against Laravel 12, and 8.3/8.4 against Laravel 13. Laravel 11 is still allowed by the version constraint but is not exercised in CI, because every 11.x release is currently blocked by Composer's security-advisory policy and cannot be installed at all.
Installation
composer require aw-studio/laravel-model-index
Optionally publish the config:
php artisan vendor:publish --tag=model-index-config
// config/model-index.php return [ 'per_page' => 10, 'max_per_page' => 100, // null to disable the ceiling 'sortable' => [], // ['*'] to allow every column 'searchable' => [], // ['*'] to search every column ];
Add the trait to any model you want an index endpoint for:
use AwStudio\ModelIndex\Traits\HasIndexQuery; class Product extends Model { use HasIndexQuery; }
That gives you a static index() returning an IndexQueryBuilder, which reads the
current request:
public function index() { return Product::index() ->filterable(['name', 'price']) ->get(); }
Return values
The builder deliberately returns different things depending on the request, so it is worth knowing which you get:
| Call | Returns |
|---|---|
get() |
Illuminate\Support\Collection, always |
paginate($perPage = null) |
LengthAwarePaginator, always |
cursorPaginate($perPage = null) |
CursorPaginator, always |
first() |
Model or null |
count() |
int |
Return types do not depend on the request. Whether an endpoint paginates is the
endpoint's decision; which page and how large is the caller's. get() still
applies filters, sorting and search — it just ignores page and perPage.
Wrap results in an API Resource with useResource():
Product::index()->useResource(ProductResource::class)->get();
Filtering
Filtering is deny-by-default. Nothing is filterable until you say so, either per call or on the model.
Product::index()->filterable(['name', 'price'])->get();
GET /products?filter[name]=Shirt GET /products?filter[price]=100
Filtering by a field that is not allowed throws an InvalidArgumentException.
Comma-separated values become an IN filter:
GET /products?filter[name]=Shirt,Hoodie
Operators
Operators are nested under the field:
GET /products?filter[price][$gte]=18&filter[stock][$gt]=0
| Operator | Meaning |
|---|---|
$eq |
Equal |
$eqi |
Equal, case-insensitive |
$ne |
Not equal |
$nei |
Not equal, case-insensitive |
$gt |
Greater than |
$gte |
Greater than or equal |
$lt |
Less than |
$lte |
Less than or equal |
$in |
In list |
$notIn |
Not in list |
$contains |
Contains |
$notContains |
Does not contain |
$containsi |
Contains, case-insensitive |
$notContainsi |
Does not contain, case-insensitive |
$between |
Between two bounds |
$startsWith |
Starts with |
$endsWith |
Ends with |
$null |
Is null |
$notNull |
Is not null |
An operator outside this list throws an InvalidArgumentException naming the operator
and the field. It is never silently treated as equality.
Note
Case-insensitive operators. The i-suffixed operators compile to
LOWER(column) <op> LOWER(?), which is portable across MySQL, PostgreSQL and SQLite.
MySQL's default collation (utf8mb4_unicode_ci) already ignores case, so on MySQL
they behave identically to their plain counterparts — the difference only shows up on
PostgreSQL and SQLite. Do not "verify" case-sensitivity against MySQL alone.
$between takes exactly two bounds, comma-separated or as an array:
GET /products?filter[price][$between]=10,99 GET /orders?filter[created_at][$between][0]=2026-01-01&filter[created_at][$between][1]=2026-12-31
Bounds are passed through without casting, so date ranges work as well as numeric ones. Anything other than exactly two bounds throws.
Several operators may be combined on one field. Each becomes its own condition, ANDed together:
GET /products?filter[price][$gte]=18&filter[price][$lte]=30 # where price >= 18 and price <= 30
Note
Conditions take the boolean of the group they sit in, so two operators inside
a single $or branch are ORed, not ANDed — $or[0][age][$gte]=18&$or[0][age][$lte]=30
means age >= 18 or age <= 30. Use $between for a range inside an $or.
Logical operators
$and and $or groups can be nested:
GET /products?filter[$or][0][name][$contains]=Shirt&filter[$or][1][name][$contains]=Hoodie
// equivalent to: where name like %Shirt% or name like %Hoodie%
Custom filters
Filter keys do not have to be columns. Register a callback to map a key onto a scope, a relation, or anything else:
public function index() { // GET /users?filter[popular]=true return User::index() ->filter('popular', fn ($query, $value) => $query->popular()) ->get(); } // GET /posts?filter[user.name]=John return Post::index() ->filter('user.name', function ($query, $value) { $query->whereHas('user', fn ($q) => $q->where('name', $value)); }) ->get();
Custom filter keys bypass the filterable() allowlist — registering the callback is
the opt-in.
Filtering on relations
Dot notation filters through an Eloquent relation, resolved with whereHas:
Post::index()->filterable(['user.name', 'user.email'])->get();
GET /posts?filter[user.name]=John GET /posts?filter[user.name][$containsi]=joh
Nested relations work too (user.company.name). Relation fields go through the
same allowlist as ordinary columns, so they must be listed in filterable().
A dotted field is only treated as a relation when its first segment is an actual
relation on the model — so if you join manually and filter on table.column,
that still resolves as a qualified column reference.
Configuring filterable fields on the model
To reuse the same configuration everywhere, put it on the model instead. A method takes precedence over a property:
class Product extends Model { use HasIndexQuery; public function filterable(): array { return ['name', 'price', 'category_id']; } // or: public $filterable = ['name', 'price']; }
Sorting
GET /products?sort=name # ascending GET /products?sort=-name # descending GET /products?sort=name:desc # descending, alternative syntax GET /products?sort=name,-price # multiple columns
Sorting is deny-by-default. Declare what an index may be ordered by:
Product::index()->sortable(['name', 'price', 'created_at'])->get();
Sorting by a field outside the allowlist throws an InvalidArgumentException.
Ordering by a column the client was never meant to see is an inference oracle: sort
by a secret, observe the row order, learn about the secret. To opt out of the safe
default application-wide, set sortable to ['*'] in config/model-index.php.
Custom sort keys work like custom filters, and receive the direction:
Product::index() ->sort('popularity', fn ($query, $direction) => $query->orderBy('sales_count', $direction)) ->get();
Searching
GET /products?search=shirt
Searching is deny-by-default. Declare what an index may be searched on:
User::index()->searchable(['name', 'email'])->get();
Related columns use dot notation and are matched with orWhereHas:
Post::index()->searchable(['title', 'user.name'])->get();
Custom search callbacks are merged into the searchable set:
Product::index() ->search('sku_prefix', fn ($query, $term) => $query->orWhere('sku', 'like', "{$term}%")) ->get();
The ['*'] wildcard
Setting searchable to ['*'] — per index, or as the config default — expands to
every column in the table. Attributes in the model's $hidden array are always
excluded, so password and remember_token are never matched, but anything
sensitive that is not hidden is. Prefer an explicit list.
Searching an index whose searchable set is empty throws an
InvalidArgumentException rather than silently returning the unfiltered list.
Registering a custom search callback is enough to make an index searchable, even
with an otherwise empty list.
Pagination
GET /products?page=2 GET /products?perPage=25
Default page size is 10. Rename the page parameter with pageName():
Product::index()->pageName('p')->get(); // GET /products?p=2
perPage is capped at 100 by default. Requests above the ceiling are clamped to
it, and a perPage that is not a positive integer (0, -5, abc) falls back to
the default page size. Override per index or in config:
Product::index()->maxPerPage(250)->paginate();
Set max_per_page to null to remove the ceiling entirely — but note that an
unbounded page size is both a denial-of-service lever and a bulk-extraction one.
Cursor pagination
For large lists, cursorPaginate() is keyset-based: it does not COUNT the full
result set and does not degrade on deep pages the way OFFSET does.
Product::index()->sortable(['created_at'])->cursorPaginate();
GET /products?perPage=25 GET /products?perPage=25&cursor=eyJpZCI6MjUsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0
The trade-off is no total and no last_page, and no jumping to an arbitrary
page number — so it suits infinite scroll rather than a numbered pager. Rename
the cursor parameter with cursorName().
Builder reference
| Method | Purpose |
|---|---|
filterable(array) |
Allowlist of filterable fields |
filter(string, callable) |
Register a custom filter key |
sortable(array) |
Allowlist of sortable fields |
sort(string, callable) |
Register a custom sort key |
IndexQueryBuilder::defaultSortable(array) |
Application-wide sort default |
searchable(array) |
Columns searched by ?search= |
search(string, callable) |
Register a custom search key |
maxPerPage(int) |
Ceiling for ?perPage= |
pageName(string) |
Rename the page query parameter |
cursorName(string) |
Rename the cursor query parameter |
useResource(string) |
Wrap results in an API Resource |
tap(callable) |
Run a callback against the builder |
query() |
Get the underlying Eloquent builder |
applyRequestQuery() |
Apply filter/sort/search without fetching |
get() / paginate() / cursorPaginate() / first() / count() |
Execute |
Any other method is proxied to the underlying Eloquent builder, so you can mix in ordinary query methods:
Product::index()->with('category')->where('active', true)->get();
Note that the proxy always returns the IndexQueryBuilder, discarding the return value
of the proxied call.
Securing a public endpoint
All three allowlists deny by default, and page size is capped:
| Concern | Default | |
|---|---|---|
| Filtering | [] — deny all |
✅ |
| Sorting | [] — deny all |
✅ |
| Searching | [] — deny all |
✅ |
perPage |
capped at 100 | ✅ |
So an index exposes nothing until you say what it exposes:
Model::index() ->filterable(['name', 'status']) ->sortable(['name', 'created_at']) ->searchable(['name', 'sku']) ->paginate();
Each list can be relaxed globally in config/model-index.php if you would rather
opt out than opt in — but the shipped defaults are the safe ones.
Frontend client
@aw-studio/nuxt-laravel provides
useLaravelIndex, which serializes exactly the query-string shape documented here.
| This package | Client |
|---|---|
1.x |
@aw-studio/nuxt-laravel 1.x |
The two are released as a pair. Because the coupling is an untyped query string, mixing majors is not supported.
The two packages are coupled only by the query string — there is no generated client and no shared schema. An operator that exists on one side but not the other fails at runtime, so the operator table above and the one in the client's README must be changed together.
Testing
composer test # Pest, via Testbench on SQLite vendor/bin/pest tests/Feature/FilterIndexTest.php # single file vendor/bin/pest --filter "case-insensitive" # single test vendor/bin/pint # code style
License
This project is licensed under the MIT License. See the LICENSE file for details.