Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Livewire Tables Laravel Package

alp-develop/laravel-livewire-tables

Full-featured reactive data tables for Laravel: search, sort, filter, paginate, export, and bulk actions with zero JavaScript. Supports Livewire 3–4, Tailwind, Bootstrap 5/4, and dark mode.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and pin a safe version. The changelog shows 1.2.2 shipped a large security batch (IDOR in bulk actions, CSV formula injection, XSS in ActionColumn icons, sort-field session injection, CSS injection via config colors). Anything below 1.2.2 should be treated as unsafe. Start with:

composer require alp-develop/laravel-livewire-tables:^1.2.2
php artisan vendor:publish --tag=livewire-tables-config

The config publish is not optional — the README states it "is required"; theme, colors, dark mode and per-page options all resolve from config/livewire-tables.php. Skipping it is the most common "why is my table unstyled / throwing" first-run failure.

Set the theme, then add the one required CSS rule. In config/livewire-tables.php:

'theme' => 'tailwind', // or 'bootstrap-5' / 'bootstrap-4'

For Tailwind only, add [x-cloak] { display: none !important; } to your app CSS. The package already ships that rule inside styles.blade.php, but if you disable/purge its styles or use a strict Tailwind content scan, the bulk-actions toggle will flash on load.

Generate your first table with the artisan command, then check four methods on the generated class:

php artisan make:livewiretable UsersTable User
  1. configure() — defaults: setDefaultPerPage(25), setSearchDebounce(300), setEagerLoad(['team', 'roles']).
  2. query() — the base Eloquent Builder. Everything (search, filter, sort, export, bulk-ID validation) derives from this.
  3. columns() — the whitelist. Also acts as the sort-field whitelist and the searchable-column source.
  4. filters() / bulkActions() — optional, add when needed.

Render with <livewire:tables.users-table />.

Where to look first in the docs: docs/installation.md → docs/columns.md → docs/filters.md. Read docs/security.md before writing any ActionColumn or BladeColumn, since those are the two places where you are personally responsible for escaping.

First real use case: an admin index page for a model. Wire query() to User::query(), mark name/email sortable+searchable, add a BooleanColumn for status, a SelectFilter for status, and a bulkActions() entry of 'deleteSelected' calling User::whereIn('id', $this->getSelectedIds())->delete(). That gets you 80% of a typical CRUD index in one class and one Blade tag.

Implementation Patterns

Treat columns() as the contract, not just the view. The 1.2.2 hardening means columns() is used as: the sort whitelist (session-restored sort fields are validated against it), the searchable-column set, the CSV header source, and the sort-chip builder's map. If a field isn't declared as a column, sorting on it will be rejected — that is intentional. Don't try to sneak extra sort fields in via query().

Use the pipeline mentally, extend it surgically. Each request runs SearchStep → FilterStep → SortStep via Engine, carrying an immutable State. Engine is non-final and getEngine() is protected, so the supported extension point for custom orchestration is a subclass:

public function configure(): void { $this->setDefaultPerPage(50); }

protected function getEngine(): Engine
{
    return new class extends Engine {
        // override applySteps() to inject a tenant scope step, etc.
    };
}

Don't mutate State from outside; drive changes through the public Livewire hooks (updatedSearch, updatedTableFilters, sortBy, clearFilters, applyFilter), which the test suite covers explicitly.

Bulk actions: always read getSelectedIds(), never the raw property. Since 1.2.2, getSelectedIds() intersects client-submitted IDs with the live query result set (array_intersect against the filtered query), and selectedIds / excludedIds / selectAllPages are #[Locked]. The practical implication: selection is relative to the current filter, so a "delete selected" under an active filter cannot touch rows outside it. That is a feature — write your action as if the IDs are already safe, but still authorize:

public function deleteSelected(): void
{
    $this->authorize('deleteAny', User::class);
    User::whereIn('id', $this->getSelectedIds())->delete();
}

CSV export is two distinct paths. exportCsvAuto (declared in bulkActions()) reuses the engine's applied steps — the changelog explicitly refactored HasExport::buildExportQuery() to call $this->getEngine()->applySteps() — so the export matches exactly what the user sees. Use a custom export method only when you need column reshaping; headers pass through escapeCsvValue() automatically, cell values are the column's rendered/raw output depending on your column type.

For relations, prefer setEagerLoad() over joins. configure() with $this->setEagerLoad(['team']) applies with() in render() and avoids N+1 on TextColumn::make('team.name')-style computed accessors. Reach for docs/joins.md (aliases + searchable alias map) only when you need sorting and searching on a joined column at scale — that's where the alias map and LikeEscape field sanitization matter.

Multiple instances on one page require distinct keys. Without table-key, instances of the same component share state through the session:

<livewire:tables.users-table table-key="users-active" />
<livewire:tables.users-table table-key="users-archived" :table-theme="$theme" />

This is the single most-asked integration question for this class of package; make it a habit even when you only render one for now.

Testing pattern. The changelog references a deep feature-test suite (UpdatedTableFiltersTest, HasSearchAndSortingTest, IntegrationTest), which tells you the intended assertion surface:

Livewire::test(UsersTable::class)
    ->set('search', 'ada')
    ->assertSee('Ada Lovelace')
    ->call('sortBy', 'name')
    ->call('deleteSelected')
    ->assertDispatched('table-refreshed');

Test the combination of search + filter + bulk, not each in isolation — that's where the TOCTOU intersection logic actually gets exercised.

Gotchas and Tips

The namespace is Livewire\Tables\*, not AlpDevelop\.... The package registers PSR-4 for Livewire\Tables\ alongside livewire/livewire's Livewire\. This works, but it means: (a) use Livewire\Tables\Columns\TextColumn; — not the vendor namespace; (b) any other package that also claims Livewire\Tables\ will collide; (c) IDE/autocomplete confusion when typing Livewire\. Don't confuse this package with rappasoft/laravel-livewire-tables — different package, incompatible API.

The README's created_at example is broken. This snippet is not valid PHP:

->format(fn ($value) => $value?->format('M d, Y')),

Use ->format(fn ($value) => $value?->format('M d, Y')) (or a DateColumn). Copy-pasting the quick-start verbatim will give you a parse error; the docs directory is the accurate source.

ActionColumn emits raw JS strings — that's a CSP and escaping boundary. ->button('Edit', fn ($row) => "edit({$row->id})") calls a global function. The 1.2.2 XSS filter strips <script>, on*=, javascript:/vbscript:/data: URIs from the icon and href/src/action attributes, but the callback string itself is your responsibility. Under a strict CSP with unsafe-inline disabled, these inline handlers won't run — prefer redirecting via a Livewire method ($this->redirect(...)) as the README's edit()/delete() methods do.

BladeColumn is unescaped by design. The changelog's BladeColumnSecurityTest explicitly documents "unescaped developer responsibility." If you interpolate user data, wrap it in e(). This is the one column type where the package cannot protect you.

Search is capped at 200 characters. $search and TextFilter values are truncated to 200 chars to prevent wildcard LIKE DoS. If you have a legitimate long-input search use case, that truncation is silent — verify with getSearch() before assuming the full string reached your custom callback.

filters() must be pure and idempotent. 1.2.2 added resolveFilters() with a $cachedFilters in-request cache specifically because filters() was previously invoked up to 5× per request. All internal calls now route through the cache, so any side effects or per-call randomness inside filters() will only execute once — if you were (ab)using it to run a query per call, refactor that out.

perPage is re-validated at render time. It's checked against $perPageOptions in render() (State build), not only in the Livewire hook. If you allow a ?per_page=500 deep link, it will be silently clamped — set the option in perPageOptions() or use setDefaultPerPage(), don't rely on the query string.

Sort fields restored from session are whitelisted against columns(). A consequence worth internalizing: renaming or removing a column is a breaking state change for users with a cached session sort/filter on that field. It fails safe (dropped), but users will see their sort reset after a deploy. table-key versioning (users-v2) is a cheap way to bust that cache deliberately.

Date filters pull Flatpickr from a CDN. All three date filter views load [email protected] from jsDelivr with SHA-384 SRI. If you're air-gapped or CSP-locked with script-src 'self', date range filters will silently render as plain inputs. Plan to self-host or relax script-src/style-src for the CDN origin.

Dark mode is a class, not a media query. Toggle .lt-dark on a container (or rely on session detection) and check $this->darkMode in your component when you need to branch. If dark styles don't apply, confirm the class lands on an ancestor of the table and that the config's dark overrides passed the CSS-safe character allowlist — an off-spec color value (e.g. rgb(0 0 0 / 50%)) is dropped silently by the injection guard.

Zero dependents and 7 stars is the real risk assessment. The architecture (immutable State, composable Engine steps, 12 doc chapters, PHPStan level 8, Pint, a genuinely deep test suite) reads like a mature package, and the security posture in 1.2.2 is better than most packages with 100× the adoption. But with no dependents you are an early adopter: budget for reading the source of Engine/State when you hit an edge case, pin ^1.2.2 in composer.json, and keep the query()/columns() surface thin so that replacing the package later is a mechanical swap rather than a rewrite.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin