Skip to content
Bladewell

<x-widget.sortable>

Sortable list Blade component for Laravel and Livewire

Items people put in order: FAQs, menu links, a product's photos. Each item has a handle that drags with a mouse, a pen or a finger, or picks the item up for the arrow keys, and Move up and Move down buttons. Every move is announced and can be undone, focus stays where it was, and the item rings for a moment where it lands. A move fires sortable:move, and with move-url is sent to your app one at a time, which keeps it or sends the list back to its last saved order with a reason; with a version, a change someone else made first is caught and the person offered a reload. With a name the order submits with its form as name[], and comes back after a failed submit. Lists or wrapping grids of tiles, right to left too. Every item moves: keep pinned ones, like a Home link, outside the list, and make a filtered or paged list readonly, where an order means nothing for the items not shown. Works inside Livewire components, with wire:model or wire:sortable-move.

php artisan bladewell:add sortable
Also adds
Button, Icon
Works with
Laravel 12 and 13, Livewire 3 and 4, Tailwind CSS 4.1+, PHP 8.3+

Usage

Move route

The URL in move-url answers POST with the move as JSON: item, position (counted from 0), after (the item it now follows, or null at the top), order (every id in the list, first to last) and, with version="…", the version this page drew. Moves go one at a time, the next one only once this answers, so they arrive in the order they were made. Any 2xx keeps the move. Anything else puts the list back in the order last saved, with your answer's message: a failed validation's first error, or abort(403, '…'). A 409 also offers the person a reload. <x-widget.sortable label="Links" :move-url="route('menus.links.reorder', $menu)" :version="$menu->links_version">

PHP
Route::post('/menus/{menu}/links/reorder', function (Request $request, Menu $menu) {
    Gate::authorize('update', $menu);
    $move = $request->validate([
        // max: a request can send any number of ids. Sized for a list a person orders by hand.
        'order' => ['required', 'array', 'max:200'],
        'order.*' => ['integer', 'distinct'],
        'version' => ['required', 'integer'],
    ]);

    $version = DB::transaction(function () use ($menu, $move) {
        // Read with a lock, so two people reordering at once take turns, and the second sees what the first saved.
        $menu = Menu::whereKey($menu->id)->lockForUpdate()->firstOrFail();
        // Someone else reordered since this page drew the list: don't overwrite their order without them seeing it.
        abort_if($move['version'] !== $menu->links_version, 409, 'Someone else has reordered these links. Reload to see their order.');

        // Every id has to be one of this menu's links, and every one of its links has to be there, once: an id from
        // another menu can't be moved into this one, and a link can't be dropped by leaving it out.
        $ids = $menu->links()->pluck('id')->all();
        if (array_diff($ids, $move['order']) !== [] || array_diff($move['order'], $ids) !== []) {
            throw ValidationException::withMessages(['order' => 'These links have changed since this page loaded. Reload it and try again.']);
        }
        // One update per link, all or none. Fine for a list someone orders by hand; for a long one, move only `item`,
        // after `after`, with sort values that leave gaps (1000, 2000, …).
        foreach ($move['order'] as $sort => $id) {
            $menu->links()->whereKey($id)->update(['sort' => $sort]);
        }
        $menu->increment('links_version');

        return $menu->links_version;
    });

    // The version the next move sends.
    return response()->json(['version' => $version]);
})->middleware('throttle:60,1')->name('menus.links.reorder');

Form

With name="links", the form sends links[] with the ids in the order on the page, and any fields inside the items as they're named (titles[11] here). Validate the ids as any array, check they are exactly this menu's links, none missing and none from another menu, and look each one up through the menu, never as a bare id. Failed validation sends the order back as old input, and the list draws itself in that order.

PHP
Route::put('/menus/{menu}', function (Request $request, Menu $menu) {
    Gate::authorize('update', $menu);
    $validated = $request->validate([
        'links' => ['required', 'array', 'max:50'],
        'links.*' => ['integer', 'distinct'],
        'titles' => ['required', 'array'],
        'titles.*' => ['required', 'string', 'max:40'],
    ], attributes: ['titles.*' => 'label']);

    DB::transaction(function () use ($menu, $validated): void {
        // Locked, so two saves at once take turns rather than mixing their orders.
        $ids = $menu->links()->lockForUpdate()->pluck('id')->all();
        if (array_diff($ids, $validated['links']) !== [] || array_diff($validated['links'], $ids) !== []) {
            throw ValidationException::withMessages(['links' => 'The links have changed since this page loaded. Reload it and try again.']);
        }
        foreach ($validated['links'] as $sort => $id) {
            $menu->links()->whereKey($id)->update(['sort' => $sort, 'title' => $validated['titles'][$id] ?? null]);
        }
    });

    return back()->with('status', 'Menu saved.');
})->name('menus.update');

Livewire

In a Livewire component, bind the order with wire:model to an array property, public array $order = [], filled with the ids in mount(). Each move sets it, and the list is drawn from it, so a render keeps the person's order. To save each move as it happens, listen with wire:sortable-move instead: public function reorder(array $order) saves it, and the render that follows draws the list as it now is; to refuse a move, don't make it, and that render puts the item back, saying so. Each item brings its own wire:key, and focus stays on the handle or button that moved it.

Blade
<div>
    <x-widget.sortable label="FAQs" wire:sortable-move="reorder($event.detail.order)">
        @foreach ($faqs as $faq)
            <x-widget.sortable.item :id="$faq->id" :label="$faq->question">{{ $faq->question }}</x-widget.sortable.item>
        @endforeach
    </x-widget.sortable>
</div>

In scripts

window.sortable is available once the sortable script has loaded. Moves an item, by its id or element, to a place counted from 0. It's announced, fires sortable:move and is sent to move-url, like a move made by hand. False when it can't move.

JavaScript
sortable.move('3', 0);

// Every move, by hand or by script, fires sortable:move on the list (and sortable-move, for wire:sortable-move). after
// is the id of the item it now follows, or null at the top; order is every id, first to last. Ids arrive as strings.
document.addEventListener('sortable:move', (event) => {
    const { item, position, after, order } = event.detail;
    console.log(`${item} is now at ${position}, after ${after ?? 'none'}: ${order.join(', ')}`);
});

On the server

The sortable list can't stop a request that skips it. Here's what to check on your server, and what to keep cheap.

Security

  • The order arrives from the browser and can hold any ids. Validate them (array, max:, distinct), check they are exactly the parent's items, none missing and none from elsewhere, look them up through that parent ($menu->links()), never as bare ids, and authorize the change. Save it in one transaction with the parent locked, and pass version so your route can answer 409 when someone else reordered first, instead of quietly overwriting their order.

    OWASP: Insecure direct object references · Laravel: Validating arrays · Laravel: Pessimistic locking

Server cost

  • Saving the whole order is one update per item on every move: fine for a list someone orders by hand. For long lists, move only the one item, by item and after, with sort values that leave gaps (1000, 2000, …), so a move usually updates one row.

Examples

These examples also use Text input, which Sortable list doesn't need on its own. To use them as they are:

Terminal
php artisan bladewell:add sortable text-input

Images

layout="grid" lays the items out as tiles that wrap, for a product's photos. They read in rows, so a drag places a tile among them by row, the arrow keys move it along and between rows, and its buttons move it earlier or later. Anything else in a tile is yours, like this remove button: the list keeps up as tiles come and go, and the empty slot shows once none are left.

Pass $photos from your controller.

Product photos
  1. Mug, front
  2. Mug, side
  3. Mug, from above
  4. Mug, back
  5. Mug on a desk

Press Space or Enter to pick it up, the arrow keys to move it, and Space or Enter again to put it down. Escape puts it back.

The first photo is the one shown in the shop.

Show code
Blade
<x-widget.sortable label="Product photos" name="photos" layout="grid" info="The first photo is the one shown in the shop.">
    @foreach ($photos as $photo)
        <x-widget.sortable.item :id="$photo->id" :label="$photo->alt">
            <div class="relative">
                <img src="{{ $photo->url }}" alt="{{ $photo->alt }}" class="block aspect-square w-full rounded-lg object-cover">
                <div class="absolute end-2 top-2">
                    <x-widget.button variant="neutral" size="sm" icon="trash" :label="'Remove '.$photo->alt" class="shadow-xs" data-photo-remove />
                </div>
            </div>
        </x-widget.sortable.item>
    @endforeach
    <x-slot:empty>No photos yet. The shop shows a placeholder until you add one.</x-slot:empty>
</x-widget.sortable>
JavaScript, in your own JS file
// The remove button takes its tile out of the list, which then submits without it. Focus goes to the next tile's
// remove button, or the one before, so the keyboard isn't left on nothing.
document.addEventListener('click', (event) => {
    const tile = event.target.closest('[data-photo-remove]')?.closest('[data-sortable-item]');
    if (!tile) {
        return;
    }
    const next = tile.nextElementSibling ?? tile.previousElementSibling;
    tile.remove();
    next?.querySelector('[data-photo-remove]')?.focus();
});

Faqs

Questions in the order your help page shows them. Drag one by its handle, pick it up with Space and place it with the arrow keys, or use its Move buttons: the list moves at once and sends each move to your route, which keeps it or sends it back with a reason. Here the route keeps the welcome question first.

Pass $faqs from your controller.

FAQs
  1. What is Bladewell?

    Blade components you copy into your app and own from then on.

  2. How long does shipping take?

    Two to four working days within the EU, a week elsewhere.

  3. Can I return an order?

    Within 30 days, unused and in its box, at no cost to you.

  4. Do you ship abroad?

    To 40 countries; the checkout lists them.

  5. How do I contact you?

    By email or chat, Monday to Friday, 9 to 5.

Press Space or Enter to pick it up, the arrow keys to move it, and Space or Enter again to put it down. Escape puts it back.

Show code
Blade
<x-widget.sortable label="FAQs" :move-url="route('faqs.reorder')">
    @foreach ($faqs as $faq)
        <x-widget.sortable.item :id="$faq->id" :label="$faq->question">
            <p class="font-medium">{{ $faq->question }}</p>
            <p class="text-muted mt-0.5 truncate">{{ $faq->answer }}</p>
        </x-widget.sortable.item>
    @endforeach
</x-widget.sortable>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.sortable>

Prop Default Description
label null Required: shown above the list, and its name for screen readers and in what's announced as items move.
name null What the order submits as: the items' ids, first to last, as name[]. Its error and old input are found under it. Optional with wire:model, which then names it; leave both out when move-url or a script saves each move.
id null Defaults to one made from the name (or the wire:model property).
layout 'list' list: one item under another. grid: tiles in rows that wrap, such as images, ordered as they read.
move-url null A route each move is sent to as JSON (POST: item, position, after, order, and version when set), with the CSRF token, one request at a time. An answer that isn't 2xx puts the list back in the order last saved and shows the answer's message; a 409 also offers to reload. Without it, a move fires sortable:move and, with a name, changes what the form submits.
version null With move-url: the order's version as this page drew it, such as the parent's updated_at or a counter. Sent with each move, so your route can answer 409 when someone else has reordered since. A 2xx answer of {"version": …} gives the next one.
error null An error message of your own; otherwise the validation error for the name, from the session or Livewire.
info null A hint under the list.
bag 'default' Which error bag to read the error from.
readonly false Shown, and submitted, but nothing in it moves. Also for a filtered or paged list: an order there means nothing for the items not shown.
disabled false Greyed out: nothing moves, and the order isn't submitted.

Slots

<x-slot:empty>
Shown while there are no items: what the list is for.

<x-widget.sortable.item>

Prop Default Description
id null Required: what the order and a move send for it, such as the question's id. Unique within the list.
label null Required: the item's name on its handle and Move buttons ("Move Shipping times") and in what's announced.

Accessibility

All 3 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What bladewell:add sortable writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds and Field, and the theme and base CSS.

resources/views/components/widget/sortable/index.blade.php Show
index.blade.php
resources/views/components/widget/sortable/item.blade.php Show
item.blade.php
resources/js/widget/sortable/index.js Show
index.js
app/View/Widget/ElementIds.php Show
ElementIds.php
app/View/Widget/FormField.php Show
FormField.php