components/tabledrag
Components/TableDrag adds drag-and-drop row reordering to Laravel tables. Sort rows with a simple handle, persist the new order to your database, and integrate with Blade or Livewire views. Lightweight, framework-friendly, and easy to drop into existing admin listings and data grids.
Know what you're installing. components/tabledrag is not a Laravel package — there is no service provider, no config file, no facade, no PHP class. It is a Composer-distributed front-end asset package: a fork of Drupal 8's core/misc/tabledrag.js plus its CSS, published under the components/* vendor namespace (the old Component installer convention). Everything you integrate lives in Blade, your asset pipeline, and a controller.
Minimal steps:
composer require components/tabledrag and check where it lands — with 2018-era components/* packages this is usually components/tabledrag/ (via oomphinc/composer-installers-extender) rather than vendor/. Confirm the actual path before wiring anything.tabledrag.js / tabledrag.css and the package's README for the constructor signature. Since it's a straight fork, the authoritative reference is the Drupal 8 API docs for Drupal.tableDrag — the fork will match those semantics, not any Laravel-specific docs (there are none).resolve.alias, or copy into resources/js/vendor/).id, rows carrying the draggable class, a weight input per row, and a parent <select> in an indentation cell.The canonical first use case is a self-referencing category/tree table: id, parent_id, weight. Drag to reorder siblings, drag right to indent (reparent). It's the classic Drupal taxonomy/menu UI, and it's the only thing this library is good at.
<table id="categories" class="tabledrag">
<thead><tr><th></th><th>Name</th><th>Parent</th><th>Weight</th></tr></thead>
<tbody>
@foreach ($categories as $category)
<tr class="draggable" data-id="{{ $category->id }}">
<td class="drag"></td>
<td>{{ $category->name }}</td>
<td class="indentation">
<select name="parent[{{ $category->id }}]">
<option value="0">Root</option>
@foreach ($categories as $candidate)
<option value="{{ $candidate->id }}" @selected($category->parent_id === $candidate->id)>
{{ $candidate->name }}
</option>
@endforeach
</select>
</td>
<td><input class="tabledrag-weight" type="number" name="weight[{{ $category->id }}]" value="{{ $category->weight }}"></td>
</tr>
@endforeach
</tbody>
</table>
Then, once the assets are loaded:
// after jQuery is on window
new Drupal.tableDrag(document.getElementById('categories'), {
// settings mirror Drupal 8: indent, maxDepth, rowClass, etc.
});
Verify the exact option names against the file you actually installed — do not trust blog posts about Drupal 9/10, which diverged.
Treat it as vendored source, not a dependency. With 1 star, 0 dependents, and a last release in January 2018, pin the version, or better, copy the JS/CSS into resources/ and commit it. That removes a dead package from your lockfile and makes future patching trivial.
Asset pipeline. jQuery must be a real global before this script evaluates — the code is written against window.jQuery/window.$, not a module import. In Vite:
// vite.config.js
resolve: { alias: { '~tabledrag': path.resolve(__dirname, 'components/tabledrag') } }
import $ from 'jquery';
window.$ = window.jQuery = $;
import '~tabledrag/tabledrag.css';
import '~tabledrag/tabledrag.js'; // expects Drupal/drupalSettings globals — see Gotchas
Persistence pattern: one endpoint, one transaction. The library only mutates the DOM. Nothing is submitted. Serialize the whole tree on drop and PATCH it:
// routes/web.php
Route::patch('/categories/tree', [CategoryTreeController::class, 'update'])->name('categories.tree');
public function update(UpdateTreeRequest $request)
{
$rows = collect($request->validated('rows'))->keyBy('id');
DB::transaction(function () use ($rows) {
foreach ($rows as $id => $row) {
// guard: no self-parent, no cycles (see Gotchas)
Category::whereKey($id)->update([
'parent_id' => $row['parent_id'] ?: null,
'weight' => $row['weight'],
]);
}
});
return response()->noContent();
}
A FormRequest with rows.*.id => ['required','integer','exists:categories,id'], rows.*.parent_id => ['nullable','integer','exists:categories,id','not_in:rows.*.id'], and rows.*.weight => ['required','integer'] covers 90% of the garbage the client can send.
Hook the drop, don't poll. Bind your own listener on the row's change/drop (or wrap the prototype method — see the extension notes below) and debounce a fetch. Do not attempt to intercept Drupal's internal tabledrag-changed bookkeeping; it exists to manage hidden inputs, not to emit a clean public event.
Blade structure. Recursive partial (resources/views/categories/_row.blade.php) rendering children in pre-order with depth-based left padding in the indentation cell. The library keys off DOM order and the weight/parent inputs, so pre-order + correct weight values is what makes a reload look identical to the drag result.
Livewire/Inertia caveat. This is a DOM-mutating jQuery plugin. Under Livewire, wrap the table in wire:ignore and re-init on livewire:navigated; otherwise every morph wipes the drag state and the handles go dead. Under Inertia this is a non-issue, but re-init after each page visit.
When not to use it. If you only need flat reordering (no nesting, no parent select, no accessibility tree), SortableJS is smaller, maintained, framework-agnostic, and MIT. This package earns its keep only for the indentation/parent-select/maxDepth behavior, which is genuinely fiddly to reimplement.
It expects Drupal globals. The forked code path likely references Drupal.t(), Drupal.behaviors, and drupalSettings.tableDrag. The fork may or may not have stripped these. Before anything else, grep the file: grep -nE 'Drupal\.|drupalSettings' components/tabledrag/*.js. If present, shim them:
window.Drupal = window.Drupal || {};
window.Drupal.t = (s) => s;
window.drupalSettings = window.drupalSettings || { tableDrag: {} };
Missing shims throw on load and the failure is silent from the UI's perspective — rows just don't drag.
No PHP surface means no guardrails. No config/tabledrag.php, no publishable views, no artisan command. There is nothing to php artisan vendor:publish. Don't waste time looking.
Licensing. GPL-2.0, inherited from Drupal core. Drupal ships it as GPL because Drupal is GPL. Minifying/bundling it into a proprietary compiled asset is a legal question, not a technical one — flagging it to counsel is cheaper than a rewrite later, and MIT-licensed alternatives exist for most of the use cases.
Cycle prevention is server-side only. The client will happily let a user (or curl) make node 3 the parent of node 1 which is the parent of node 3. Walk ancestors before saving, or you get an infinite loop the next time you render the recursive Blade partial.
weight inputs must be real form inputs with names. Drupal's implementation writes weight and the parent selection into those inputs as you drag so a normal form POST round-trips. If you render a <span> for weight to save space, the drag visually works but you have nothing to serialize — you'd have to read weights from DOM order instead, which is fragile. Match the markup.
Indentation depends on the parent <select> being inside the row's .indentation cell. Put it in a different column and the depth clamping breaks; the drag-right gesture drives selectedIndex on that element.
Debugging checklist, in order: (1) is jQuery on window before the script? (2) did the shims not throw? (3) does the <tr> carry draggable after init ($('tr').hasClass('draggable'))? (4) is the table id correct? (5) is the CSS loaded — without it the handle column has zero width and is un-grabbable, which reads as "drag is broken."
Extension points are prototype overrides, not events. The stable hooks in Drupal's implementation are the Drupal.tableDrag.prototype methods (e.g. the row-dragging and row-dropping routines). Because this is a forked snapshot, override the prototype methods by name (with a comment noting the pinned version) rather than monkey-patching internals, and re-check after any vendor update — of which there will be none, which is both the risk and the mitigating factor.
Unmaintained since 2018 means jQuery-era browser assumptions. Don't expect it to be validated against modern touch/pointer events on tablets. Test mobile drag explicitly; if it fails, a "move up / move down / change parent" form fallback is the pragmatic answer and also fixes keyboard accessibility, which this library inherits from a pre-accessibility-focused Drupal release.
How can I help you explore Laravel packages today?