Download the PHP package ismailnakkar/laravel-listing without Composer
On this page you can find all versions of the php package ismailnakkar/laravel-listing. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download ismailnakkar/laravel-listing
More information about ismailnakkar/laravel-listing
Files in ismailnakkar/laravel-listing
Package laravel-listing
Short Description Easy, typed querying and filtering for Blade list pages: declare filters and sorts once, and no query string ever bounces the page.
License MIT
Homepage https://github.com/ismailnakkar/laravel-listing
Informations about the package laravel-listing
laravel-listing
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
That is the whole install: no service provider, no config, no views.
A 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 |
|---|---|---|
int($key, $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 |
ints($key, $column = null, $max = PHP_INT_MAX) |
a list of positive ints up to $max |
the column is any of them |
search($key, $columns = null, $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()andints()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 follows the column's collation on MySQL, MariaDB and SQL Server: their default collations ignore
case (MySQL's and MariaDB's accents too), and a
_binor_cscolumn matches case exactly. PostgreSQL usesILIKE, ASCII letters only under aClocale, and SQLite ignores ASCII case. - 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
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.
The current request
Pass Listing::for() the current request: paginator links are built from the container's request.
A relation
Listing::for() takes a relation as well as a query, and pages it as itself, so each row keeps its pivot:
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 |
|---|---|
int() |
an int |
flag() |
a bool |
enum() |
the case |
enums() |
a non-empty list of cases |
ints() |
a non-empty list of ints |
text() and date() |
a string |
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
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 ints(), the field sends a list, from a multiple select or checkboxes named status[], and the value
is a list, so the refill tests membership:
A sortable header:
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:
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.- Every other paginator. Call
Listing::protectAllPages()once, in a service provider'sboot(), and Laravel's ownpaginate()andsimplePaginate()read?pagethe same way, from the query string only, for page sizes up to 10,000. ?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. On MySQL and MariaDB 12 or later, start the query with Laravel's
->timeout($seconds): the listing keeps it. Other databases ignore that call, so set their statement timeout aroundcursorPaginate().
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
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.- On a form request, give the request no
rules(): a failing rule redirects, so a URL the listing would read as absent bounces. To build the listing once, keep it in a declared property (private ?Listing $listing = null;, thenreturn $this->listing ??= Listing::for($query, $this)->…;): an undeclared$this->listingreads?listing=from the request. - 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
int()andints()on aninteger(notbigInteger) columnmax: 2147483647; oneints()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
The same suite runs on another engine through Laravel's own variables, for example PostgreSQL:
CI runs SQLite on PHP 8.4 and 8.5, and PostgreSQL 18, MariaDB 11.8 and MySQL 8.4. Before a release, also run the suite against:
- 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.
Upgrading
From 0.4
search()no longer wraps its columns inlower(). On MySQL, MariaDB and SQL Server a_binor_cscolumn now matches letter case exactly; PostgreSQL usesILIKE. Nothing else changes.
From 0.3
Listing::id()andids()are nowint()andints().
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.
Licence
MIT.