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 Eloquent Position Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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".

Implementation Patterns

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.

Gotchas and Tips

  • Don't put a unique index on (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.
  • Concurrency. Two simultaneous creates in the same group can compute the same next position. Wrap creates in a transaction with lockForUpdate() on the sibling rows, or accept occasional duplicate positions and normalise on read (orderBy('position')->orderBy('id')) so the list stays deterministic.
  • Bulk writes bypass the trait. 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.
  • Upgrade to ^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.
  • Gaps and soft deletes. Deleting a row leaves a hole (soft deletes don't fire 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.
  • Keep position out of $fillable. Otherwise a crafted request can pin a record to an arbitrary slot and create duplicates.
  • Reordering is O(n) writes. Each move shifts the rows between source and target. Fine for tens of rows per group, wrong for thousands — if a group grows past a few hundred, switch to spaced integers (1000, 2000, …) or fractional/lexicographic ranks.
  • Aggregate queries need the scope. min('position') / max('position') must include the same group where clauses the trait uses, or "is this the first item?" checks lie.
  • Debugging. Log 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.
  • Version risk. Releases are infrequent, so check 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.
  • Extension points. The column and group are plain properties on the model — override them per model rather than forking the package. If you need custom semantics (e.g. moving across groups), implement it in a model method that wraps the trait's primitives inside your own transaction instead of editing vendor code.
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.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator