bogddan/laravel-tablekit

TableKit for Laravel

Maintainers

Package info

github.com/bogddan/laravel-tablekit

pkg:composer/bogddan/laravel-tablekit

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.12.0 2026-07-27 16:46 UTC

This package is auto-updated.

Last update: 2026-07-27 16:48:12 UTC


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

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.

$sortable is 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 parent on the same table) are handled by aliasing the parent side as parent_<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=addressaddressSortable()), 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 010; 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 nameBuilder::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=xxSortable()), so:

  • the column must be declared before a name is derived from it. Otherwise any public method ending in Sortable was callable from the query string — ?sort=is would reach an unrelated isSortable() 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 scope will not dispatch to a custom sorter, because ?sort=scope would otherwise resolve to this trait's own scopeSortable() 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 &nbsp; 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.