optimistdigital/nova-sortable
A Laravel Nova package for drag-and-drop sorting of resource records in index tables. Add a sortable trait and field, then reorder rows directly in Nova with minimal setup. Ideal for models using an order or sort_order column.
optimistdigital/nova-sortable (now published as outl1ne/nova-sortable) is a small, focused Nova add-on: it adds a drag handle to a resource’s index rows and persists the new order to an integer column on the model’s table. It is not a general-purpose sorting library — it only matters when a Nova resource needs manual ordering (FAQs, banners, menu items, slides, checklist steps, etc.). The day-to-day value is that you avoid building a custom reorder UI and endpoint for every such resource.
1. Install the package
composer require outl1ne/nova-sortable
Older codebases may still reference the optimistdigital/nova-sortable package name. The namespace also changed from OptimistDigital to Outl1ne; check which one your composer.lock and imports use before copying snippets from old blog posts.
2. Add the sort column with a migration
Schema::table('banners', function (Blueprint $table) {
$table->integer('sort_order')->nullable()->index();
});
The column is conventionally sort_order, but the name is configurable. Make it nullable so existing rows and creates that don’t set it don’t fail, then backfill (see below).
3. Opt the Nova resource in
namespace App\Nova;
use Outl1ne\NovaSortable\Traits\HasSortableRows;
class Banner extends Resource
{
use HasSortableRows;
}
That’s the minimum. The resource index should now show drag handles and persist order.
4. First use case
The canonical first case is a Banner or Faq resource that editors want to arrange by hand. Add the trait, add the column, seed a few rows with sort_order values, then open the index in Nova and drag. If the handles don’t appear, confirm the trait is on the resource (not the model), the column exists, and the user has update permission on the resource.
Where to look first
src/Traits/HasSortableRows.php — the entire behaviour is a thin trait; reading it takes minutes and answers most “why is it ordering like that?” questions.indexQuery and $defaultSort — most integration problems originate there, not in the package.There is generally no config file to publish; behaviour is configured with static properties on each resource.
Default index ordering
By default the trait appends an orderBy on the sort column to the resource index query, so the list matches the drag order out of the box. If you need a different default order and only want drag-and-drop as a secondary tool, disable it per resource:
public static $sortOnIndex = false;
Custom column name
If sort_order clashes with an existing column or naming convention:
public static $sortColumnName = 'position';
Keep the property and the migration in sync; a mismatch produces SQL “unknown column” errors on the index.
Grouped sorting (children within a parent)
For rows that should only be reordered inside a group (menu items under a menu, steps under a checklist), declare the group columns:
public static $sortableGroups = [
'menu_id',
];
The practical pattern is one sort_order sequence per group. Make sure the group column is present and indexed, and that the index query actually exposes it (no global scope hiding it).
Backfilling existing rows
Never leave production rows with NULL sort values if you rely on the order. Backfill once, deterministically:
DB::table('banners')->orderBy('id')->get()->each(function ($row, $i) {
DB::table('banners')->where('id', $row->id)->update(['sort_order' => $i + 1]);
});
For large tables, chunk by id or use a window function (ROW_NUMBER() OVER (ORDER BY id)) instead of loading everything.
Keep the model usable outside Nova
The package only owns the Nova endpoint. Your application code still needs deterministic ordering, so add a scope or default relation order on the model:
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('id');
}
Also assign a sort_order on create so new records don’t land at the top arbitrarily:
protected static function booted()
{
static::creating(function ($model) {
$model->sort_order ??= (static::max('sort_order') ?? 0) + 1;
});
}
If the package has an “append after create” option in your version, prefer the built-in flag so the Nova and non-Nova paths agree.
Workflow tips
sort_order will leak ordering across tenants.update policy is correct, because that is effectively the permission to reorder.NULL sort values sort unpredictably. Backfill before enabling the feature for editors, and set a value on create.indexQuery() joins another table that also has sort_order, an unqualified orderBy('sort_order') can throw an ambiguous-column error. Qualify the column (e.g. banners.sort_order) or set $sortColumnName to the qualified name.$defaultSort conflicts. The trait’s index ordering and a resource-level $defaultSort can fight each other. Decide which one owns the default order; disable $sortOnIndex if the resource’s own sort should win.SoftDeletes, reordering doesn’t renumber deleted rows; gaps are normal and harmless unless you depend on contiguous values.sort_order is 1..N. Use ORDER BY sort_order, id and treat values as sparse.optimistdigital to outl1ne means old imports (OptimistDigital\NovaSortable\...) and old Composer names can linger. Standardise on one namespace across resources and update upgrade guides when bumping majors.position trait, choose one. Two systems writing the same column will corrupt order silently.$defaultSort or a global scope)? Work through those in order; it isolates nearly every issue.How can I help you explore Laravel packages today?