bogddan / laravel-tablekit
TableKit for Laravel
Requires
- php: ^8.5
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.7
- pestphp/pest-plugin-laravel: ^4.1
- rector/rector: ^2.3.9
- roave/security-advisories: dev-latest
README
TableKit for Laravel — sortable table columns for server-rendered Laravel dashboards.
Add a trait to your model, a directive to your Blade table headers, and clicking a header sorts the query. No Livewire, no Alpine, no build step, no JavaScript at all — the sort state lives in the query string and the page re-renders on the server.
Requires PHP 8.5+ and Laravel 12 or 13.
<th>@sortablelink('name', 'Name')</th> <th>@sortablelink('created_at', 'Registered')</th>
- Install
- Usage
- Default sorting
- Sorting through relations
- Custom sorting logic
- Aliases,
withCount()and computed columns - Icons
- Configuration
- Security
- Error handling
- Under the hood
- Coming from Kyslik/column-sortable
- Credits
Install
composer require bogddan/laravel-tablekit
The service provider is auto-discovered. Publish the config if you want to change anything:
php artisan vendor:publish --tag=tablekit-config
Usage
The model
Use the trait and declare which columns may be sorted:
use Bogddan\Tablekit\Sortable; class User extends Model { use Sortable; public array $sortable = ['id', 'name', 'email', 'created_at']; }
$sortable is a whitelist, and it is the only thing that makes a column sortable. A
column that is not listed is ignored, so ?sort= cannot reach columns you did not intend
to expose.
$sortableis required. A model that does not declare it sorts by nothing at all. There is no fallback to schema introspection — see Nothing is sortable by default.
The controller
sortable() is a query scope, so it composes with everything else:
public function index(User $user) { $users = $user->sortable()->paginate(25); return view('users.index', compact('users')); }
The view
<table> <thead> <tr> <th>@sortablelink('id', 'ID')</th> <th>@sortablelink('name')</th> <th>@sortablelink('created_at', 'Registered')</th> </tr> </thead> <tbody> @foreach ($users as $user) <tr><td>{{ $user->id }}</td><td>{{ $user->name }}</td><td>{{ $user->created_at }}</td></tr> @endforeach </tbody> </table> {{ $users->withQueryString()->links() }}
The directive takes up to four arguments:
@sortablelink('column', 'Title', ['extra' => 'query-param'], ['class' => 'header-link'])
| # | Argument | Purpose |
|---|---|---|
| 1 | column |
Column to sort by. Required. Use relation.column to sort through a relation. |
| 2 | title |
Anchor text. Defaults to the column name. Accepts an Htmlable. |
| 3 | array |
Extra query parameters to add to the link. |
| 4 | array |
Extra attributes for the <a> tag. A href key overrides the target path, and must be a local path. |
Attribute names must be plain HTML attribute names and may not begin with on; a href
carrying a scheme or a host is refused. Both raise InvalidArgumentException — see
Links point at your own application.
Generated links preserve the rest of the current query string and reset page, so
sorting keeps your filters and search intact but sends the visitor back to page one.
Blade needs whitespace before a directive at the start of a tag:
<th> @sortablelink(...).
Default sorting
Pass a default to apply when the URL carries no sort:
$user->sortable('name')->paginate(); // name asc $user->sortable(['name'])->paginate(); // name asc $user->sortable(['name' => 'desc'])->paginate(); // name desc
Or sort by the first entry of $sortable automatically:
// config/tablekit.php 'default_first_column' => true,
An applied default renders as the active column in the header links, exactly as if it had come from the URL. It does not modify the request to achieve this.
Sorting through relations
Three things are needed: the relation itself, $sortableRelations naming it as traversable,
and $sortable on the related model naming the column.
class User extends Model { use Sortable; public array $sortable = ['id', 'name']; /** Relations this model may be sorted through. */ public array $sortableRelations = ['profile']; public function profile(): HasOne { return $this->hasOne(Profile::class); } } class Profile extends Model { public array $sortable = ['phone_number', 'address']; }
@sortablelink('profile.phone_number', 'Phone')
$sortableRelations is required and is not optional hardening. Resolving a relation
means calling the method named in the URL, so without it ?sort= reaches far past your
relations — see Relations are called, so they are
authorised.
Each model authorises its own next hop, the same way $sortable keeps each model in
charge of its own columns. To allow profile.country.name, Profile declares country:
class Profile extends Model { public array $sortable = ['phone_number']; public array $sortableRelations = ['country']; // not declared on User }
Note that this grant is transitive: once Profile allows country, any model that can
reach Profile can continue on to Country.
Chains work to any depth, bounded by max_relation_depth:
@sortablelink('profile.country.continent.name', 'Continent')
Each hop is joined (leftJoin by default) and the column is qualified with the deepest
table, so a name column existing on several tables is never ambiguous.
Only hasOne and belongsTo can be sorted through — a to-many relation would
multiply rows. For to-many data, use a withCount() alias or a custom sorter.
A relation must also be a real, public, zero-argument method with a declared Relation
return type. Relations registered dynamically with Model::resolveRelationUsing() have no
such method and cannot be sorted through.
Self-referencing relations (a
parenton the same table) are handled by aliasing the parent side asparent_<table>.
Custom sorting logic
Define a {column}Sortable() method to take over completely. The column still has to be
declared — a custom sorter does not exempt it from the whitelist:
class User extends Model { use Sortable; public array $sortable = ['name', 'address']; // "address" required, even though // no such column exists on users public function addressSortable(Builder $query, string $direction): Builder { return $query->join('profiles', 'users.id', '=', 'profiles.user_id') ->orderBy('address', $direction) ->select('users.*'); } }
@sortablelink('address')
The method must be public, and must not be named like a query scope — see Security. It is handed the builder to order, so it has to apply its clauses to that builder rather than to a copy of it.
The method name is derived from the column (
?sort=address→addressSortable()), which is why declaring the column first matters: it is what stops the query string choosing which method gets called.
Aliases, withCount() and computed columns
Aliases are declared in $sortableAs. That includes withCount() aggregates:
class User extends Model { use Sortable; public array $sortable = ['id', 'name']; public array $sortableAs = ['posts_count', 'nick_name']; }
$users = $user->withCount('posts')->sortable()->paginate(); $users = $user->select('name as nick_name')->sortable(['nick_name'])->paginate();
@sortablelink('posts_count', 'Posts')
Being present in the query's select list is not a declaration. An earlier version
scanned the select list and let anything aliased there be sorted by, which meant an
addSelect(['risk_score' => ...]) added for internal use became sortable as a side effect
of being computed. $sortable and $sortableAs are now the only two answers to "may this
be ordered by".
Aliases are ordered by unqualified; real columns are qualified with their table.
Icons
Icons are rendered as <i class="...">, with classes driven entirely by config. The
shipped defaults are Font Awesome class names, and columns are matched to an icon by type:
'columns' => [ 'alpha' => ['rows' => ['name', 'email'], 'class' => 'fa fa-sort-alpha'], 'numeric' => ['rows' => ['id', 'created_at'], 'class' => 'fa fa-sort-numeric'], ], 'asc_suffix' => '-asc', // Font Awesome 4; use '-up' for Font Awesome 5+ 'desc_suffix' => '-desc', // Font Awesome 4; use '-down' for Font Awesome 5+
For a chain like profile.country.name, the icon is chosen from the final segment
(name), not the relation.
Turn icons off entirely with 'enable_icons' => false and style the anchor yourself:
'anchor_class' => 'th-link', 'active_anchor_class' => 'th-link--active', 'direction_anchor_class_prefix' => 'th-link', // yields th-link-asc / th-link-desc
Inline SVG icons are planned and will become the default; the Font Awesome coupling is the last part of this package that assumes a specific CSS framework.
Configuration
| Key | Default | Purpose |
|---|---|---|
columns |
Font Awesome sets | Maps column names to icon classes by type. |
enable_icons |
true |
Render the trailing <i> tag at all. |
default_icon_set |
fa fa-sort |
Icon for columns not matched by columns. |
sortable_icon |
fa fa-sort |
Icon shown when a column is not the active sort. |
clickable_icon |
false |
Place the icon inside the anchor rather than after it. |
icon_text_separator |
' ' |
String between anchor text and icon. Escaped, so it is text and not markup. |
asc_suffix / desc_suffix |
-asc / -desc |
Appended to the icon class when active. |
anchor_class |
null |
Class on every generated anchor. |
active_anchor_class |
null |
Extra class on the active column's anchor. |
direction_anchor_class_prefix |
null |
Prefix for a direction-specific class. |
uri_relation_column_separator |
'.' |
Separator between relation and column. Ignored if it contains a word character. |
max_relation_depth |
4 |
Maximum relations one sort parameter may traverse. Clamped to 0–10; 0 disables relation sorting. |
formatting_function |
'ucfirst' |
Any callable applied to titles, or null. This value is called — treat it as code. |
format_custom_titles |
true |
Apply the above to explicit titles too. |
inject_title_as |
null |
Merge the sorted column's title into the query string. |
default_direction |
'asc' |
Direction when none is specified. |
default_direction_unsorted |
'asc' |
Direction an unsorted column links to. |
default_first_column |
false |
Sort by $sortable[0] when no sort is given. |
join_type |
'leftJoin' |
join, leftJoin or rightJoin for relation sorting. |
request_persist |
[] |
Query keys that may be carried into generated links. Empty means use the denylist below. |
request_persist_ignore |
see below | Query keys never carried into generated links. Only consulted while request_persist is empty. |
Security
Sort parameters are attacker-controlled, and the package treats every one of them as untrusted. Three properties carry most of the weight:
- Nothing is reachable unless a model names it. Columns come from
$sortable, aliases from$sortableAs, relations from$sortableRelations. Matching is exact — case, whitespace and JSON-path variants are all rejected. - Authorisation happens before dispatch. Both a relation name and a custom sorter's method name are derived from the URL, so both are checked against a declaration before anything is called.
- Directions are parsed to an enum, never interpolated, and every column reaching the database goes through Eloquent's identifier quoting.
Nothing is sortable by default
A model that declares no $sortable sorts by nothing. There is no schema discovery.
Earlier versions discovered the column list from the schema and filtered it with the
model's $hidden. That was the wrong control twice over. $hidden governs serialisation,
not authorisation, so internal flags, tenant identifiers and risk scores were all sortable
by default. And the filter was bypassable: Schema::hasColumn() matches case-insensitively
while the $hidden check was exact, so ?sort=Password walked straight past it on MySQL
and SQLite and ranked every row by the hidden column.
Declaring a column is now the only way to make it sortable, $hidden or not.
Relations are called, so they are authorised
This is the one worth understanding, because the failure mode is not subtle.
Eloquent resolves a relation by calling the method of that name —
Builder::getRelation() runs $model->newInstance()->$name(), and Model::__call
forwards anything it does not recognise to newQuery(). A request-derived relation name
therefore reaches every zero-argument method on the query builder. Before
$sortableRelations existed, ?sort=truncate.id emptied the table, ?sort=dd.id dumped
the SQL and bindings into the response, and ?sort=get.id loaded every row into memory.
So a relation is authorised before it is invoked, on two independent gates: it must be
listed in the owning model's $sortableRelations, and it must be a public, zero-argument
method declaring a Relation return type. Its type is checked again immediately after the
call. An undeclared relation and a nonexistent one produce the same error, so the message
is not an oracle for your schema.
Custom sorters
The method name comes from the column (?sort=x → xSortable()), so:
- the column must be declared before a name is derived from it. Otherwise any public
method ending in
Sortablewas callable from the query string —?sort=iswould reach an unrelatedisSortable()helper. - the method must be public.
method_exists()matches private methods too, and calling one is a fatal error rather than a catchable throw. - query scopes are refused. A column whose camelCase form starts with
scopewill not dispatch to a custom sorter, because?sort=scopewould otherwise resolve to this trait's ownscopeSortable()and recurse until the process died. Such a column is still sortable as an ordinary column.
Links point at your own application
A custom href must be a local path. Any scheme, any host, protocol-relative paths,
backslashes and control characters are refused with InvalidArgumentException.
Escaping an href stops it breaking out of the attribute; it does not make the destination safe. A generated link carries the request's whole persisted query state, so an off-origin target hands that state to someone else while still looking like an ordinary column header.
Anchor attribute names are validated for the same reason escaping is not enough:
htmlspecialchars() leaves spaces and = alone, so a name like x onmouseover renders as
two attributes. Names must be plain attribute names and may not begin with on.
Sorting is an inference channel
Worth stating plainly, because it is easy to miss: letting a visitor sort by a column
tells them about its values, even if the column is never rendered. Page through
?sort=salary and the row order ranks every salary, without a single value appearing on
screen.
So $sortable is not a list of columns you display — it is a list of orderings you are
willing to reveal. The same applies to relations: @sortablelink('profile.salary') exposes
the ordering of a column on another table.
The same reasoning applies to row-level authorization. This package orders whatever query
you hand it; scoping that query to what the current user may see is the application's job,
and it must happen before sortable().
Your responsibility
Sensitive query parameters. Every parameter on the current request is carried into
generated links, so that sorting preserves filters. URLs leak through Referer headers,
browser history, bookmarks and access logs, so anything sensitive must be excluded. The
shipped defaults cover the obvious cases:
'request_persist_ignore' => ['_token', '_method', 'password', 'password_confirmation'],
A denylist cannot anticipate parameter names specific to your application. If you have any, invert the rule — name what may travel, and nothing else will:
'request_persist' => ['status', 'search', 'per_page'],
Allowlist mode replaces the denylist rather than stacking with it, so a key named in
request_persist travels even if it also appears in request_persist_ignore. The keys a
link owns — sort, direction, page — are dropped either way.
Only the query string is read. Request bodies, uploaded files and route parameters never influence sorting and never reach a generated link.
formatting_function is called. is_callable('exec') is true, so treat this config
value as code, exactly like a Blade template. Never build it from env(), a database
column, or anything a user can influence.
Htmlable titles bypass escaping. That is the point of passing one, but it means
@sortablelink('name', new HtmlString($userInput)) is an XSS vector. Never build an
Htmlable title from user input.
Error handling
A sort that came from the query string never throws. Anything a visitor can put in the
URL — an unknown column, an unknown relation, a to-many relation, a malformed chain — is
ignored, and the query comes back unsorted. You do not need to wrap sortable() in a
try/catch:
$users = $user->with('profile')->sortable()->paginate();
A sort you wrote yourself does throw, because that is a bug worth seeing rather than a
table that silently ignores its default ordering. TablekitException is raised from a
default passed to sortable(), and from a @sortablelink argument:
| Constant | Code | Cause |
|---|---|---|
INVALID_ARGUMENT |
0 |
Unparseable sort parameter: an empty segment (a..b), or a chain deeper than max_relation_depth. |
RELATION_NOT_FOUND |
1 |
The relation is not declared in $sortableRelations, or does not exist. |
UNSUPPORTED_RELATION |
2 |
The relation is declared but is not a hasOne or belongsTo. |
InvalidArgumentException — distinct from the above, and never triggerable by a visitor —
signals a misconfiguration: a non-local href, an invalid anchor attribute name, or
inject_title_as set to sort, direction or page.
Under the hood
Three pieces:
| Class | Responsibility |
|---|---|
Sortable |
The sortable() scope: parses parameters, joins relations, applies orderBy. |
SortableLink |
Renders the anchor behind @sortablelink. |
TableState |
Owns the request's query-string state and builds link URLs. |
TableState is bound per request and is the single place that decides what a generated
URL looks like — which keys carry over, which are replaced, which reset. It is also how an
applied default sort is remembered without mutating the request.
Coming from Kyslik/column-sortable
The API is familiar, but this package refuses several things the original allowed. If you are porting a dashboard across, these are the changes that will bite:
| Change | What to do |
|---|---|
A model with no $sortable sorts by nothing; there is no schema discovery. |
Declare $sortable on every sortable model. |
| Relations must be declared. | Add $sortableRelations to every model a chain passes through. |
A custom {column}Sortable() no longer exempts its column from the whitelist. |
Add the column to $sortable. |
| Select aliases are no longer discovered from the query. | Declare them in $sortableAs, withCount() aggregates included. |
A custom href must be a local path. |
Drop the scheme and host: https://app.test/reports → /reports. |
icon_text_separator is escaped. |
An entity like now renders literally; use a CSS class for spacing. |
| Sorting is driven by the query string only. | Request bodies and route parameters no longer influence it. |
| A bad sort from the URL is ignored rather than raised. | Remove any try/catch around sortable() that existed only for that. |
| The request is never mutated. | inject_title_as writes to the generated URL, not to request(). |
Credits
A fork of Kyslik/column-sortable by Martin Kiesel, rewritten for PHP 8.5 and Laravel 12/13. MIT licensed; the original copyright is retained in LICENSE.