hello-sebastian/hello-bootstrap-table-bundle
A Symfony bundle for creating Bootstrap-styled data tables with sorting, filtering, and pagination. It simplifies rendering interactive tables and integrating them cleanly into Symfony applications.
This is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no config/ publish tag. composer require hello-sebastian/hello-bootstrap-table-bundle inside a Laravel app will pull symfony/framework-bundle-adjacent dependencies into your vendor tree and then never boot — there is no config/bundles.php to register it in. Your first decision is therefore not "how do I install it" but "am I porting this or standing up a sidecar Symfony app".
Where to look first, in order:
src/ — the Table and Column base classes. Everything in the bundle is a thin DSL over column definitions plus a Doctrine QueryBuilder data provider. Read these two files before anything else; they are the whole product.tests/ — the fastest way to see the expected JSON envelope and the exact request parameters the front end sends. After v0.8.1, the tests are also the only place the filter-control behaviour is pinned down.Resources/views/ — the Twig template that emits the <table data-*> markup and the JS init. This is the contract you'll reproduce in Blade.README — setup, bundle registration, and the route the bundle exposes for AJAX data.Minimal first use case: an admin index page for one model. Define a Table subclass, add a handful of columns (text, date, boolean, action), register it, and render it. The value is not the rendering — it's that the column list and the query live in one class instead of being duplicated across a controller, a Blade view, and an export.
For a Laravel codebase, the realistic path is lift the pattern, not the package. The front end (bootstrap-table) is framework-agnostic; you only need to reimplement the backend half.
The contract you actually have to match. bootstrap-table in server-side mode issues a GET with limit, offset, sort, order, and search, and expects back {"total": <int>, "rows": [...]}. If the filter-control extension is enabled, it additionally sends a filter parameter — a JSON object mapping column keys to the typed filter values. v0.8.1 is the release that fixes the bundle's handling of exactly this pair (search + filter control), so the reference implementation is now worth copying from rather than working around.
Table class as the single source of truth. Mirror the bundle's structure: one class per resource exposing query(): Builder and columns(): array. Then derive both the HTML and the JSON from it:
abstract class Table
{
abstract public function query(): Builder;
abstract public function columns(): array;
public function paginate(Request $r): array
{
$base = $this->query();
$filtered = (clone $base)
->when($r->filled('filter'), fn ($q) =>
$q->where(fn ($q) => $this->applyFilters($q, json_decode($r->filter, true) ?? [])))
->when($r->filled('search'), fn ($q) =>
$q->where(fn ($q) => collect($this->searchable())
->each(fn ($c) => $q->orWhere($c, 'like', "%{$r->search}%"))));
return [
'total' => (clone $base)->count(),
'rows' => $filtered->orderBy($this->safeSort($r->sort), $r->order === 'desc' ? 'desc' : 'asc')
->offset((int) $r->offset)->limit((int) $r->limit)->get(),
];
}
protected function applyFilters($q, array $filters): void
{
foreach ($filters as $key => $value) {
if (! $column = $this->filterable()[$key] ?? null) continue; // whitelist
str_contains($column, 'like') !== false
? $q->where($column, 'like', "%{$value}%")
: $q->where($column, $value);
}
}
}
Two counts, not one. bootstrap-table expects total (unfiltered) and often reads totalNotFiltered for the "showing X of Y" label. The bundle's Doctrine provider distinguishes these; if you collapse them into one count() your footer lies whenever a search or a column filter is active. This is more visible now that filters actually reach the query.
Column types are the reusable part. Text, link, boolean, date, and action/button columns are worth copying verbatim — they're just formatters. The new filter-control handling in 0.8.1 also makes the column class the natural place to declare whether a column is filterable and how (exact vs LIKE vs select). Everything Doctrine-specific (the QueryBuilder adapter, the field-mapping metadata) is dead weight in Laravel and should be rewritten against Eloquent.
Reuse the table for exports. Because query() is public and column definitions are declarative, CSV/Excel export becomes $table->query()->cursor() plus the same column list. This is the strongest argument for the pattern over hand-rolled Blade tables.
Eager-load relation columns. Any column that renders $row->user->name needs ->with('user') in query(). The bundle inherits this problem from Doctrine; it does not solve it. Relation-backed filters are worse — decide up front whether filtering by a relation goes through whereHas or a join, and keep that decision next to the column definition.
Still 0.8.x, still thin. v0.8.1 is a single-bugfix patch on the same 0.8 line — no API additions, no new config, no Laravel-adjacent surface. The project remains a low-traffic, pre-1.0 package with effectively no support contract. Treat it as a reference implementation whose correctness just improved slightly, not as a dependency you can lean on.
Never trust sort, order, or filter. They arrive as raw query params. The bundle maps them to a QueryBuilder order-by / where, which is where a whitelist belongs. In your port, resolve sort against the declared column keys and throw (or fall back to the primary key) on a miss. The filter keys in filter deserve the same treatment — a JSON blob from the client is more tempting to trust than a scalar, and the fix in 0.8.1 is precisely the code path that consumes it. Otherwise you get Unknown column errors at best and injection at worst.
filter is JSON, and it disappears when empty. Decode defensively (json_decode(...) ?? []), and remember the param is simply absent when no filter is set — when() it, don't if ($r->filter) it into a 500 on a malformed body.
sidePagination defaults to client. If you forget data-side-pagination="server" on the table element, bootstrap-table fetches the URL once and paginates in the browser, so your limit/offset are ignored and you silently ship the entire table to the client. This is the single most common "why is it slow / why is pagination wrong" bug.
filter-control and search fight each other. Both funnel through the same server-side path, and 0.8.1 fixing one does not mean your port should combine them naively — decide whether a term typed in the search box should also populate column filters, and make the two clauses ANDed (as in the pattern above) rather than one overwriting the other.
Version skew between the bundle's bundled JS and your asset pipeline. The bundle pins a bootstrap-table version in its Twig template. If you're loading bootstrap-table from npm/Vite instead, drop the bundle's script tag or you'll get two copies and conflicting option names (notably responseHandler and queryParams changed across 1.11+). The filter-control extension in particular has changed its request shape across versions — make sure the JS you ship matches the PHP you wrote, not the other way around.
JSON shape is the whole debugging story. Empty table, wrong page count, and broken sorting are almost always a key mismatch, not a PHP error. Open the network tab, read the request params (including filter), read the response body. The bundle's own tests now cover the search/filter path — diff against those.
Composer conflicts in a Laravel app. Laravel already depends on symfony/http-foundation and symfony/options-resolver. Adding a bundle that constrains older majors will either fail resolution or pin Symfony components backwards. Don't fight it.
Extension points worth knowing if you do run it in Symfony: custom column classes (override the render/formatter), overriding the bundle's Twig templates via your app's templates/ directory, and the table-level hooks for pre-query modification (the natural home for a filter whitelist). In the Laravel port these map cleanly to a Blade component with @once-guarded script includes and a scopeTable-style query hook.
Alternatives before you build. If the goal is an admin index, yajra/laravel-datatables, Filament, or Livewire + a plain JSON endpoint will get you there faster than porting a thin Symfony bundle. Reach for bootstrap-table directly only when you specifically want its front end — and then write the ~100-line backend yourself, filter-control included.
How can I help you explore Laravel packages today?