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 and publish (if the package ships assets):
composer require marshmallow/nova-sortable
php artisan nova:publish # only if the README mentions publishable assets/config
2. Add the ordering column. Drag-and-drop needs a persisted integer position. Create it in a migration rather than hand-editing an old one:
Schema::table('slides', function (Blueprint $table) {
$table->unsignedInteger('sort_order')->default(0)->index(); // index, NOT unique
});
Backfill existing rows before you touch the UI, otherwise every record sits at 0 and the first drag reshuffles the whole table:
Slide::orderBy('id')->get()->each(function ($slide, $i) {
$slide->update(['sort_order' => $i + 1]);
});
3. Wire it into the resource. The package follows the usual Nova convention: a model trait (or an interface) plus a field/method call on the resource. Check the README for the exact names — for a 0-star package, read the source in vendor/marshmallow/nova-sortable/src before trusting the docs. The shape is almost always:
class Slide extends Model
{
use \Marshmallow\NovaSortable\Traits\Sortable; // verify trait name
}
// Slide.php (Nova resource)
public function fields(Request $request)
{
return [
// ...
Sortable::make('Sort order', 'sort_order'), // or $this->sortable()
];
}
4. Make the index query ordered. Nova's index does not automatically respect your column. Set the resource's default sort so the list matches what the user is dragging:
public static $sort = ['sort_order' => 'asc'];
First use case to validate with: a single-tenant resource with one flat list (testimonials, team members, hero slides). Drag one row, hard-refresh, confirm the order persisted and that other users see the same order. Only after that works should you point it at anything with scopes.
Ordering new records at the end. A common breakage is records created via API/seeder arriving with sort_order = 0 and jumping to the top. Centralise this on the model:
protected static function booted()
{
static::creating(function ($model) {
$model->sort_order ??= (static::max('sort_order') ?? 0) + 1;
});
}
Use the same expression when attaching records from a nested create, a Nova action, or an importer.
Scoped sorting (the pattern you'll actually need). Almost nothing is a single flat list — menu items belong to a menu, sections belong to a page. Keep one counter per parent:
// model
public function scopeOrdered($query) {
return $query->orderBy('sort_order')->orderBy('id'); // id tiebreaker = stable
}
Then verify the package actually posts a scope identifier (parent id) with the reorder payload. If it only sends an array of IDs, the reorder endpoint will happily reassign positions across parents and silently interleave two lists. If that's the case, don't patch the vendor — implement the reorder in a small Nova Action or custom controller of your own and keep the package's UI only if you can live with a global counter.
Custom column name. If you can't add sort_order (legacy schema), check whether the field/method accepts a column argument. If it doesn't, that's a hard blocker — don't reach for a DB view or a sort_order alias column as a workaround; it will confuse Nova's index query and every orderBy.
Combining with filters. Drag-reorder on a filtered index is the classic data-corruption path: the JS sends the visible ids, the server reassigns 1..n, and now the filtered subset has collided with the hidden rows. Either disable the field when a filter is active, or make the endpoint renumber only relative to neighbours. Test by filtering to 5 of 50 rows and dragging.
Consistency with paginated views. Nova paginates. A drag that moves an item from page 1 to page 2 will usually "work" on the DB but look broken in the UI. For large sets, prefer per-page sorting (each page a bucket) or turn pagination off for that resource — public $perPage = 100; — and be explicit about the limit.
Keep a single source of truth for ordering. Everything — Nova index, API list endpoints, Blade loops, sitemap generation — should call the same scope. Otherwise you get an order that "randomly" differs between admin and frontend after a drag.
This package has 0 stars and 0 dependents. Treat it as a fork candidate, not infrastructure. Pin the exact version in composer.json, and read the reorder controller before shipping. If you need guarantees, the well-trodden alternatives are outl1ne/nova-sortable (SortableJS-based, widely used) and Spatie's nova-orderable — both are safer defaults for production, and both give you the same drag-and-drop UX.
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 the whole renumber in DB::transaction().
Duplicates are your debugging enemy. If two rows share sort_order, the list looks fine in MySQL but jumps around in the browser. Always add ->orderBy('id') as a tiebreaker and add a "repair positions" artistan command you can run after a bad import.
Soft deletes. A deleted row keeps its position, so gaps appear. That's usually fine; just don't compute "is this the last item?" from max(sort_order) without excluding trashed records.
Global scopes and multi-tenancy. If a global scope filters by tenant_id, confirm the reorder endpoint applies it too. An endpoint that takes raw IDs and updates them without the scope is an IDOR — you're letting a user reorder another tenant's data. Check authorize() / Nova::check on the route.
Concurrency. Two editors dragging simultaneously will overwrite each other silently. Acceptable for an admin panel; not acceptable if the same list is reordered by end users. If it matters, send the expected version/revision and reject stale writes.
Nova version compatibility. A 2026 release implies Nova 4/5-era APIs. If you're on an older Nova, the field's JS may not register and you'll see a plain text input instead of a drag handle — check the browser console for a failed app.js load, not the PHP logs.
Debugging checklist, in order:
php artisan nova:publish and a hard refresh after any composer update — stale published assets are the #1 cause of "it stopped working."indexQuery for the sorted column; Nova lenses and $sort overrides are the usual culprits.Extension points worth knowing: the field's options array (usually ->sortable() / ->onlyOnIndex()), the published config for the reorder route middleware, and Nova's Nova::script() in a service provider if you need to tweak the Sortable instance. Keep any customisation in your app's NovaServiceProvider — never edit vendor/, because the next composer update deletes it.
How can I help you explore Laravel packages today?