fsi/admin-positionable-bundle
Symfony bundle that adds positionable behavior to FSi Admin lists, letting administrators reorder entities with drag-and-drop controls and persist sort positions. Integrates with Doctrine entities and the FSi AdminBundle to provide sortable admin grids and position management.
This is a Symfony + Sonata Admin bundle, not a Laravel package. There is nothing to composer require into an app/ directory — treat this assessment as a spec for the feature (up/down reordering in an admin list) and decide whether to port the idea or take the dependency.
If you are working inside a Symfony/Sonata codebase:
composer require fsi/admin-positionable-bundle
This pulls in fsi/doctrine-positionable-extension, which owns the actual position bookkeeping. The admin bundle is only the UI/route layer.config/bundles.php, after SonataAdminBundle:
FSi\Bundle\AdminPositionableBundle\FSiAdminPositionableBundle::class => ['all' => true],
position integer column and implement the positionable contract from the extension package:
use FSi\Component\DoctrinePositionable\Model\PositionableInterface;
class Category implements PositionableInterface
{
/** @ORM\Column(type="integer") */
private $position = 0;
public function getPosition(): int { return $this->position; }
public function setPosition(int $position): void { $this->position = $position; }
}
class CategoryAdmin extends AbstractAdmin implements PositionableAdminInterface
{
protected function configureListFields(ListMapper $list): void
{
$list->add('position');
// up/down buttons are injected by the bundle's admin extension
}
}
Where to look first when you open the repo: DependencyInjection/Configuration.php (what is actually configurable), Admin/ (the Sonata AbstractAdminExtension that adds the buttons — this is the whole feature), Controller/ (the move endpoint), Resources/config/routing.xml (the route name you'll link to), and Resources/views/ (the two arrow templates you'll override).
First use case: a manually curated ordered list — nav categories, FAQ entries, homepage blocks, banner slides — where an editor needs to nudge an item up or down without typing a sort number.
Laravel equivalent: Spatie eloquent-sortable plus a Filament table with ->reorderable('position'), or a Nova resource with a sortable() field plus custom up/down actions.
Interface-on-admin (the normal path). Implementing PositionableAdminInterface on the admin is the least-magic integration: the bundle's extension sees the marker, appends the buttons to the list row, and points them at the move route using the admin's code and the object id. Every admin that should be reorderable opts in explicitly — no global behaviour to reason about.
Admin extension for cross-cutting rollout. If you have fifteen lookup-table admins, register a Sonata admin extension instead of editing each class. Target it by admin code or by entity class so unrelated admins (users, logs) never get the arrows:
services:
app.admin.extension.positionable:
class: FSi\Bundle\AdminPositionableBundle\Admin\PositionableAdminExtension
tags:
- { name: sonata.admin.extension, target: app.admin.category }
Order the query, always. The bundle changes the value in the DB; it does not change what the grid shows. Sonata's datagrid defaults to id:
protected function configureDefaultSortValues(array &$sortValues): void
{
$sortValues['_sort_by'] = 'position';
$sortValues['_sort_order'] = 'ASC';
}
Without this the arrows "work" but the row visually stays put until the next page load — the single most common report against this style of bundle.
Bypass the UI for bulk operations. For imports, seeders, or a console command, do not drive the Sonata route. Resolve the extension's positionable manager (or the Doctrine listener's service) and set positions directly, then flush. This is what you want for "reset ordering to alphabetical" style maintenance tasks.
Scope positions when lists are nested. Position is a column on the table, so a global counter mixes categories from different parents. If your list is filtered (e.g. children of one menu), add a discriminator column and renumber within it, or the up/down swap will trade places with an item the user cannot see. The bundle has no built-in grouping concept — this is the first thing to design around.
Template overrides, not forks. Symfony resolves overrides at templates/bundles/FSiAdminPositionableBundle/<original path>. Copy the arrow template, restyle to your admin theme, keep the bundle updatable. If you need to change the route or add CSRF, override the controller instead — a route definition in your app config that points at your own action beats patching vendor code.
Laravel porting pattern. If the target is Laravel: one position unsigned integer column, Spatie Sortable trait, Filament ->reorderable('position'), and a moveUp/moveDown action that swaps with the nearest neighbour scoped by the same parent id inside a transaction. That is the entire bundle, modulo Sonata plumbing.
It is effectively unmaintained. Last release August 2019, one star, zero dependents. That era means Sonata Admin 3.x and Symfony 3.4/4.x — expect Composer to refuse on Symfony 5/6 or PHP 8, and expect no fixes for anything. Pin versions and budget for replacing it, or fork it. For a new Laravel project this is a conceptual reference only.
The move endpoint needs its own access check. It is a separate route from the CRUD screens, so Sonata's checkAccess on edit does not automatically apply. Verify the controller calls checkAccess('EDIT') (or wrap the route in a firewall rule), and confirm there is CSRF protection on the link — many 2019-era bundles used a plain <a href> for the arrows. If not, convert them to POST forms or accept that a crafted GET can reorder your content.
Position gaps are normal, but swaps may assume adjacency. Deletes leave holes (1,2,5,6). That is fine for ORDER BY position, but if the move implementation fetches position - 1 rather than the previous row, an up-click can silently do nothing. Read the manager's moveUp/moveDown before trusting it on a table that has seen deletions, and run a periodic renumber if it does assume contiguity.
No concurrency control. Two editors clicking simultaneously produce last-write-wins and duplicated positions. Wrap moves in a transaction and re-read the row, or add a unique constraint on (scope, position) and accept the retry. Cheap to do, invisible until it bites.
Insert failures on a non-null column. If position is NOT NULL with no default and the Doctrine listener is not registered (bundle order, cache not cleared, listener not tagged), every insert fails with a constraint violation. Check doctrine.event_subscriber in the container before debugging your entity.
Pagination makes the arrows lie. Moving the last row of page 1 "down" swaps it with the first row of page 2 — the user sees it disappear. Either disable the arrows at page boundaries or render the list unpaginated for short lists.
Debugging checklist, in order: does the arrow render (extension registered / interface implemented) → does clicking hit the route (profiler, look for the admin code and object id in the URL) → does the position column actually change in the DB (listener registered) → does the grid re-sort (default sort values). Each failure mode has exactly one cause; do not skip a step.
Extension points worth knowing: decorate the positionable manager service to add logging or scoping, override the move controller for CSRF/redirect behaviour, and swap the arrow templates via bundle override. Avoid subclassing the bundle's admin extension — it is small and the coupling will outlive the package.
How can I help you explore Laravel packages today?