Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Sortable Laravel Package

james.xue/laravel-sortable

Laravel Sortable adds easy drag-and-drop ordering to Eloquent models: define sortable columns, automatically manage positions on create/update/delete, and reorder records with simple query helpers. Ideal for menus, galleries, and any list that needs custom sequencing.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a small, low-activity package (2 stars, 0 dependents) that implements the familiar "sortable Eloquent model" pattern. It is useful primarily as a lightweight, single-purpose trait — or as a reference implementation to copy — rather than as a foundation you should depend on blindly.

Minimum path to first use:

  1. Install

    composer require james.xue/laravel-sortable
    

    The package is likely not covered by Laravel's auto-discovery for service providers unless it ships them; check composer.json → extra.laravel in vendor/james.xue/laravel-sortable first. If absent, there is nothing to register.

  2. Publish assets, if any

    php artisan vendor:publish --provider="JamesXue\LaravelSortable\SortableServiceProvider"
    

    If that command errors with "provider not found", the package is trait-only and you can skip it. Look in vendor/james.xue/laravel-sortable/config/ and database/migrations/ for anything shipped.

  3. Add the migration column — sortable packages conventionally require a numeric column. The most common name here is sort_order (some forks use sortable or order). Confirm the exact column in the trait's orderBy call before running migrate:

    Schema::table('faqs', function (Blueprint $table) {
        $table->unsignedInteger('sort_order')->default(0)->index();
    });
    
  4. Add the trait

    use JamesXue\LaravelSortable\Sortable;
    
    class Faq extends Model
    {
        use Sortable;
    
        protected $fillable = ['question', 'answer', 'sort_order'];
    }
    
  5. First use case — a reorderable list (FAQ entries, menu items, kanban cards). The canonical first exercise:

    $faq = Faq::create(['question' => 'How do I reorder?', 'answer' => 'Move me.']);
    $faq->moveUp();       // or ->moveDown() / ->moveToStart() / ->moveToEnd()
    

Read the trait source (vendor/james.xue/laravel-sortable/src/Sortable.php) as your documentation — method names, the column it sorts by, and scoping behavior are not spelled out elsewhere and this is exactly the kind of package where the source is the README.

Implementation Patterns

Scoped ordering (per group). Most real use cases are "sortable within a parent" — items inside a category, tasks inside a project. The typical trait shape is a sortableGroup or $sortable property you override:

class Faq extends Model
{
    use Sortable;

    protected $sortableGroup = 'category_id'; // override to whatever the trait calls it
}

If the trait does not support grouping out of the box, override the sortableQuery() hook (or copy the trait and add a where('category_id', $this->category_id) clause). This is the single most common reason people fork these packages.

Bulk reorder from the frontend. Drag-and-drop sends an ordered array of IDs; do not call moveUp() in a loop. Instead, in a transaction, iterate and set the position directly:

DB::transaction(function () use ($ids) {
    foreach ($ids as $position => $id) {
        Faq::whereKey($id)->update(['sort_order' => $position]);
    }
});

This is dramatically faster than N sequential moveUp() calls (each of which issues its own shift queries), and it keeps the list dense and gap-free — which is what drag-and-drop UIs expect.

New records get a sane position. If the trait does not populate sort_order automatically, add a creating hook so new items land at the end rather than at 0 (which silently puts them at the top of every list):

protected static function booted(): void
{
    static::creating(function ($model) {
        $model->sort_order ??= static::max('sort_order') + 1;
    });
}

Query sorted lists explicitly. Do not rely on implicit ordering. Wrap with a local scope:

public function scopeOrdered($query)
{
    return $query->orderBy('sort_order')->orderBy('id');
}

The ->orderBy('id') tiebreaker is important — duplicate sort_order values are almost inevitable if you ever do bulk updates or seed data, and without a tiebreaker your list order can shuffle between requests on different DBs.

Deleting an item mid-list. After a delete, either renumber or accept gaps. Gaps are fine if you only ever use relative moves (moveUp/moveDown); they are not fine if you paginate or display "position 3 of 5". Pick one and be consistent.

API endpoint shape. A minimal drag-drop handler:

public function reorder(ReorderRequest $request)
{
    $this->authorize('reorder', Faq::class);
    Faq::reorder($request->validated()['ids']); // custom scope wrapping the transaction above
    return response()->noContent();
}

Integration with Filament/Nova/Livewire. These admin tools either ship their own ordering or let you override the default sort. Bind the model's ordering to sort_order in the resource (defaultSort in Filament, indexQuery in Nova) so admin lists look the same as the public site.

Gotchas and Tips

Vet the package before production. Two stars and zero dependents means: no security surface review, no guarantee of Laravel 11/12 compatibility, and no issue triage. Pin the version in composer.json, fork it, or copy the ~100-line trait into app/Models/Concerns/Sortable.php and take ownership. The pattern is trivial enough that copying is often the correct move — you get an App namespace, tests you control, and no surprise breakage on Laravel upgrades.

Column name and grouping are not self-documenting. Verify by grepping the source:

grep -n "sort\|order\|group" vendor/james.xue/laravel-sortable/src/*

Everything you need is there; do not guess and then debug weird ordering.

Concurrent reorders race. Without locking, two users dragging at once can interleave updates and produce duplicate positions. Wrap bulk reorders in DB::transaction() and a pessimistic lock on the affected rows (->lockForUpdate()), or serialize reorders per group with a cache lock:

Cache::lock("reorder:{$categoryId}", 5)->block(3, fn () => ...);

Index the sort column. Sorted reads on a table over a few thousand rows will table-scan without a composite index matching your grouping: ->index(['category_id', 'sort_order']). Cheap to add now, painful to add in a hot path later.

Unique constraints fight this pattern. If you have unique(['category_id', 'sort_order']), every moveUp() will transiently violate it (two rows at the same position). Either drop the constraint or renumber inside a transaction using a temporary offset (add +1000 to all rows, then subtract). Most packages ignore this and you'll only discover it via a production error.

moveUp/moveDown on the first/last item. Behaviour varies: some no-op, some swap with a non-existent neighbor and corrupt positions. Test both edges explicitly in your model test suite, and treat a no-op as the correct outcome in your code regardless.

Bulk updates bypass model events. ->update(['sort_order' => ...]) will not fire updated/saved watchers on the models. If you have observers (e.g. cache invalidation, search index sync), call ->saveQuietly() per model instead, or invalidate the cache/index manually after the transaction.

Ordering after orderBy on the relation. If you eager-load $category->load('faqs'), the ordering comes from the relationship definition, not from the trait — add ->ordered() to the relation in Category::faqs().

Extension points. The genuinely useful places to override are: (a) the sort-column property, (b) the group-scope builder method, (c) the "new item at the end" boot hook if it does not ship one. If the trait does not expose these, fork — do not use reflection hacks to force them.

Debugging checklist when ordering looks wrong: confirm the column exists and is populated for all rows (legacy rows with NULL sort first/last depending on the DB driver); confirm the query has no conflicting orderBy from a global scope; confirm duplicate positions (there is no unique constraint telling you) with GROUP BY sort_order HAVING COUNT(*) > 1.

Do not paginate a sortable list without a stable tiebreaker. orderBy('sort_order') alone with duplicate values is non-deterministic across pages, which looks exactly like "the drag-and-drop lost my change." Always add ->orderBy('id').

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk