oddvalue/eloquent-sortable
A Laravel package that adds sortable behavior to Eloquent models. Use a simple trait to assign and maintain custom order, move records up or down, and reorder items reliably—great for drag-and-drop lists, menus, and other ordered content.
Install and confirm compatibility first. The changelog for v2.2.0 is CI-only, so it tells you nothing about framework support — read composer.json in the repo (or after install, vendor/oddvalue/eloquent-sortable/composer.json) to confirm your Laravel/PHP target is covered before you write code against it. Pin ^2.2.0 and commit the lockfile.
composer require oddvalue/eloquent-sortable:^2.2
Where to look first. Documentation is thin, and this package is an extension of spatie/eloquent-sortable — so your primary reference is the upstream README plus the trait itself: vendor/oddvalue/eloquent-sortable/src/. Skim the trait once before you build anything; it's a few hundred lines and it tells you the exact method names, the $sortable configuration keys, and which lifecycle hooks fire. Do not assume method names from blog posts; verify against the source you actually installed.
Minimal setup. Add the column and a composite index in one migration — the index is not optional, it is what makes both reads and moves tolerable:
Schema::table('faq_items', function (Blueprint $table) {
$table->unsignedInteger('order_column')->default(0)->after('id');
$table->index(['category_id', 'order_column']); // group column first
});
Then opt the model in:
use Oddvalue\EloquentSortable\Sortable; // confirm namespace in vendor/src
class FaqItem extends Model
{
use Sortable;
protected $sortable = [
'order_column_name' => 'order_column',
'sort_when_creating' => true,
// group/scoping key — check the trait for the exact key name
];
}
First use case. A flat, optionally grouped list with user-controlled position: FAQ entries per category, menu links, gallery images, kanban-less ordered rows. Do not reach for this if you need trees, nested sets, or ordering across heterogeneous entities — it solves flat lists only.
Verify in ten minutes. Factory three rows in one group, dump Model::ordered()->pluck('id'), move one to the end, dump again, then do the same across two groups. If those four assertions pass, the rest is plumbing.
Wrap it before you use it. The single highest-value pattern: a thin service that is the only sanctioned path for reordering. It isolates the package (swap-out seam), centralises locking, and gives you one place to test invariants.
class ReorderService
{
public function moveBefore(Model $model, Model $neighbour): void
{
DB::transaction(function () use ($model, $neighbour) {
// lock the affected group so concurrent drags serialise
$this->lockGroup($model);
$model->moveBefore($neighbour); // confirm exact method in trait
});
}
}
Prefer neighbour-ID over absolute position in your API. PATCH /faq-items/{id}/position with {"before_id": 42} (or after_id) is idempotent-ish and immune to off-by-one drift from stale clients. A full client-side array of IDs is a lost-update waiting to happen. Return the affected slice (id, order_column) after the write so the UI reconciles rather than guessing.
Backfill deterministically, in SQL, once. Don't let the trait assign positions during a migration or seeder for existing data — set them in a single statement so the result is reproducible:
UPDATE faq_items f JOIN (
SELECT id, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY created_at, id) AS rn
FROM faq_items
) x ON x.id = f.id
SET f.order_column = x.rn;
Scope every read. Always constrain by the group column before ordered(); a bare ordered() on a grouped table gives you interleaved groups and a confusing UI. The composite index makes this cheap.
Handle cross-group moves explicitly. Moving a model from category A to B is not a plain attribute update — it leaves a gap in A and needs a fresh position in B. Treat it as remove-from-A + append-to-B inside one transaction, then decide whether to renumber A.
UI wiring (Filament/Livewire/Vue). Filament tables have their own reorderable behaviour and column conventions; if you use it, either let Filament own the ordering or disable it and route drags through your service. Mixing both produces two writers to the same column. For a JS drag library, send the neighbour ID, not the array index.
Testing pattern. A single assertion helper pays for itself immediately:
$this->assertSame([1,2,3,4], FaqItem::ordered()->pluck('id')->all());
Cover: move up/down, to start/end, first-to-last, last-to-first, single-item group, empty group, cross-group move, and delete-then-reorder. Run these against MySQL/Postgres in CI (see gotchas), not just SQLite.
No database-level guarantee. Uniqueness of positions is enforced in application code only. Seeders, imports, INSERT ... SELECT, and "quick fixes" in tinker will silently produce duplicates. Add a periodic integrity check and alert on it:
SELECT category_id FROM faq_items
GROUP BY category_id
HAVING COUNT(*) <> COUNT(DISTINCT order_column)
OR MAX(order_column) <> COUNT(*); -- only if you renumber on delete
Gaps are benign; duplicates are not. Deleting a row usually leaves a hole in the sequence, and ordered() doesn't care. Don't write an integrity check that fails on gaps unless you've committed to renumbering — that turns every delete into an O(n) write.
Concurrency is your problem, not the package's. Two simultaneous drags in the same group can interleave. Always wrap moves in DB::transaction() with lockForUpdate() on the group. Beware: lockForUpdate() is a no-op on SQLite, so concurrency bugs will never surface in your default test suite — that's why the reorder tests need a real database in CI.
Moves are O(n) in group size. Fine for hundreds to tens of thousands of rows per group; not fine for a single global list with a million entries. If a group grows large, move to sparse ordering (assign positions in hundreds, renumber periodically) before you benchmark your way into a rewrite. Watch for deadlocks on hot groups and retry with jitter.
ordered() fights other orderBy calls. A later ->latest() or ->orderBy('name') silently overrides it. Chain carefully, or use ->reorder() deliberately when you mean it. Debug with ->toSql() / ->dump() — if the group column isn't in the generated SQL, your scope isn't applied.
Column-name mismatches fail quietly. If order_column_name doesn't match the migration, you get nulls or an exception deep in a lifecycle hook, not a clear config error. Assert it once in a test ($model->getSortableOrderColumnName() or equivalent — check the trait) rather than discovering it in production.
Direct writes to the order column bypass the trait. $model->update(['order_column' => 99]) or Model::where(...)->update([...]) will not renumber neighbours. Consider guarding the attribute ($guarded) and exposing only the service.
Group configuration is the least-documented part. Scoped sorting lives in the $sortable array on the model. Confirm the exact key and whether the trait reads a column or a closure. Also test what happens when the group column changes — that path is rarely exercised and is a common source of orphaned positions.
Maintenance mode. v2.2.0 has no source changes; bus factor is one. That's acceptable for a utility trait because you wrapped it. Keep the wrapper's tests green on every Laravel major upgrade — they're your canary — and treat the package as a patch-level auto-merge candidate behind Dependabot.
Extension points worth knowing. Everything meaningful is overridable in the model: the order column name, the sort-on-create behaviour, and the group key. If you need custom semantics (e.g. positions assigned per-tenant per-status), subclass or override rather than editing vendor code — and encode the rule in the wrapper so a future swap to a hand-rolled trait is a contained change.
How can I help you explore Laravel packages today?