sofa/laravel-sortable
A Laravel package for making Eloquent models sortable, letting you reorder records and persist their positions with minimal setup. Handy for drag-and-drop lists and custom sort columns.
composer require sofa/laravel-sortable. The only tagged release is from November 2016, so its illuminate/* constraint almost certainly pins Laravel 5.x. Before writing a line of code, confirm the solve succeeds against your framework version; if it doesn't, the realistic options are a fork with a loosened constraint or vendoring the (small) trait into app/Support/.sort_order). Make it a non-nullable unsignedInteger with a default and a composite index on any grouping columns plus the sort column:
$table->unsignedInteger('sort_order')->default(0);
$table->index(['category_id', 'sort_order']);
0 — the trait cannot infer intent from ties. Seed positions in insertion order (UPDATE items SET sort_order = id is fine for a first pass) and only then start moving things.use Sofa\Eloquent\Sortable\Sortable; (namespace depends on the release you pin — read the file you actually installed) and, if you don't want sort_order, override the column name on the model.Model::sorted()->get() to render the list, $model->moveToPosition($n) from a drag-and-drop handler to persist. That single loop — read sorted, move one row, re-render — is 90% of day-to-day usage.Where to look first: the trait source itself. It's a few hundred lines, it is the documentation, and every behaviour (how new rows get a position, whether moves renumber or swap, how the column name is resolved) is visible there in under ten minutes.
Ordered reads. Always read through the scope (Model::sorted()->get()) rather than orderBy('sort_order') scattered through controllers, so a future change to direction or tie-breaking is one edit. For paginated admin tables add ->orderBy('id') as a deterministic tiebreak.
Moves, not assignments. Treat sort_order as owned by the trait. Day-to-day operations should be moveToStart(), moveToEnd(), moveBefore($other), moveAfter($other), moveToPosition($n). Setting sort_order by hand is how you get duplicates.
Prefer relative moves in the UI. Drag-and-drop usually computes an absolute index, which is fragile when the visible list is filtered. If the endpoint can send "place after item #42", use moveAfter($neighbor) — it stays correct regardless of what the user was filtering on. Fall back to moveToPosition() only when the client truly sees the whole scope.
Wrap every move in a transaction. Moves shift neighbouring rows; a failure mid-shift leaves holes or duplicates. In the controller:
DB::transaction(fn () => $item->moveToPosition($request->integer('position')));
Bulk reorder endpoint. For "save all" style UIs, accept an ordered array of IDs and assign 1..n deterministically in a single transaction instead of issuing n individual moves. This is also the cheapest way to repair a list that has drifted.
Grouped scopes. Multi-list scenarios (items per category, comments per post, menus per location) need the trait restricted to the group. Check what the pinned version supports for scope columns; if it's thin, a small local helper that only queries and moves within where('category_id', ...) is safer than assuming the trait handles it.
Creation hooks. Confirm in the source whether creating auto-assigns the next position. If it does, note that Model::insert(), DB::table()->insert(), and raw SQL all bypass it — write your seeders and importers with create()/save() so they go through the model events.
Testing. After a move, refresh() the affected models and assert the full ordered ID list rather than a single row's sort_order. Property-style assertions ("the list is a permutation of 1..n with no gaps") catch drift that single-value checks miss.
app/Support/Sortable.php under your own namespace removes an entire class of supply-chain and compatibility risk — and lets you add the features below.items:renumber) that reassigns 1..n per scope and run it after imports. Don't build UI that assumes contiguous values.lockForUpdate() on the scoped rows inside the transaction, or switch the endpoint to sending the full ordered ID list — the latter is far simpler and idempotent.0 behaviour. MySQL sorts NULL first; every row at 0 collapses into ties resolved by the engine's whim. Keep the column non-nullable and backfilled.withTrashed() consistently.sort_order drifts from id. After enough moves the two are unrelated; never use id as a proxy for display order in queries, exports, or "next article" links.sort_order out of $fillable.Model::sorted()->toSql() plus DB::listen() will show you exactly which UPDATEs a move emits — the count tells you whether you're on the renumber-all strategy (cheap to reason about, expensive at scale) or the shift-window strategy. If a move appears to do nothing, check that save() actually fired: isDirty() on a no-op move is common.Sortable interface for type-hinted service methods, a nextSortOrder($scope) helper, and a reorder(array $ids, $scope) service method. These three cover the gaps you'll hit in the first week of real use.How can I help you explore Laravel packages today?