marshmallow/nova-sortable
marshmallow/nova-sortable adds drag-and-drop sorting to Laravel Nova resource index views. Define a sortable column and reorder rows with a handle; the new order is saved automatically—ideal for models with an ordering field. Simple setup, no extra migrations required.
1. Install, then read the composer resolution before anything else. As of v3.9.0 this package now allows spatie/eloquent-sortable ^5.0:
composer require marshmallow/nova-sortable
composer show spatie/eloquent-sortable marshmallow/nova-sortable | grep versions
That bump is the only functional change in the release — everything else is dependabot noise in the build toolchain (webpack, terser, svgo, minimatch, lodash, node-forge, follow-redirects). The practical consequences for you:
spatie/eloquent-sortable ^4.0 for another sortable model, composer update will either fail or silently hold you back. Resolve it deliberately — you can run ^4.0 for your own models and still let the Nova field work, but only if the package's constraint allows it. Check composer why spatie/eloquent-sortable."marshmallow/nova-sortable": "3.9.0"), not ^3.9.2. Know what you're actually using. The ^5.0 constraint confirms the thing the previous assessment could only guess at: this package is a thin Nova field wrapped around Spatie's eloquent-sortable. That changes the setup — you do not write your own trait, and you do not hand-roll the "append at the end" logic:
use Spatie\EloquentSortable\Sortable;
use Spatie\EloquentSortable\SortableTrait;
class Slide extends Model implements Sortable
{
use SortableTrait;
public $sortable = [
'order_column_name' => 'sort_order', // so the column IS configurable
'sort_when_creating' => true, // no manual booted() hook needed
];
}
3. Migration. Still one non-unique, indexed integer column:
Schema::table('slides', function (Blueprint $table) {
$table->unsignedInteger('sort_order')->default(0)->index(); // index, NOT unique
});
Backfill before opening the UI, and let Spatie do it rather than a loop:
Slide::orderBy('id')->get()->each->moveToEnd(); // or a plain foreach setting sort_order = $i + 1
4. Wire the Nova field and set the index order. $sortable['order_column_name'] tells Spatie where to read/write; the Nova field and public static $sort = ['sort_order' => 'asc'] tell the index what to render and in what order. All three have to agree.
First use case: one flat, single-tenant list (testimonials, team members, hero slides). Drag one row, hard-refresh, confirm persistence and that a second admin sees the same order. Only then point it at anything scoped.
Use the Spatie scopes, don't reinvent them. ->ordered() already exists and appends an id tiebreaker in recent versions; wrap it so every consumer shares one definition:
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('id');
}
// Nova index, API resources, Blade loops, sitemap — all call ->ordered()
Scoped sorting is where the Spatie version matters. sortableGroups is the supported mechanism for "menu items belong to a menu":
public $sortable = [
'order_column_name' => 'sort_order',
'sort_when_creating' => true,
'sortableGroups' => false, // set per-instance, not a static array
];
// or override on the model:
public function buildSortQuery()
{
return static::query()->where('menu_id', $this->menu_id);
}
buildSortQuery() is the extension point to prefer — it covers creation, moveToStart/End, and the reorder path in one place. The open question remains whether the Nova field's JS posts the group identifier with the drop payload. If it only sends an array of IDs, the controller will renumber across parents. Test with two parents interleaved on one index; if it breaks, keep the field for the UI and route the drop through your own Nova Action that calls Spatie's setNewOrder() on a scoped query.
Custom column name is a solved problem now. Point order_column_name at the legacy column and you're done — no alias columns, no DB views. Just make sure the Nova field is given the same name, and that any orderBy in your app uses it too.
New records. With sort_when_creating => true, Spatie sets the position on creating. Remove any hand-rolled static::creating() hook from the old setup — two mechanisms fighting over sort_order is a real source of duplicate positions. The one case Spatie won't cover for you is a bulk attach from an importer or nested create: those bypass model events, so call moveToEnd() explicitly after attaching.
Filters and pagination. Unchanged and still the highest-risk area. Drag-reorder on a filtered index renumbers the visible subset and collides with hidden rows; drag across pagination pages writes correctly but looks broken. Either disable the field when a filter is active (->hideWhenCreating() won't help — gate it in the field's canSee), or drop pagination for small resources with public $perPage = 100;.
After composer update, republish assets. This release bumped the webpack/terser chain, which means the shipped dist/js bundle may have been rebuilt. Run php artisan nova:publish, hard-refresh, and confirm the drag handle still renders — a stale published asset is the single most common "it stopped working after an update" cause with this package.
This release is a dependency-bump release; assess it as such. No breaking changes, no new features, no deprecations. The only line that can affect you is the spatie/eloquent-sortable ^5.0 widening — and that's a permission, not a change: your resolved version only moves if your lockfile allows it. Read the Spatie v5 upgrade notes for dropped PHP/Laravel support before you let it float.
Verify the tag diff before shipping. Dependabot PRs here include a bump of lodash to 4.18.1, which does not correspond to a public lodash release. These are devDependencies used to build the JS bundle, so runtime exposure is limited — but it's a reason to git diff v3.8.1..v3.9.0 -- package.json webpack.mix.js and eyeball what actually ships in dist/ rather than trusting the notes.
Pin and vendor-review. Still a low-star, low-dependent package. Keep it pinned; read the reorder controller (routes/ + the controller it points at) once, so you know exactly what the endpoint authorises. The safer defaults remain outl1ne/nova-sortable (SortableJS, widely used) and Spatie-backed alternatives — same UX, more eyes on the code.
Don't put a unique index on the sort column. Swaps transiently duplicate a value and the query dies mid-transaction, leaving the list half-reordered. Non-unique index, and wrap renumbers in DB::transaction().
Duplicates are the debugging enemy. Two rows sharing sort_order look fine in MySQL and jump around in the browser. Keep the ->orderBy('id') tiebreaker and ship a sort:repair artisan command for after bad imports.
Soft deletes. Deleted rows keep their position (gaps are usually fine), but moveToEnd() / max(sort_order) logic should exclude trashed records — Spatie's buildSortQuery() is the place to add withoutTrashed() if it matters.
Multi-tenancy is an IDOR risk. If a global scope filters by tenant_id, confirm the reorder endpoint runs through the model (and therefore the scope) rather than a raw whereIn('id', ...)->update(). Test by posting an id from another tenant and expecting a 403/404, not a 200.
Concurrency. Two editors dragging simultaneously overwrite each other silently. Fine for an admin panel, not fine if end users reorder the same list — send an expected revision and reject stale writes if that's your case.
Nova version compatibility. A 2026 minor release implies Nova 4/5-era APIs. On older Nova the field's JS may fail to register and you'll get a plain text input instead of a drag handle — check the browser console for a failed app.js, not the PHP log.
Debugging checklist, in order:
$sortable['order_column_name'] disagree.php artisan nova:publish + hard refresh after every composer update (especially this one).indexQuery and check the resource's $sort, lenses, and any buildSortQuery() override.Extension points: buildSortQuery() and the $sortable array on the model, the field's fluent options (->onlyOnIndex()), the published config/middleware for the reorder route, and Nova::script() in NovaServiceProvider for JS tweaks. Never edit vendor/ — the next composer update (like this one) deletes it.
How can I help you explore Laravel packages today?