Looking to hire Laravel developers? Try LaraJobs

laravel-list-ordering maintained by exileofaranei

Description
Tight ordering of records within grouped lists using fractional indices: insertion and movement modify a single row, not the entire list.
Author
Last update
2026/07/26 02:51 (dev-main)
License
Downloads
4

Comments
comments powered by Disqus

Laravel List Ordering

Latest Version on Packagist GitHub Tests Action Status Total Downloads

Tight ordering of records within grouped lists using fractional indices: insertion and movement modify a single row, not the entire list.

A list is defined by an arbitrary set of column values on the model — a composite key, a single column, or none at all (one global list). Order is stored as a byte-sortable string rank, not an integer position. Reordering within a list and moving between lists go through the same primitive: placeInto(), expressed entirely in terms of neighbor anchors, never integer positions.

The package knows nothing about the domain it's ordering. It has no concept of trees, hierarchies, or nested sets — it orders flat lists, however those lists are grouped.

Installation

Install via Composer:

composer require exileofaranei/laravel-list-ordering

The service provider registers itself through Laravel package auto-discovery — no manual registration needed.

The package publishes no migration and no config file. You write your own migration for each model that needs ordering, using the orderingRank() Blueprint macro the package registers:

Schema::create('tasks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('board_id');
    $table->string('column');
    $table->orderingRank(); // 64-char rank column, byte-comparable collation per driver
    $table->timestamps();

    $table->unique(['board_id', 'column', 'rank']);
});

The unique index always covers the model's group columns plus the rank column — this is what a concurrent write collides against, and what list-ordering:check-index (below) checks your migration against.

Usage

Declaring a model

A model implements Orderable and uses the HasOrdering trait. The trait supplies everything except orderingGroupColumns() — the one method that says which columns define the model's list.

Composite key (both columns not null):

use ExileOfAranei\ListOrdering\Concerns\HasOrdering;
use ExileOfAranei\ListOrdering\Contracts\Orderable;

class Task extends Model implements Orderable
{
    use HasOrdering;

    public function orderingGroupColumns(): array
    {
        return ['board_id', 'column'];
    }
}

Single column:

class GalleryImage extends Model implements Orderable
{
    use HasOrdering;

    public function orderingGroupColumns(): array
    {
        return ['gallery_id'];
    }
}

Empty list of columns (one global list spanning the whole table):

class LandingFeature extends Model implements Orderable
{
    use HasOrdering;

    public function orderingGroupColumns(): array
    {
        return [];
    }
}

Placing and moving records

placeInto() is the one primitive. Reordering within a list and moving to a different list are the same call:

use ExileOfAranei\ListOrdering\Support\GroupKey;

// Insert a new task at the end of a board column.
$task->placeInto(GroupKey::of(['board_id' => 42, 'column' => 'todo']), $lastInColumn, null);

// Move it to a different column, between two existing tasks.
$task->placeInto(GroupKey::of(['board_id' => 42, 'column' => 'done']), $doneFirst, $doneSecond);

Anchors define bounds, not required adjacency — if other rows already sit between the two anchors you pass, the rank is still generated strictly between them. Any anchor you omit is resolved automatically (the next/previous element, or the end of the list if neither is given).

Thin wrappers read by intent, all delegating to placeInto():

$task->placeAtEnd(GroupKey::of(['board_id' => 42, 'column' => 'todo']));
$task->placeAtStart(GroupKey::of(['board_id' => 42, 'column' => 'todo']));
$task->placeAfter($anchorTask);  // target group is $anchorTask's group right now
$task->placeBefore($anchorTask);

Reading a list in order

Task::ordered(GroupKey::of(['board_id' => 42, 'column' => 'todo']))->get();

Without a GroupKey, ordered() only sorts — it does not, and cannot, guarantee order across different groups in the result. Only omit it when the query is already narrowed to one group some other way (an already-scoped relationship, for example).

What the guard does — and doesn't — protect

A saving guard rejects direct writes to the group or rank columns on an already-persisted model, so a typo like $task->column = 'done'; $task->save(); fails loudly instead of silently corrupting the list. create(), factories, and seeders are unaffected — the guard only fires on updates to an existing row.

The guard does not see insert(), upsert(), withoutEvents(), or any bulk write that bypasses Eloquent's saving event. It is a developer-experience convenience, not the integrity guarantee — that's the database-level composite unique index (group columns + rank), which holds regardless of how a write reaches the table.

Checking your index hasn't drifted

Since migrations are immutable and a model's orderingGroupColumns() can change over time, nothing stops them from silently diverging. Two ways to catch it, both backed by the same check, neither run automatically:

php artisan list-ordering:check-index "App\Models\Task"
php artisan list-ordering:check-index --all
use ExileOfAranei\ListOrdering\Testing\AssertsOrderingIndex;

class TaskOrderingTest extends TestCase
{
    use AssertsOrderingIndex;

    public function test_ordering_index_matches(): void
    {
        $this->assertOrderingIndexMatches(Task::class);
    }
}

Testing

composer test

Tests run against fixture models from an intentionally unrelated domain via orchestra/testbench — the package's own test suite never references a consuming application. CI runs the full suite against SQLite, MySQL, MariaDB, and PostgreSQL, since collation bugs are invisible on SQLite alone.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.