pion/laravel-eloquent-position
A Laravel Eloquent package that adds sortable position columns to your models. Automatically manage record ordering, move items up, down, or to a specific position, and query models in sequence with minimal setup. Useful for drag-and-drop lists, menus, and any ordered data.
The package is deliberately small: one trait that keeps an integer position column in sync, exposes move operations, and hooks model deletion. Because it has a single maintainer, nine stars and no dependents, treat the source in src/ as the documentation of record — read the trait before wiring it into anything important, and confirm the exact trait name and method signatures against the README for the version you install.
1. Install and add the column. The package does not ship a migration; you own the schema.
composer require pion/laravel-eloquent-position:^1.1.3
Schema::table('articles', function (Blueprint $table) {
$table->unsignedInteger('position')->default(0);
$table->index(['category_id', 'position']); // scope + sort
});
2. Attach the trait and declare the ordering scope.
use Pion\Laravel\EloquentPosition\HasPosition; // verify namespace/trait name in the README
class Article extends Model
{
use HasPosition;
protected $positionColumn = 'position';
protected $positionGroup = ['category_id']; // rows are ordered *within* this group
}
3. First use case — a per-category list with up/down buttons. Read the list with ->orderBy('position'), and expose moveUp() / moveDown() (or whatever the trait names them) on the model. That single scenario exercises everything the package does: append-on-create, scoped numbering, shifting neighbours, and — once you delete a row — the deletion hook fixed in 1.1.3.
Where to look first, in order: the trait's boot method (what it hooks into creating/deleting), the group/column properties, then the tests — they are the fastest way to see intended semantics for edge cases like "move first item up" and "delete a row with a string primary key".
Append-on-create with a scope. Let the trait assign the position on creating rather than computing max(position) + 1 yourself; that's the whole point of the package. Anything that bypasses Eloquent events (seeders using insert(), raw SQL, upsert) silently produces duplicates at position 0 — see Gotchas.
Controller endpoints for reordering. Keep the model thin and put authorisation in front of every mutation:
public function moveUp(Article $article)
{
$this->authorize('update', $article);
$article->moveUp(); // re-reads siblings inside the group
return back();
}
Drag-and-drop / explicit target position. For a full reorder from a JS table, prefer one transactional endpoint that accepts an ordered array of IDs and writes positions in sequence, rather than N calls to moveToPosition(). N calls means N shifts and N round-trips:
DB::transaction(function () use ($ids, $categoryId) {
Article::where('category_id', $categoryId)
->whereIn('id', $ids)
->lockForUpdate()
->get();
foreach ($ids as $i => $id) {
Article::whereKey($id)->update(['position' => $i + 1]);
}
});
Deletion inside a group (1.1.3+). v1.1.3 adds support for deleting models whose primary key isn't the conventional auto-incrementing id — UUID/ULID keys, string keys, or a custom $primaryKey. If your reorderable models use non-integer identifiers, require ^1.1.3 and cover the delete path with a feature test:
$article = Article::create([...]); // position 3, say
$article->delete();
$this->assertSame(
[1, 2, 3, 4],
Article::where('category_id', $id)->orderBy('position')->pluck('position')->all()
);
If you'd rather not rely on the trait's renumbering semantics at all, delete normally and run your own compaction command — the fix is narrow, so treat it as "deletes no longer misbehave" rather than "deletes are transactional and race-free".
Scoped lists as a first-class concept. If you have several boards/categories on one page, group the queries by scope and render each list independently — never mix a global orderBy('position') with per-group moves, or the UI will disagree with the DB.
Pair with an API resource. Expose position read-only and add a can_move_up/can_move_down flag computed from min/max in the group so the client doesn't render dead buttons.
Cache invalidation. If you cache an ordered list, flush on create, delete and move — moves typically don't touch updated_at in a way your observers will catch.
(group, position). A swap or shift transiently duplicates positions and will blow up mid-transaction on MySQL/Postgres. Use a plain index; enforce uniqueness in application code under a lock, or use a deferred constraint if your driver supports it.lockForUpdate() on the sibling rows, or accept occasional duplicate positions and normalise on read (orderBy('position')->orderBy('id')) so the list stays deterministic.Model::insert(), upsert(), update() on a query builder, and ->each() over a chunk without saving all skip the creating hook. Model::where(...)->delete() skips deleting too, so the 1.1.3 deletion fix only applies to per-model delete() calls — mass deletes still leave gaps. Seeders are the classic offender: seed sequentially with create() or assign positions explicitly.^1.1.3 if you use non-id keys. Before 1.1.3 the deletion path assumed an integer primary key, so models with UUID/ULID or string keys could throw or leave stale positions behind on delete. Pin the version, and if you can't upgrade, override the delete handling in your model (delete + compact inside a transaction) rather than patching vendor code. Composite primary keys remain untested territory — verify before relying on them.deleting at all by default); if you rely on position == count assumptions in the UI, add a normalisation command (php artisan positions:compact) rather than renumbering on every delete.position out of $fillable. Otherwise a crafted request can pin a record to an arbitrary slot and create duplicates.min('position') / max('position') must include the same group where clauses the trait uses, or "is this the first item?" checks lie.DB::listen() while calling a move or a delete and read the generated UPDATEs — that's the fastest way to see which group predicates the trait actually applies. Test against SQLite in-memory for wiring, but verify reorder and delete behaviour on your production driver: SQLite is lenient about constraints, string-key collation and NULL ordering in ways MySQL isn't.composer.json constraints against your Laravel/PHP version before committing; you may need "ignore-platform-reqs" or a fork. Pin the version in composer.json and cover the trait with your own feature tests so a future Laravel upgrade — or the next patch like 1.1.3 — surfaces breakage in CI rather than in the reorder button.How can I help you explore Laravel packages today?