wedevelopnl/ux-table
A Laravel package for quickly building interactive data tables with minimal code. It supports sortable columns, search, filters, pagination, and row actions, plus a customizable, developer-friendly API so you can tailor each table's layout and styling to fit your application's UI.
Before anything else: this is a Symfony package (Symfony UX + Twig + Stimulus), not a Laravel one. A Laravel developer can still use it as an architectural reference, but adopting it directly means running Symfony. Treat it as "Symfony UX Table" first and "a Laravel table abstraction blueprint" second.
Minimal path:
composer.json — PHP 8.1+, Symfony 6.x, and symfony/ux-twig-component / Stimulus. It expects the Symfony UX asset pipeline (AssetMapper or Webpack Encore), so budget an hour for controller registration if you're on Encore.composer require wedevelopnl/ux-table. Flex should register the bundle; if not, add it to config/bundles.php and check for a config/packages/*.yaml recipe.php bin/console importmap:require ... or your controllers.json), and import the package CSS or write your own against its class hooks.src/ in this order — the abstract table/column classes, the filter types, the Twig component template(s), and the Stimulus controller. The Stimulus controller is what actually owns search/sort/filter state on the client and is the least documented part; read it before you trust the README.First use case worth building: a paginated user/order list with one global search box, a sortable "created at" and "name" column, and a single select filter. That exercises every feature the package advertises without touching custom column renderers.
// shape will vary by version — verify names in src/
final class UserTable extends AbstractTable
{
public function configure(TableBuilder $builder): void
{
$builder
->addColumn('name', sortable: true, searchable: true)
->addColumn('email', searchable: true)
->addColumn('createdAt', sortable: true)
->addFilter('status', ChoiceFilter::class, ['choices' => UserStatus::cases()]);
}
}
Table-as-a-class, route-as-a-controller. The idiomatic flow is: a class describes columns/filters/searchable fields, a thin controller action builds the query and hands it to the table, Twig renders it. Keep the table class purely declarative — no repository calls inside it — so it stays testable and swappable.
Query building is your job, not the package's. Search, sort, and filter parameters arrive as request/query params; you apply them to a Doctrine QueryBuilder (or a plain array for the non-ORM path). The package gives you the plumbing and the UI, not a magic ORM layer. Practical order:
$qb = $userRepo->createQueryBuilder('u')->leftJoin('u.company', 'c')->addSelect('c');
$table = UserTable::create($qb)
->handleRequest($request); // applies search/filter/sort params + pagination
return $this->render('user/index.html.twig', ['table' => $table]);
Always leftJoin(...)->addSelect(...) for any column you display from a relation. This is the single biggest day-to-day difference between a table that feels fast and one that doesn't.
Filters: prefer typed filters over free-text. ChoiceFilter/entity filters map cleanly onto WHERE x IN (:...) and survive validation. Free-text filters need per-field LIKE handling and a decision about wildcards.
Search strategy matters more than the search UI. Global search over multiple columns is usually WHERE a LIKE :t OR b LIKE :t. On MySQL/InnoDB that's a full scan the moment you lead with %. For anything past a few thousand rows, either restrict search to indexed prefix columns or back it with a MATCH ... AGAINST / Postgres pg_trgm / tsvector column and route the search term there.
Sorting: whitelist, always. Map the incoming sort param through the table's declared sortable columns before touching orderBy(). Never interpolate a column name from the request.
URL as the source of truth. The Stimulus-driven updates are the happy path; the durable pattern is pushing search/sort/filter/page into the query string with history.pushState. Users share links to filtered views, refresh works, and back button works. If the package's controller doesn't do this for you, wrap it with a small custom Stimulus controller that owns state and calls the package's fetch.
Rendering: prefer Twig block overrides over PHP string building. Headers, empty states, and cell contents should be overridable templates. If a column needs a badge or a link, override that column's block rather than returning HTML from PHP.
Integrate with forms when filters get complex. Date ranges, multi-selects, and dependent filters quickly outgrow ad-hoc query params — bind the filter set to a Symfony Form for validation and normalization, then translate the bound data into table parameters.
Testing. Because state is in the query string, table behaviour is trivially testable with $client->request('GET', '/users?search=foo&sort=-createdAt') and asserting on rendered rows. Do that instead of unit-testing the table class; the value is in the query construction, and a functional test covers it end to end.
Laravel translation of the pattern (if you're evaluating it for a Laravel codebase): the equivalent is Livewire's WithPagination + a query-string-bound public property set, or Inertia + a server-side table resource. Copy the declarative table class + whitelisted sort map + URL-as-state triad; do not try to port the Twig/Stimulus layer.
src/ and the Twig templates to answer basic questions (what block names exist? what param names does the controller send?). Budget for that in estimation; it's a small package, not a framework.COUNT(*) over a filtered, joined query is often the slowest part of the request. Select only the IDs you need, or cache the count per filter signature for a few minutes.-createdAt vs createdAt:desc vs direction=desc — the convention is version-specific. Read the controller, don't guess; a silently ignored sort direction is a nasty bug because it looks like it works.aria-sort and be real <button>s, not clickable <th>s. Filter inputs need labels. The package may not do this — check, and patch in your overrides.data-controller attributes on the rendered table before debugging PHP.composer update can jump you across breaking changes with no migration guide. Constrain to ^x.y and diff src/ before bumping.How can I help you explore Laravel packages today?