rtablada/eloquent-rankable
A lightweight Laravel trait that adds sortable rank to Eloquent models. Keep an order in a rank column, move records up or down, and fetch ranked lists without writing custom SQL. Simple trait-based setup for menus, galleries, and any admin-sorted content, with no complex configuration.
composer require rtablada/eloquent-rankable. With 31 stars, zero dependents, and no recent activity, open vendor/rtablada/eloquent-rankable/src/ before writing any code — the trait is small, so read it top to bottom and confirm the method names and default column it expects. Do not rely on the README alone.rank (default 0) to the table and index it, ideally as a composite index with whatever your grouping column is (['board_id', 'rank']).use Rtablada\EloquentRankable\Rankable; inside the model. Override the column name and grouping hooks on the model itself if the package exposes them (typically a $rankColumn property and a rankingGroup()/scope method) — check the trait for the exact names in your pinned version.rank values swap rather than renumber the whole table. That single test tells you whether the package fits your workload.boot/creating hooks (does it auto-assign rank on insert?), its move methods, and whether reordering is wrapped in a transaction internally or expects you to provide one.Append-on-create. Confirm whether the trait assigns a rank automatically when a model is created. If it does not, set it yourself in a creating observer or model event:
static::creating(function ($model) {
$model->rank = (static::where('board_id', $model->board_id)->max('rank') ?? 0) + 1;
});
Without this you get a table full of rank = 0 and arbitrary display order — the single most common first-day failure.
Scoped ranking per group. For anything with a parent (board, category, tenant), make the grouping explicit so each group has its own independent 1..n sequence. Keep the group key in the model's group hook rather than passing it at every call site, and always filter by it when rendering:
$cards = Card::where('board_id', $board->id)->orderBy('rank')->get();
Controller endpoint for drag-and-drop. Model the move as a single PATCH with a target position, not a bulk "save the whole array" payload:
public function move(Request $request, Card $card)
{
$request->validate(['position' => ['required', 'integer', 'min:0']]);
DB::transaction(function () use ($card, $request) {
$card->insertAt((int) $request->position);
});
return back();
}
insertAt() is usually the only method you need in the UI layer; moveUp/moveDown/moveToTop/moveToBottom are conveniences for buttons. Wrapping in a transaction is non-negotiable — a partially applied reorder produces duplicate ranks and a visibly broken list.
Bulk reorder from a submitted array. If your frontend posts an ordered list of IDs (common with SortableJS), loop within one transaction and treat index as the position. Keep the loop idempotent so a double-submit does not corrupt ranks.
Cache invalidation. Any response cache or view cache keyed on the list must be flushed in the same place you reorder. Put the flush in a model event or in the controller's transaction block so it can't drift from the write.
Read-side ordering. Standardise on ->orderBy('rank') everywhere, and add a small local scope (scopeRanked) on your model rather than relying on a package scope — it survives a future migration off this package.
composer.json, and write your reordering logic against a thin wrapper class (or a Rankable concern of your own) so swapping to spatie/eloquent-sortable, which is actively maintained and does essentially the same job, is a one-file change.UPDATE, or a concurrent reorder can leave two rows with the same rank; SQL then returns them in arbitrary order and the bug looks like "the list reshuffles on every refresh". Add a scheduled or artisan command that detects duplicates and renumbers per group, and add a composite unique index only if every reorder is transactional — otherwise the unique constraint will make moves fail halfway.1..n; verify against the source before letting anyone insert rows outside the package.lockForUpdate() on the group's rows inside the transaction if reordering is a real multi-user workflow.rank out of $fillable. A stray $request->all() in an update path will let clients rewrite ordering directly and bypass the move methods.NULL ranks differently in ORDER BY. Backfill rank to a non-null default before going live, and never leave the column nullable.pluck('id') sequences after a move; asserting exact rank integers makes tests brittle the moment you change the renumbering strategy. Use RefreshDatabase and seed three to five items per test.DB::listen() and dump the statements — it is the fastest way to see whether the package is doing a full-table renumber or a targeted swap, which determines whether you can keep it at your scale.How can I help you explore Laravel packages today?