ismailnakkar / laravel-listing
Easy, typed querying and filtering for Blade list pages: declare filters and sorts once, and no query string ever bounces the page.
Requires
- php: ^8.4
- laravel/framework: ^13.0
Requires (Dev)
- larastan/larastan: ^3.12
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Easy querying and filtering for Blade list pages. Declare, once, in the controller, the filters and sorts a page's query string may set. The package reads them leniently, so a mistyped id, a stale bookmark or a hostile URL shows the default list, never a 4xx or 5xx.
Requires PHP 8.4 and Laravel 13.
Install
composer require ismailnakkar/laravel-listing
That is the whole install: no service provider, no config, no views.
A listing
use App\Enums\OrderStatus; use App\Models\Order; use Illuminate\Http\Request; use Listing\Listing; final class OrderController { public function index(Request $request) { $orders = Order::query() ->join('customers', 'customers.id', '=', 'orders.customer_id') ->select('orders.*', 'customers.name as customer_name'); $listing = Listing::for($orders, $request) ->enum('status', OrderStatus::class) ->flag('paid') ->search('q', ['orders.reference', 'customers.email']) ->date('from', 'orders.created_at', '>=') ->date('to', 'orders.created_at', '<=') ->sorts('reference', 'total', 'created_at', customer: 'customer_name') ->defaultSort('-created_at'); return view('orders.index', ['orders' => $listing->paginate(), 'listing' => $listing]); } }
?status=shipped&paid=0&q=lamp&from=2026-10-01&sort=-total&page=2 is the second page of unpaid, shipped orders
matching "lamp" since October 1st, largest first. paginate() takes a page size, else the model's $perPage. Its
links keep the query string. A ?page that is not a positive whole number, or so large that its offset would overflow,
shows the first page; a page past the last one is empty, as in Laravel. For large tables, simplePaginate() and
cursorPaginate() skip the COUNT; see Paging.
Filters
| Method | Reads ?key= as |
Keeps the rows where |
|---|---|---|
id($key = 'id', $column = null, $max = PHP_INT_MAX) |
a positive int up to $max |
the column is it |
text($key, $column = null, $max = 255) |
the trimmed string; empty is null |
the column is it |
flag($key, $column = null) |
'1' as true, '0' as false |
the column is it |
enum($key, Enum::class, $column = null) |
the backed case; an int-backed enum reads digits only | the column is it |
enums($key, Enum::class, $column = null) |
a list of backed cases: ?key[]=a&key[]=b, or one ?key=a |
the column is any of them |
ids($key, $column = null, $max = PHP_INT_MAX) |
a list of positive ints up to $max |
the column is any of them |
search($key, $columns, $max = 255) |
the trimmed string; empty is null |
any of the columns contains it, as typed |
date($key, $column = null, $operator = '=') |
a real day, 2026-10-01 |
the column falls on it (=), on or after it (>=), or on or before it (<=) |
- A value that does not read as its type is
nulland filters nothing, so the empty fields a form sends filter nothing either.enums()andids()hold a list instead: values that do not read are dropped, none is an empty list, which filters nothing, and more than 1000 values read as an empty list. - The column defaults to the key, as a column of the listed model's table, like a positional sort:
enum('status')on orders comparesorders.status, so it stays unambiguous on a join. A column you pass is used as written: name a joined or pivot column in full (customers.email,team_user.role). flag()reads0asfalseon purpose: "No" is an answer, not an absent filter. A checkbox needsvalue="1"; without it the browser sendson, which reads asnull.- Each key is declared once; declaring it again throws.
- Only the URL's query string is read: a form or JSON body never sets a filter, a sort or a page.
Search
search() keeps the rows where any of the columns contains the term as typed: %, _, ! and [ match
themselves.
- Letter case does not matter as far as the database lowercases. SQLite and a PostgreSQL
Clocale lowercase ASCII letters only, and MySQL's and MariaDB's default collation ignores accents too. - PostgreSQL casts each column to text, so a number, date or uuid column can be searched.
- SQL Server turns a legacy
datetimecolumn, whichtimestamps()creates there, into text such asOct 1 2026 12:00AM, so a2026-10-01term misses. Filter days withdate(), notsearch().
Date ranges
->date('from', 'orders.created_at', '>=') ->date('to', 'orders.created_at', '<=')
Both days count, whole. ?from=2026-10-01&to=2026-10-31 keeps created_at >= '2026-10-01' and < '2026-11-01', so
all of the 31st is in, and an index on the column serves the range. A day before 1753-01-01, where SQL Server's
datetime starts, reads as null.
From and to are days on the column's own clock:
- For
dateanddateTimecolumns, andtimestampoutside MySQL and MariaDB, that isapp.timezone, the clock Eloquent writes in. - MySQL's and MariaDB's
timestampand PostgreSQL'stimestamptzstart a day at midnight in the connection's time zone, so keepdatabase.connections.*.timezoneequal toapp.timezone. A named zone on MySQL needs its time zone tables loaded. - SQL Server's
timestampTz(datetimeoffset) compares instants in UTC. Eloquent's own rows still fall on theirapp.timezoneday; a row written elsewhere with a real offset falls on its UTC day.
Sorts
sorts() names what ?sort= may use: ?sort=total is ascending, ?sort=-total descending.
- A positional name is a column of the listed model's table.
- A named argument gives a select alias or another column a name of its own:
customer: 'customer_name',orders: 'orders_count'. Name a joined column by its select alias, as here:cursorPaginate()reads each row's sort value by that name, so a joined column named in full (customers.name) pages wrong there. - Every call adds names, so a sort can depend on the user:
->when($user->isAdmin(), fn (Listing $l) => $l->sorts('margin')).
How the order is chosen:
- Anything else in
?sort=givesdefaultSort(), or else the model's key, descending. - The sort replaces any order the query had, so a relation's own
latest()cannot outrank the header. - Ties then order by the model's key in the same direction, so rows with equal values keep their place from one page to the next.
Keep the list narrow: each column needs an index, or the page scans on every click. On PostgreSQL the index has to cover the column and the key.
A relation
Listing::for() takes a relation as well as a query, and pages it as itself, so each row keeps its pivot:
$listing = Listing::for($team->members(), $request)->text('role', 'team_user.role')->sorts('name');
A filter the package does not cover
Pass a closure in place of the column. It runs only when there is a value, and gets the query and the typed value:
| Filter | Value the closure gets |
|---|---|
id() |
an int |
flag() |
a bool |
enum() |
the case |
enums() |
a non-empty list of cases |
ids() |
a non-empty list of ints |
text() and date() |
a string |
use Illuminate\Database\Eloquent\Builder; ->id('team', fn (Builder $query, int $team) => $query->whereHas('teams', fn (Builder $q) => $q->whereKey($team)))
On a relation, the closure's query is the relation's own, so name a pivot column in full there too. A closure that parses its value further must not hand the database an impossible value.
The view
<form method="get"> <input type="search" name="q" value="{{ $listing->values['q'] }}"> <select name="status"> <option value="">Any status</option> @foreach (\App\Enums\OrderStatus::cases() as $status) <option value="{{ $status->value }}" @selected($listing->values['status'] === $status)>{{ $status->name }}</option> @endforeach </select> <select name="paid"> <option value="">Paid: any</option> <option value="1" @selected($listing->values['paid'] === true)>Paid: yes</option> <option value="0" @selected($listing->values['paid'] === false)>Paid: no</option> </select> <input type="date" name="from" value="{{ $listing->values['from'] }}"> <input type="date" name="to" value="{{ $listing->values['to'] }}"> <input type="hidden" name="sort" value="{{ $listing->sort }}"> <button>Filter</button> </form>
Refill the form from $listing->values, never from request(): {{ request('q') }} fails the page on ?q[]=x.
Every declared key is there, holding its typed value or null, so compare with ===: an enum() holds the case, a
flag() a bool. The hidden sort keeps the order when the form is sent.
For enums() or ids(), the field sends a list, from a multiple select or checkboxes named status[], and the value
is a list, so the refill tests membership:
<select name="status[]" multiple> @foreach (\App\Enums\OrderStatus::cases() as $status) <option value="{{ $status->value }}" @selected(in_array($status, $listing->values['status'], true))>{{ $status->name }}</option> @endforeach </select>
A sortable header:
<th @if ($listing->ariaSort('total')) aria-sort="{{ $listing->ariaSort('total') }}" @endif> <a href="{{ $listing->sortUrl('total') }}">Total</a> </th>
sortUrl()flips the sorted column and starts any other descending. It keeps the query string and dropspageandcursor.sortUrl()throws for a namesorts()does not have, so a header for a sort that only some users have needs the same condition in the view.ariaSort()isnullon a column the list is not sorted by.
To make each header one line, save this as resources/views/components/sort-header.blade.php:
@props(['listing', 'column']) <th @if ($listing->ariaSort($column)) aria-sort="{{ $listing->ariaSort($column) }}" @endif {{ $attributes }}> <a href="{{ $listing->sortUrl($column) }}">{{ $slot }}</a> </th>
<x-sort-header :listing="$listing" column="total">Total</x-sort-header>
Paging
| Method | Gives | Use it for |
|---|---|---|
paginate($perPage = null) |
a page with a total and page links | most screens |
simplePaginate($perPage = null) |
previous and next only, with no COUNT query | a table too large to count |
cursorPaginate($perPage = null) |
previous and next by cursor, with no COUNT and no OFFSET | a very large table |
Each takes a page size, else the model's $perPage, and its links keep the query string.
?pagereads like an id. One that is not a positive whole number, or so large its offset would overflow, shows the first page. Laravel's own resolver takes?page=9223372036854775807, a 500 on PHP 8.5.?cursorcomes from the query string like every other parameter. Laravel throws for a cursor from another sort order, such as a bookmark from before a deploy that changed the order, and for one holding a null. PostgreSQL and SQL Server also reject a hand-edited value the column cannot hold.cursorPaginate()shows the first page for each; an error that is not the cursor's throws again from the retry.- NULL sort values. As in Laravel, a page that ends on a NULL sort value links back to the first page, and in the
other direction the NULL rows never show. Give a cursor screen NOT NULL sort columns;
timestamps()columns are nullable. - A joined column pages by its select alias (see Sorts).
- A statement timeout goes around the call: set it, call
cursorPaginate(), then restore it.
Without paging
apply() is the filtered, sorted query without paging:
- an export, with
->lazy() - a count, a sum or a second query on the same filters
A relation stays a relation. Page through the three methods above rather than apply(): they carry the page cap and
the cursor guard.
Reusing a listing
final class OrderController { public function index(Request $request) { $listing = self::listing($request); return view('orders.index', ['orders' => $listing->paginate(), 'listing' => $listing]); } public function export(Request $request) { return OrdersExport::of(self::listing($request)->apply()->lazy()); } /** @return Listing<Order> */ private static function listing(Request $request): Listing { return Listing::for(Order::query(), $request)->enum('status', OrderStatus::class)->sorts('total'); } }
Authorization
The listing only reads the query string. Who may see the page is the route's business: put Laravel 13's
#[Authorize('viewAny', Order::class)] on the controller or action, or ->can('viewAny', Order::class) on the route.
Traps
- Refill the form from
$listing->values, never fromrequest(). - Do not read list input with Laravel's
enum(),date(),boolean(),string()orinteger()on query strings:- Some throw:
enum()on an int-backed enum,date()on garbage,string()on an array. - Others misread:
boolean('on')istrue, andinteger('12abc')is12.
- Some throw:
sort,pageandcursorare the listing's query-string names.- A misspelled bare column. MySQL, MariaDB, PostgreSQL and SQL Server reject it. SQLite reads an unknown bare
name as a string, so there it matches nothing, or every row in a search, with no error. Test on the database you
deploy, or name the column in full (
orders.status), which SQLite does check.
Engine notes
- PostgreSQL raises an error, instead of matching nothing, when a value does not fit the column's type:
- Give
id()andids()on aninteger(notbigInteger) columnmax: 2147483647; oneids()value out of range fails the whole list. - Filter a
uuidcolumn with a closure that checksStr::isUuid()first.
- Give
- GROUP BY or DISTINCT listings fail on PostgreSQL and MySQL, because the key tiebreak orders by a column that is neither grouped nor selected.
- SQL Server. Use
pdo_sqlsrv. Throughpdo_dblib, SQL Server turns bound text into code page 1252 (so%becomes%) and returns NULL instead of raising conversion errors. Beyond the driver:- The legacy
smalldatetimetype (1900-01-01 to 2079-06-06) is not supported bydate(); use a closure. - A legacy
datetimestores a write at23:59:59.999as the next day's midnight; usetimestamps(precision: 3)where that matters.
- The legacy
- MySQL and MariaDB: a legacy
utf8mb3column fails on an emoji search; useutf8mb4.
Testing
composer test # phpunit, on in-memory SQLite composer check # pint --test, phpstan, then phpunit
The same suite runs on another engine through Laravel's own variables, for example PostgreSQL:
docker run -d --name listing-pg -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=listing -p 127.0.0.1:5432:5432 postgres:18 DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=listing DB_USERNAME=postgres DB_PASSWORD=secret vendor/bin/phpunit
CI runs SQLite on PHP 8.4 and 8.5, and PostgreSQL 18 and MySQL 8.4. Before a release, also run the suite against:
- MariaDB 11.8 (
DB_CONNECTION=mariadb) - SQL Server 2022 (
DB_CONNECTION=sqlsrv) from a PHP withpdo_sqlsrv; throughpdo_dblib, conversion errors vanish
Then do a mutation pass on SQLite and PostgreSQL: revert each guard, and some test must fail.
Deliberately not here
Query DSLs, multi-column sort and JSON APIs. For an API, use spatie/laravel-query-builder. A list page that needs
more reads $listing->values and writes it.