Looking to hire Laravel developers? Try LaraJobs

laravel-reorderable maintained by atomcoder

Description
Drag and drop reorder functionality for Laravel Eloquent models.
Author
Last update
2026/04/12 17:36 (dev-main)
License
Links
Downloads
2

Comments
comments powered by Disqus

Laravel Reorderable

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, or category_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:

  1. add a sort column to your table
  2. add the trait to your model
  3. add your model to allowed_models
  4. load records using the ordered() scope
  5. 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:

  1. the package reads the list order in the browser
  2. it sends the ordered item IDs to the package route
  3. the package updates the sort column in the database
  4. it dispatches an ItemsReordered event

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 moves
  • reorderFromArray() for bulk reordering
  • default implementations for getReorderKey() and getReorderLabel()

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->id are considered
  • dragging task A will 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