zikula/sortable-columns
zikula/sortable-columns adds reusable sortable column support to Zikula tables, helping developers define sortable headers, generate sort links, and keep column ordering consistent across modules with minimal configuration.
zikula/sortable-columns is a small, framework-agnostic helper extracted from the Zikula CMS. It does one job: given the current request and a list of columns, it tells you which column is being sorted, in which direction, and gives you a header object that knows how to build a toggle link. There is no service provider, no config file, no facade, and no Blade integration — you wire it up by hand.
composer require zikula/sortable-columns
Before you commit to it: the repository is archived (last release January 2020) with zero dependents and a single star. Treat it as vendored source code, not as a maintained dependency. If your PHP constraint is 8.1+ and the package's composer.json still says ^7.1, either fork it into app/Support or read it once and reimplement the ~60 lines of logic yourself (a sketch is at the end of this section).
Where to look first, in order:
src/SortableColumns.php — the whole public API lives here: addColumn(), setDefaultSort(), setOrderByFromRequest(), getSort(), getSortDirection().src/Column.php and src/SortableColumnHeader.php — the value objects, including whatever it exposes for URL/title/CSS state.README.md and tests/ — the tests are the real documentation; the README assumes you already live inside Zikula.composer.json — check the PHP constraint and the Symfony http-foundation/routing requirements against your Laravel version.Your first use case will be an admin index page: GET /admin/users?sort=email&direction=desc, with clickable <th> links.
The pragmatic integration is to use the package only for state (getSort(), getSortDirection()) and let Laravel build the URLs:
use Zikula\Component\SortableColumns\SortableColumns;
$sorter = new SortableColumns(request());
$sorter->addColumn('name', 'Name');
$sorter->addColumn('email', 'Email');
$sorter->addColumn('created_at', 'Joined');
$sorter->setDefaultSort('name');
$sorter->setOrderByFromRequest(request()); // confirm the exact name in src/
$column = $sorter->getSort(); // e.g. 'email'
$dir = $sorter->getSortDirection(); // 'ASC' | 'DESC'
Laravel's Illuminate\Http\Request extends Symfony\Component\HttpFoundation\Request, so a Symfony Request type-hint will pass. A UrlGeneratorInterface type-hint will not — Laravel's UrlGenerator does not implement it. Check the constructor signature before you get excited.
If that mismatch bites, write the 60 lines yourself:
final class TableSort
{
public function __construct(
public readonly string $column,
public readonly string $direction,
) {}
public static function fromRequest(Request $request, array $allowed, string $default, string $defaultDir = 'asc'): self
{
$column = in_array($request->query('sort'), $allowed, true) ? $request->query('sort') : $default;
$dir = strtolower($request->query('direction', $defaultDir)) === 'desc' ? 'desc' : 'asc';
return new self($column, $dir);
}
public function toggleUrl(string $column): string
{
$next = $this->column === $column && $this->direction === 'asc' ? 'desc' : 'asc';
return request()->fullUrlWithQuery(['sort' => $column, 'direction' => $next, 'page' => 1]);
}
}
Whitelist before orderBy. The package hands you a string from the query string. Never pass it to Eloquent directly. Map UI keys to real columns:
$map = ['name' => 'users.name', 'email' => 'users.email', 'created_at' => 'users.created_at'];
$query->orderBy($map[$sort->column] ?? 'users.name', $sort->direction);
Apply the default in the query, not just in the helper. The package's default sort only affects what the header reports; your orderBy still needs to run when no ?sort= is present.
Blade partial or component for the header row. Don't call the sorter inside @foreach in a 400-line view. Pass an array of header DTOs from the controller, or bind a Blade::component('sortable-th'):
@foreach ($headers as $key => $label)
@php $active = $sort->column === $key; @endphp
<th>
<a href="{{ $sort->toggleUrl($key) }}" class="{{ $active ? 'font-bold' : '' }}">
{{ $label }}
@if ($active) {{ $sort->direction === 'asc' ? '▲' : '▼' }} @endif
</a>
</th>
@endforeach
Preserve filters. fullUrlWithQuery() keeps existing query params; a hand-rolled ?sort=...&direction=... will silently drop your search and filter state, which is the single most common bug in this feature.
Reset page to 1 on sort. Sorting page 4 of a differently-ordered list is confusing; include page => 1 in the toggle URL.
Keep pagination links in sync. ->paginate(25)->appends(request()->query()) so page 2 keeps the sort.
Multiple tables per page. sort/direction collide. Support a prefix (users_sort, users_direction) or scope by route.
Reusable wrapper. Wrap the whole thing in a small App\Support\TableSorter service so controllers, API resources, and Inertia props all consume one shape ({column, direction, headers: [{key, label, url, active, direction}]}). That wrapper is also the seam that lets you swap the package out later without touching views.
For JSON/Inertia endpoints, expose the sort state in the resource meta and let the frontend render arrows — the package contributes nothing beyond the two strings.
Testing. Feature-test the query string, not the markup: $this->get('/admin/users?sort=email&direction=desc')->assertOk(), plus a unit test on the whitelist fallback for ?sort=id;DROP TABLE.
app/Support/SortableColumns and take ownership. For a helper this small, forking is usually cheaper than an adapter layer.UrlGeneratorInterface or AbstractController requirement, don't fight it — extract getSort()/getSortDirection() and abandon the rest.ASC/DESC; Laravel's orderBy accepts either but request()->query('direction') from a hand-written link may be lowercase. Normalize at the boundary.{{ }} in Blade. Don't {!! !!} a column label you built from user input.SortableColumnHeader (or wrap it) if you need to attach Tailwind/Bootstrap classes, aria-sort, and rel="nofollow". Wrapping in your own DTO is cleaner than subclassing an unmaintained class.rel="nofollow", and if you cache index pages, include sort params in the cache key.getSort()/getSortDirection() at the top of the controller with no ?sort present. If you get null/ASC instead of your default, you've called setOrderByFromRequest() before setDefaultSort() — order matters.spatie/laravel-query-builder (allowedSorts(), sort(), ->appends()) is maintained, Laravel-native, and covers this plus filtering and includes. For a five-column admin table, either is fine; for anything load-bearing, pick the maintained one. Use zikula/sortable-columns only if you're already inside a Zikula codebase or the license/behavior is a hard requirement.How can I help you explore Laravel packages today?