
Drag-and-drop sorting for Laravel Eloquent models, with both Blade and Livewire UI support.
This package lets you:
project_id, board_id, or category_idIf you have records like tasks, posts, sections, menu items, images, or lessons, this package gives you a clean way to let users reorder them.
^8.3^13.0^4.0composer require atomcoder/laravel-reorderable
php artisan reorderable:install
The install command publishes:
config/reorderable.phpAfter installing, the usual setup is:
allowed_modelsordered() scopeBy default, the package uses a column named sort_order.
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::table('tasks', function (Blueprint $table) {
$table->unsignedInteger('sort_order')->default(0)->index();
});
If you want a different column name such as position, that is fine. You will just need to tell the model which column to use.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Atomcoder\LaravelReorderable\Contracts\ReorderableContract;
use Atomcoder\LaravelReorderable\Enums\SortDirection;
use Atomcoder\LaravelReorderable\Traits\HasSortOrder;
class Task extends Model implements ReorderableContract
{
use HasSortOrder;
protected $fillable = [
'title',
'project_id',
'sort_order',
];
protected string $sortColumn = 'sort_order';
protected SortDirection|string $reorderSortDirection = SortDirection::Asc;
public function getReorderLabel(): string
{
return $this->title;
}
}
Edit config/reorderable.php:
'allowed_models' => [
App\Models\Task::class,
],
If allowed_models is empty, the package accepts any Eloquent model that uses the trait. In most apps, whitelisting your models is safer and clearer.
$tasks = Task::query()
->where('project_id', $project->id)
->ordered()
->get();
Use ordered() whenever you fetch reorderable items for display.
To sort descending, pass a direction string or the enum:
use Atomcoder\LaravelReorderable\Enums\SortDirection;
$tasks = Task::query()
->where('project_id', $project->id)
->ordered(SortDirection::Desc)
->get();
SortDirection::fromValue() accepts SortDirection, asc or desc strings (case-insensitive), or null, and returns a SortDirection (defaults to Asc).
Blade:
@include('reorderable::components.list', [
'items' => $tasks,
'modelClass' => App\Models\Task::class,
'groupColumn' => 'project_id',
'groupValue' => $project->id,
'title' => 'Reorder Tasks',
'listId' => 'project-tasks-list',
])
Livewire:
<livewire:reorderable-list
:items="$tasks"
model-class="App\Models\Task"
group-column="project_id"
:group-value="$project->id"
title="Reorder Tasks"
list-id="project-tasks-list"
/>
That is all you need for a working reorderable list.
When a user drags an item:
ItemsReordered eventFor grouped lists, the reorder only applies inside the given group.
Your model must:
Illuminate\Database\Eloquent\ModelAtomcoder\LaravelReorderable\Contracts\ReorderableContractAtomcoder\LaravelReorderable\Traits\HasSortOrderHasSortOrder gives youordered() query scopemoveToPosition() for single-item movesreorderFromArray() for bulk reorderinggetReorderKey() and getReorderLabel()| Item | Required | What it does |
|---|---|---|
$sortColumn |
No | Overrides the name of the database column that stores the order. If omitted, the package uses config('reorderable.sort_column'). |
$reorderSortDirection |
No | Default direction for ordered() when no argument is provided. Accepts SortDirection or asc/desc strings (case-insensitive). |
getReorderLabel() |
Usually yes | Returns the text shown in the UI. The trait provides a fallback using name, title, label, or the primary key, but defining it explicitly is clearer. |
getReorderKey() |
No | Returns the identifier sent by the UI. The default is the model primary key. Keep this aligned with your actual primary key because reordering uses whereKey() internally. |
ordered() |
No | Query scope that sorts by the reorder column. Accepts SortDirection, asc/desc, or null (falls back to getDefaultSortDirection()). |
getDefaultSortDirection() |
No | Override to return a custom default direction as SortDirection (use SortDirection::fromValue() to normalize input). |
moveToPosition() |
No | Moves one model to a specific position and renumbers the rest. |
reorderFromArray() |
No | Reorders records based on an array of IDs. Used internally by the package. |
class Post extends Model implements ReorderableContract
{
use HasSortOrder;
protected $fillable = ['title', 'sort_order'];
public function getReorderLabel(): string
{
return $this->title;
}
}
class MenuItem extends Model implements ReorderableContract
{
use HasSortOrder;
protected $fillable = ['label', 'position'];
protected string $sortColumn = 'position';
public function getReorderLabel(): string
{
return $this->label;
}
}
Grouped reordering is for cases where items should only reorder within a parent record.
Examples:
$tasks = Task::query()
->where('project_id', $project->id)
->ordered()
->get();
@include('reorderable::components.list', [
'items' => $tasks,
'modelClass' => App\Models\Task::class,
'groupColumn' => 'project_id',
'groupValue' => $project->id,
'title' => "Tasks for {$project->name}",
])
This means:
project_id = $project->id are consideredA will not affect tasks in another projectWhen a new model is created, the trait automatically sets the next sort value if the sort column is blank.
If you want that auto-assignment to happen inside a group, add this method to the model:
public function getDefaultReorderGroupColumn(): ?string
{
return 'project_id';
}
Now a newly created Task with project_id = 5 will get the next sort number inside project 5, not across all tasks.
Render the Blade include anywhere you already have a collection of reorderable models.
@include('reorderable::components.list', [
'items' => $tasks,
'modelClass' => App\Models\Task::class,
'groupColumn' => 'project_id',
'groupValue' => $project->id,
'title' => 'Reorder Tasks',
'listId' => 'project-tasks-list',
])
| Variable | Required | Type | Meaning |
|---|---|---|---|
items |
Yes | iterable | The models to display. These should already be loaded in the order you want shown, normally with ordered(). |
modelClass |
Yes | string | The fully qualified model class, for example App\Models\Task::class. |
groupColumn |
No | string or null |
The column used to limit reordering to a group, such as project_id. |
groupValue |
No | mixed | The value of the group column, such as $project->id. |
title |
No | string | Heading shown above the list. Default: Reorder Items. |
listId |
No | string | DOM ID for the <ul>. Use a unique value if you render multiple lists on one page. Default: reorderable-list. |
The Blade view sends a fetch() request with a CSRF token and pushes its JavaScript into the scripts stack.
Your layout should include:
<head>
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
<body>
@yield('content')
@stack('scripts')
</body>
If your app does not render @stack('scripts'), the drag-and-drop JavaScript will not load.
Use the Livewire component when the list already lives inside a Livewire-driven screen.
<livewire:reorderable-list
:items="$tasks"
model-class="App\Models\Task"
group-column="project_id"
:group-value="$project->id"
title="Reorder Tasks"
list-id="project-tasks-list"
/>
| Prop | Required | Type | Meaning |
|---|---|---|---|
:items |
Yes | iterable | The initial items to display. |
model-class |
Yes | string | The model class to reorder. |
group-column |
No | string | Group column such as project_id. |
:group-value |
No | mixed | Group value such as $project->id. |
title |
No | string | The card heading. Default: Reorder Items. |
list-id |
No | string | Unique HTML ID for the list. Default: reorderable-livewire-list. |
Make sure your page layout includes the normal Livewire directives:
@livewireStyles
@livewireScripts
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use Illuminate\View\View;
class ProjectTaskOrderController extends Controller
{
public function __invoke(Project $project): View
{
$tasks = $project->tasks()
->ordered()
->get();
return view('projects.task-order', [
'project' => $project,
'tasks' => $tasks,
]);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Atomcoder\LaravelReorderable\Contracts\ReorderableContract;
use Atomcoder\LaravelReorderable\Traits\HasSortOrder;
class Task extends Model implements ReorderableContract
{
use HasSortOrder;
protected $fillable = [
'project_id',
'title',
'sort_order',
];
public function getReorderLabel(): string
{
return $this->title;
}
public function getDefaultReorderGroupColumn(): ?string
{
return 'project_id';
}
}
@extends('layouts.app')
@section('content')
<div class="max-w-3xl mx-auto py-8">
@include('reorderable::components.list', [
'items' => $tasks,
'modelClass' => App\Models\Task::class,
'groupColumn' => 'project_id',
'groupValue' => $project->id,
'title' => "Reorder tasks for {$project->name}",
'listId' => 'project-task-order-list',
])
</div>
@endsection
You can reorder without the UI as well.
$task = Task::findOrFail(15);
$task->moveToPosition(1);
Move to a position inside a group:
$task = Task::findOrFail(15);
$task->moveToPosition(
position: 2,
groupColumn: 'project_id',
groupValue: $task->project_id,
);
Task::reorderFromArray([8, 3, 5, 1]);
Grouped:
Task::reorderFromArray(
orderedIds: [8, 3, 5, 1],
groupColumn: 'project_id',
groupValue: 12,
);
The Blade and Livewire UIs ultimately reorder using this shape:
{
"model": "App\\Models\\Task",
"items": [5, 9, 2, 7],
"group_column": "project_id",
"group_value": 12
}
The package route is:
POST /reorderable/update
If you change route_prefix in config, the URL changes with it.
Published config file:
return [
'middleware' => ['web'],
'route_prefix' => 'reorderable',
'sort_column' => 'sort_order',
'allowed_models' => [
// App\Models\Task::class,
],
'authorize' => null,
'demo' => [
'enabled' => false,
'middleware' => ['web'],
],
];
| Key | Default | What it does |
|---|---|---|
middleware |
['web'] |
Middleware used on the reorder update route. |
route_prefix |
'reorderable' |
Prefix for package routes such as /reorderable/update and /reorderable/demo. |
sort_column |
'sort_order' |
Default sort column name used when the model does not define $sortColumn. |
allowed_models |
[] |
Optional whitelist of models that may be reordered. |
authorize |
null |
Optional callback that receives the current request and model class. Return true to allow the reorder. |
demo.enabled |
false |
Turns the demo route on or off. |
demo.middleware |
['web'] |
Middleware applied to the demo route. |
'authorize' => function ($request, string $modelClass): bool {
return $request->user()?->can('manage-content') ?? false;
},
If the callback does not return true, the package aborts with 403.
The package dispatches this event after a reorder succeeds:
Atomcoder\LaravelReorderable\Events\ItemsReordered
Event properties:
| Property | Meaning |
|---|---|
$modelClass |
The reordered model class. |
$items |
The reordered IDs in their new order. |
$groupColumn |
The group column used, if any. |
$groupValue |
The group value used, if any. |
use Atomcoder\LaravelReorderable\Events\ItemsReordered;
use Illuminate\Support\Facades\Event;
Event::listen(ItemsReordered::class, function (ItemsReordered $event) {
logger()->info('Items reordered', [
'model' => $event->modelClass,
'items' => $event->items,
'group_column' => $event->groupColumn,
'group_value' => $event->groupValue,
]);
});
The package includes a helper command:
php artisan reorderable:make Task --table=tasks --label=title --column=sort_order
This command:
It does not edit your model or config automatically. You still need to update the model and allowed_models yourself.
| Option | Default | Meaning |
|---|---|---|
{name} |
none | The model name, for example Task. |
--table |
plural snake case of the model | Table to alter, for example tasks. |
--label |
title |
Model attribute that should be returned from getReorderLabel(). |
--column |
sort_order |
Sort column to create in the migration. |
Enable the demo in config/reorderable.php:
'demo' => [
'enabled' => true,
'middleware' => ['web'],
],
Then visit:
/reorderable/demo
If you changed route_prefix, the demo URL uses the new prefix.
allowed_modelsordered() when loading the list@stack('scripts')listIdgetReorderKey() that is not the model primary keycomposer test
MIT
How can I help you explore Laravel packages today?