laravel-reorderable maintained by atomcoder
Laravel Reorderable

Drag-and-drop sorting for Laravel Eloquent models, with both Blade and Livewire UI support.
What This Package Does
This package lets you:
- store a sortable position on any Eloquent model
- render a drag-and-drop list in Blade or Livewire
- persist the new order automatically through a package route
- reorder items inside a group such as
project_id,board_id, orcategory_id - reorder items programmatically in PHP
- listen for an event when items are reordered
If you have records like tasks, posts, sections, menu items, images, or lessons, this package gives you a clean way to let users reorder them.
Requirements
- PHP
^8.3 - Laravel
^13.0 - Livewire
^4.0
Installation
composer require atomcoder/laravel-reorderable
php artisan reorderable:install
The install command publishes:
config/reorderable.php- package views
- the migration stub used by the generator command
After installing, the usual setup is:
- add a sort column to your table
- add the trait to your model
- add your model to
allowed_models - load records using the
ordered()scope - render the Blade include or Livewire component
Quick Start
1. Add a sort column to your table
By 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.
2. Make your model reorderable
<?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 = [
'title',
'project_id',
'sort_order',
];
protected string $sortColumn = 'sort_order';
public function getReorderLabel(): string
{
return $this->title;
}
}
3. Allow the model in the package config
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.
4. Load the items in the correct order
$tasks = Task::query()
->where('project_id', $project->id)
->ordered()
->get();
Use ordered() whenever you fetch reorderable items for display.
5. Render the UI
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.
How It Works
When a user drags an item:
- the package reads the list order in the browser
- it sends the ordered item IDs to the package route
- the package updates the sort column in the database
- it dispatches an
ItemsReorderedevent
For grouped lists, the reorder only applies inside the given group.
Model Setup Explained
Your model must:
- extend
Illuminate\Database\Eloquent\Model - implement
Atomcoder\LaravelReorderable\Contracts\ReorderableContract - use
Atomcoder\LaravelReorderable\Traits\HasSortOrder
HasSortOrder gives you
- automatic sort assignment on create
- an
ordered()query scope moveToPosition()for single-item movesreorderFromArray()for bulk reordering- default implementations for
getReorderKey()andgetReorderLabel()
Model properties and methods
| 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'). |
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 ascending. |
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. |
Basic model example
class Post extends Model implements ReorderableContract
{
use HasSortOrder;
protected $fillable = ['title', 'sort_order'];
public function getReorderLabel(): string
{
return $this->title;
}
}
Custom sort column example
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
Grouped reordering is for cases where items should only reorder within a parent record.
Examples:
- tasks inside a project
- lessons inside a course
- cards inside a board column
- menu items inside a menu
Example: reorder tasks inside one project
$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:
- only tasks with
project_id = $project->idare considered - dragging task
Awill not affect tasks in another project - the package renumbers only that project's tasks
Auto-assigning sort order within a group on create
When 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.
Blade Usage
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',
])
Blade variables explained
| 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. |
Blade layout requirements
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.
Livewire Usage
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"
/>
Livewire props explained
| 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. |
Livewire requirements
Make sure your page layout includes the normal Livewire directives:
@livewireStyles
@livewireScripts
Complete Example
Controller
<?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,
]);
}
}
Model
<?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';
}
}
Blade view
@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
Programmatic Reordering
You can reorder without the UI as well.
Move one item to a position
$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,
);
Reorder from an array of IDs
Task::reorderFromArray([8, 3, 5, 1]);
Grouped:
Task::reorderFromArray(
orderedIds: [8, 3, 5, 1],
groupColumn: 'project_id',
groupValue: 12,
);
Request Payload Sent by the UI
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.
Configuration
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'],
],
];
Config values explained
| 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. |
Authorization example
'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.
Events
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. |
Event listener example
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,
]);
});
Generator Command
The package includes a helper command:
php artisan reorderable:make Task --table=tasks --label=title --column=sort_order
This command:
- creates a migration to add the sort column
- prints the model snippet you should add
- prints a Blade example
- prints a Livewire example
It does not edit your model or config automatically. You still need to update the model and allowed_models yourself.
Generator options explained
| 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. |
Demo Page
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.
Common Mistakes
- forgetting to add the sort column to the database table
- forgetting to add the model to
allowed_models - forgetting to call
ordered()when loading the list - using the Blade component without a CSRF meta tag
- using the Blade component without rendering
@stack('scripts') - using multiple lists on one page without giving each one a unique
listId - returning a custom value from
getReorderKey()that is not the model primary key
Testing
composer test
License
MIT