power-components/livewire-powergrid
Livewire PowerGrid is a Laravel Livewire package for building modern, customizable data tables. It ships with sorting, global search, column filters, bulk actions, inline editing, and data export ready out of the box, plus an Artisan scaffold to generate table components fast.
Pick your major version first — this is the single most important decision. PowerGrid is mid-flight between two incompatible lines:
| 6.x (stable) | 7.x (beta, currently 7.0.0-beta.8) |
|
|---|---|---|
| PHP | 8.1+/8.2+ | 8.3+ |
| Laravel | 10/11/12 | 12/13 |
| Livewire | 2/3 | 4.0+ |
| Tailwind | 3.x | 4.x |
| Themes | Tailwind, DaisyUI, Bootstrap 5 | Tailwind, DaisyUI, Flux (Bootstrap dropped) |
If your app is on Livewire 3 / Tailwind 3, install power-components/livewire-powergrid:^6.11 and ignore the v7 docs. Only reach for ^7.0@beta on a greenfield Livewire 4 / Tailwind 4 app — and read UPGRADE.md on the 7.x branch before committing, because PowerGrid::lazy() / LazyManager, window.pgActions, and the Bootstrap theme are gone with no shims.
composer require power-components/livewire-powergrid
php artisan vendor:publish --tag=livewire-powergrid-config # config/livewire-powergrid.php
php artisan vendor:publish --tag=livewire-powergrid-assets # theme CSS/JS
php artisan powergrid:create DishTable --model=App\\Models\\Dish
Where to look first, in order:
https://livewire-powergrid.com/get-started/install.html — the README deliberately punts the real install steps (Tailwind content paths, @powergridScripts, layout @livewireStyles) to the docs. Do not try to infer install from the README.app/Livewire/DishTable.php generated by powergrid:create. It is a working scaffold with commented-out examples for filters, actions, and bulk actions. Uncomment-to-learn is the intended workflow and is faster than the docs for day-to-day work.config/livewire-powergrid.php — theme selection, per-page options, export disk/directory, cache TTLs, and (v7) escape_closure_fields.First use case: replace a hand-rolled <table> in an admin panel. You get sorting, global search, pagination, and per-column filters "for free" the moment datasource() returns rows and columns() returns column objects. Budget ~20 minutes from composer require to a themed, searchable list — most of that is Tailwind asset wiring, not PHP.
If you use Laravel Boost, run php artisan boost:install (or boost:update --discover). PowerGrid ships Agent Skills that teach Claude Code/Cursor the v7 struct() theme API and plugin scaffolding — genuinely useful here because v7's plugin system is under-documented relative to its surface area.
Every table is a Livewire component extending PowerGridComponent with four near-mandatory methods plus optional chrome:
public function datasource(): Builder
{
return Dish::query()->with('category:id,name'); // eager load = your N+1 insurance
}
public function relationSearch(): array
{
return ['category' => ['name']]; // makes global search hit relations
}
public function fields(): PowerGridFields
{
return PowerGrid::fields()
->add('id')
->add('name')
->add('price', fn (Dish $d) => number_format($d->price / 100, 2))
->add('created_at_formatted', fn (Dish $d) => $d->created_at->format('d/m/Y'));
}
public function columns(): array
{
return [
Column::add()->title('ID')->field('id')->sortable(),
Column::add()->title('Dish')->field('name')->sortable()->searchable(),
Column::add()->title('Price')->field('price')->sortable()->withSum('total'),
Column::action('Action'),
];
}
The split between fields() (render values, including computed/derived ones) and columns() (behaviour + presentation) is the core mental model. Anything you compute in fields() is not sortable unless you also add it to datasource() as a selected alias or use searchableRaw / sortableRaw with real SQL.
Note that fields() closure output is HTML-escaped by default. Since v7.0.0-beta.8 an escape_closure_fields key in config/livewire-powergrid.php controls this — flip it off only when you deliberately render trusted markup (badges, links) from a closure, and prefer returning HtmlString for the one column that needs it over disabling escaping globally.
Three shapes, in increasing order of control:
sortable() on real columns and relationSearch().DB::table() — same API, loses model accessors in fields() closures.For multi-tenant or permission-scoped tables, apply the scope inside datasource() — never in columns() or filters(). Everything downstream (search, filters, export, bulk actions) inherits the builder, so scoping there is the one place that can't be bypassed by a crafted Livewire payload.
filters() returns an array of filter objects; each declares a field() that must match a column or a searchableRaw alias. Pattern that scales well: put one filter per high-cardinality dimension only (category, status, date range) and leave full-text to the global search box. Every filter is one more round-trip on each keystroke unless you set ->debounce() sensibly.
Actions are declared per-column and rendered server-side in v7 (in 6.x they were JS-driven — this is the biggest behavioural difference when porting). The idiom that keeps you out of trouble is Rule:: for visibility/enabling, because rules evaluate on the server:
Button::add('edit')->slot('edit')->route('dishes.edit', ['dish' => 'id']),
Button::add('delete')->slot('delete')
->click('confirmDelete') // component method
->rules([
Rule::when(fn (Dish $d) => $d->orders_count === 0, 'can_delete'),
]),
Because Rule closures are serialized into the Livewire component's state, keep them pure and cheap — capture scalars via use, avoid touching auth()->user() inside the closure if you can hoist the check to datasource(). Non-serializable captures surface as confusing "closure could not be serialized" errors at render time, not at declaration time.
Bulk actions are the same Button/Action classes with ->confirm() and access to the selected-key array. For export:
WithExport / Exportable as your version's trait requires.exportColumns() explicitly, otherwise hidden/computed columns leak into the file.These are plugins, not column flags, in v7. Enable, then mark columns:
Column::add()->title('Active')->field('active')->toggleable();
public function onUpdatedToggleable(int|string $id, string $field, string $value): void
{
Dish::query()->where('id', $id)->update([$field => $value]);
}
The onUpdated{Plugin} hook is optional — without it the plugin's dispatch event is routed to pgPluginListener and you get no persistence. This is the most common "the toggle flips but nothing saves" bug. Same shape for onUpdatedEditable.
Give every component a distinct tableName (it defaults to a class-based slug; override it if you have two tables from the same model). In v7 the pg-tbody / pg-pagination / pg-filters hot zones mean only the region that changed gets patched — big win, but it also means a wire:key collision or a missing @powergridScripts in one partial produces silent no-ops rather than errors.
Pre-v7 you pick a theme in config and override Blade views. In v7 you call struct() on the theme and get a fluent builder returning tokens; config('livewire-powergrid.theme_overrides') is merged last, so a no-code override always beats a plugin's themeTokens() and the theme's own defaults. Order of precedence worth memorising: theme section methods → plugin themeTokens() → theme_overrides.
./vendor/power-components/livewire-powergrid/resources/views/**/*.blade.php (v6) or the v7 theme path to content in tailwind.config.js / the v4 @source directive. Symptom: unstyled pagination and a very wide, borderless table. This is the most common install failure.php artisan config:cache after changing theme, per_page, or escape_closure_fields — and remember published assets are versioned; re-publish or your CSS drifts from the Blade.fields() closures. Closure output is escaped by default, so fn ($d) => "<span class='badge'>{$d->status}</span>" renders as literal text. In v7 you can set escape_closure_fields => false globally in config, but prefer returning HtmlString for the specific column — a global opt-out quietly turns every user-supplied field value into an XSS vector.fields() closure isn't backed by SQL, ->sortable() renders an arrow that doesn't sort. Fix with sortableRaw('(price / 100)') or accept it and drop the arrow.datasource() scoping is your security boundary. Filters arrive from the client; the base builder does not. Any row-level permission check belongs in the builder or in Rule:: — never in Blade.SELECT * on wide tables. PowerGrid needs every sortable/searchable/filterable column present. Explicitly select() what the table shows; on a 60-column table this alone is often a 3–5× win, and it makes the pg-tbody hot-zone diffs cheaper too.disk and directory for batch exports (configurable since v7 beta) and always Storage::disk(...)->exists()-check before offering the download link.datasource() semantics. With Scout, sorting and non-search filters have to be re-applied to the collection afterwards; don't expect sortable() to work identically to the Eloquent path. Test the search path explicitly.composer.json is a real safeguard. ^7.0 will not resolve to betas by default, which is what you want in production; conversely ^6.11 will happily pull 6.11.x bugfixes (e.g. the row-actions-after-pagination fix) that you do want.7.0.0-beta.8 is a single small feature (escape_closure_fields); betas drop frequently, so re-read release notes per bump and don't assume config keys added mid-beta exist in the tag you pinned.->dd() on the component dumps the builder; DB::listen() around the render shows exactly which filters fired; ->queryString() in v7 gives you the SQL as a string. Start there before opening an issue.handles() / render() and contribute themeTokens() — that's the supported seam, and it survives minor upgrades. theme_view() with a powergrid-plugins:: fallback is the pattern internal plugins use for theme-specific markup.UPGRADE.md before any 6→7 move. Removed: LazyManager / "load more", window.pgActions, PowerGrid::lazy(), Pulse integration, CDN fallbacks. The struct() rewrite touches every custom theme you own.How can I help you explore Laravel packages today?