PHPackages                             bogddan/laravel-tablekit - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Templating &amp; Views](/categories/templating)
4. /
5. bogddan/laravel-tablekit

ActiveLibrary[Templating &amp; Views](/categories/templating)

bogddan/laravel-tablekit
========================

TableKit for Laravel

v0.12.0(today)03↑2900%MITPHPPHP ^8.5

Since Jul 27Pushed todayCompare

[ Source](https://github.com/bogddan/laravel-tablekit)[ Packagist](https://packagist.org/packages/bogddan/laravel-tablekit)[ RSS](/packages/bogddan-laravel-tablekit/feed)WikiDiscussions main Synced today

READMEChangelog (2)Dependencies (9)Versions (4)Used By (0)

TableKit - WIP
==============

[](#tablekit---wip)

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.**

```
@sortablelink('name', 'Name')
@sortablelink('created_at', 'Registered')
```

---

- [Install](#install)
- [Usage](#usage)
    - [The model](#the-model)
    - [The controller](#the-controller)
    - [The view](#the-view)
- [Default sorting](#default-sorting)
- [Sorting through relations](#sorting-through-relations)
- [Custom sorting logic](#custom-sorting-logic)
- [Aliases, `withCount()` and computed columns](#aliases-withcount-and-computed-columns)
- [Icons](#icons)
- [Configuration](#configuration)
- [Security](#security)
- [Error handling](#error-handling)
- [Under the hood](#under-the-hood)
- [Coming from Kyslik/column-sortable](#coming-from-kyslikcolumn-sortable)
- [Credits](#credits)

---

Install
-------

[](#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
-----

[](#usage)

### The model

[](#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](#nothing-is-sortable-by-default).

### The controller

[](#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

[](#the-view)

```

            @sortablelink('id', 'ID')
            @sortablelink('name')
            @sortablelink('created_at', 'Registered')

        @foreach ($users as $user)
            {{ $user->id }}{{ $user->name }}{{ $user->created_at }}
        @endforeach

{{ $users->withQueryString()->links() }}
```

The directive takes up to four arguments:

```
@sortablelink('column', 'Title', ['extra' => 'query-param'], ['class' => 'header-link'])
```

\#ArgumentPurpose1`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 `` 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](#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: ` @sortablelink(...)`.

Default sorting
---------------

[](#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
-------------------------

[](#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](#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_`.

Custom sorting logic
--------------------

[](#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](#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-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)

Icons are rendered as ``, 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
-------------

[](#configuration)

KeyDefaultPurpose`columns`Font Awesome setsMaps column names to icon classes by type.`enable_icons``true`Render the trailing `` 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 belowQuery keys never carried into generated links. Only consulted while `request_persist` is empty.Security
--------

[](#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

[](#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

[](#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

[](#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 `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

[](#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

[](#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

[](#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
--------------

[](#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:

ConstantCodeCause`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
--------------

[](#under-the-hood)

Three pieces:

ClassResponsibility`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
----------------------------------

[](#coming-from-kyslikcolumn-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:

ChangeWhat to doA 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
-------

[](#credits)

A fork of [Kyslik/column-sortable](https://github.com/Kyslik/column-sortable) by Martin Kiesel, rewritten for PHP 8.5 and Laravel 12/13. MIT licensed; the original copyright is retained in [LICENSE](LICENSE).

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance100

Actively maintained with recent releases

Popularity4

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity43

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

0d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/7372065?v=4)[Bogdan Ciobanu](/maintainers/bogddan)[@bogddan](https://github.com/bogddan)

---

Top Contributors

[![bogddan](https://avatars.githubusercontent.com/u/7372065?v=4)](https://github.com/bogddan "bogddan (7 commits)")

---

Tags

laravelbladesortablesorttablesortingcolumntablekit

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/bogddan-laravel-tablekit/health.svg)

```
[![Health](https://phpackages.com/badges/bogddan-laravel-tablekit/health.svg)](https://phpackages.com/packages/bogddan-laravel-tablekit)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M348](/packages/psalm-plugin-laravel)[kyslik/column-sortable

Package for handling column sorting in Laravel 6.x

6625.9M26](/packages/kyslik-column-sortable)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9762.4M134](/packages/roots-acorn)[pressbooks/pressbooks

Pressbooks is an open source book publishing tool built on a WordPress multisite platform. Pressbooks outputs books in multiple formats, including PDF, EPUB, web, and a variety of XML flavours, using a theming/templating system, driven by CSS.

45444.2k1](/packages/pressbooks-pressbooks)[api-platform/laravel

API Platform support for Laravel

58174.6k17](/packages/api-platform-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
