Skip to content
Bladewell

<x-widget.table>

Table Blade component for Laravel and Livewire

Data table for arrays or paginators: sortable columns (one or several at once), a Columns menu to show and hide columns, a totals row, a filters toolbar that filters in place with option counts (dimmed rows or skeleton while loading, optional in-memory cache), row selection with bulk actions, clickable and expandable rows, status badges, a row-actions menu, compact density, sticky header, stacked cards on phones, loading placeholders, drawer/numbers/jump/infinite pagination and an empty state. It brings in dropdown, pagination, search and select for its row menu, pages and filters; the ones a table doesn't use stay unrendered, so they add nothing to your pages.

php artisan bladewell:add table
Also adds
Button, Dropdown, Icon, Pagination, Search, Select
Works with
Laravel 12 and 13, Livewire 3 and 4, Tailwind CSS v4, PHP 8.3+
Guide
Laravel data table with sorting, filters, pagination

Usage

In a controller

Pass a paginator (or any collection) to the view. The table renders its own pagination links.

PHP
public function index(): View
{
    return view('transactions.index', [
        'transactions' => Transaction::query()->latest()->paginate(5),
    ]);
}

// For pagination="infinite", a cursor paginator keeps every page equally fast, however deep.
Transaction::query()->latest('id')->cursorPaginate(10, cursorName: 'feed_cursor');

// Sortable columns: only ever pass orderBy() a column from your own list, never the raw query string.
$sort = in_array($request->query('sort'), ['reference', 'date', 'amount'], true) ? $request->query('sort') : 'date';
$direction = $request->query('direction') === 'desc' ? 'desc' : 'asc';
Payment::query()->orderBy($sort, $direction)->paginate(10)->withQueryString();

// Filters (the filters slot): each field arrives as a query parameter. Check every one against your own list
// before it reaches the query, bind values (never interpolate them into whereRaw), and keep the query string so
// page and sort links carry the filters.
$status = in_array($request->query('status'), ['paid', 'pending', 'refunded', 'failed'], true) ? $request->query('status') : null;
$search = mb_substr(trim((string) $request->query('q')), 0, 100);
Order::query()
    ->when($search !== '', fn ($query) => $query->where('number', 'like', '%'.addcslashes($search, '%_\\').'%'))
    ->when($status, fn ($query) => $query->where('status', $status))
    ->latest()
    ->paginate(10)
    ->withQueryString();

// multi-sort: sort and direction arrive as comma lists in priority order (sort=status,amount&direction=asc,desc).
// Keep only keys from your list, each once.
$directions = explode(',', (string) $request->query('direction'));
$query = Order::query();
foreach (array_unique(explode(',', (string) $request->query('sort'))) as $i => $key) {
    if (in_array($key, ['status', 'date', 'amount'], true)) {
        $query->orderBy($key, ($directions[$i] ?? '') === 'desc' ? 'desc' : 'asc');
    }
}

// Option counts (faceted filters): each option's meta is how many rows picking it would give, counting every
// other filter but its own. The table updates them in the toolbar after each change.
$statusCounts = Order::query()->when($search !== '', fn ($query) => $query->where('number', 'like', '%'.addcslashes($search, '%_\\').'%'))
    ->selectRaw('status, count(*) as total')->groupBy('status')->pluck('total', 'status');
$statusOptions = collect(['paid' => 'Paid', 'pending' => 'Pending'])
    ->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label, 'meta' => (string) ($statusCounts[$value] ?? 0)])
    ->values();

// Bulk actions: the ticked rows arrive as selected[]. Authorize and scope them like any other input.
$invoices = $request->user()->invoices()->whereKey($request->validated('selected'))->get();

Livewire component

In a Livewire component, page, sort and rows-per-page links update the component in place. Use WithPagination, and name the sort properties after the table's sort-name and direction-name (sort and direction by default). Every public property can be set from the browser, so check each one before it reaches a query.

PHP
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;

class Invoices extends Component
{
    use WithPagination;

    #[Url]
    public string $search = '';

    #[Url]
    public ?string $sort = null;

    #[Url]
    public string $direction = 'asc';

    // per-page-model="perPage"
    public int $perPage = 10;

    // select-model="selected": the ticked ids, kept across pages. The table counts from it, so the bulk bar
    // always says what deleteSelected() will get.
    public array $selected = [];

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    // For a selection per page, clear it when the page changes.
    // public function updatedPaginators(): void { $this->selected = []; }

    public function deleteSelected(): void
    {
        $invoices = auth()->user()->invoices()->whereKey($this->selected)->get();
        $invoices->each(fn (Invoice $invoice) => $this->authorize('delete', $invoice));
        $invoices->each->delete();
        $this->selected = [];
    }

    public function render()
    {
        $sort = in_array($this->sort, ['number', 'date', 'amount'], true) ? $this->sort : 'date';
        $perPage = in_array($this->perPage, [10, 25, 50, 100], true) ? $this->perPage : 10;
        // Capped, and % and _ taken as themselves: otherwise a search of % matches every row.
        $search = addcslashes(mb_substr(trim($this->search), 0, 100), '%_\\');

        return view('livewire.invoices', [
            'invoices' => auth()->user()->invoices()
                ->when($search !== '', fn ($query) => $query->where('number', 'like', "%{$search}%"))
                ->orderBy($sort, $this->direction === 'desc' ? 'desc' : 'asc')
                ->paginate($perPage),
        ]);
    }
}

Livewire view

The view for that component. Pass :sort and :direction from the component. Fields in the filters slot are yours to bind with wire:model; Livewire filters in place, and the empty state's Clear filters resets every bound field. Give each row a wire:key, so open rows and ticks stay with their record when the component re-renders.

Blade
<div>
    <x-widget.table
        id="invoices"
        caption="Invoices"
        :rows="$invoices"
        :columns="[
            ['label' => 'Number', 'key' => 'number', 'sortable' => true],
            ['label' => 'Date', 'key' => 'date', 'sortable' => true],
            ['label' => 'Amount', 'key' => 'amount', 'sortable' => true, 'align' => 'end'],
        ]"
        :sort="$sort"
        :direction="$direction"
        selectable
        select-model="selected"
        pagination="footer"
        per-page-model="perPage"
    >
        <x-slot:filters>
            <x-widget.search name="search" wire:model.live.debounce.300ms="search" placeholder="Search invoices" />
        </x-slot:filters>

        <x-slot:bulk>
            <x-widget.button type="button" variant="danger" size="sm" wire:click="deleteSelected" wire:confirm="Delete the selected invoices?">Delete</x-widget.button>
        </x-slot:bulk>

        @foreach ($invoices as $invoice)
            <x-widget.table.row wire:key="invoice-{{ $invoice->id }}" :value="$invoice->id" :select-label="'Select '.$invoice->number">
                <td>{{ $invoice->number }}</td>
                <td>{{ $invoice->date->toFormattedDateString() }}</td>
                <td>{{ $invoice->amount }}</td>
            </x-widget.table.row>
        @endforeach
    </x-widget.table>
</div>

On the server

The table can't stop a request that skips it. Here's what to check on your server, and what to keep cheap.

Security

  • Pass orderBy() and filters only columns from your own list, never the raw query string: an open sort lets anyone sort by, and so learn about, columns you don't show.

    OWASP: SQL injection

  • Bulk actions send selected[], which is whatever the browser chose. Load those rows through the person's own scope and authorize each, as for any other input.

    OWASP: Insecure direct object references · Laravel: Authorization

Server cost

  • Always paginate, and if a per_page comes from the request, cap it (min(100, …)): ?per_page=100000 would otherwise load the whole table.

    Laravel: Pagination · OWASP API4: Unrestricted resource consumption

  • Offset pages slow down as they go deeper: the database reads and throws away every row before the page. Consider cursorPaginate() for infinite scroll or big tables, and simplePaginate() when you don't show a page count, which skips the count(*) query.

    Laravel: Cursor pagination · Laravel: Simple pagination

  • Filters re-run the query as people type, and option counts add a grouped count per filter. Index the columns you filter on, consider caching the counts for a minute, and cache-for keeps pages already fetched in the browser so going back doesn't ask again.

    Laravel: Cache

Examples

Filters

Put the fields in the filters slot: the table filters as you type (after a short pause) or pick, in place, back on page 1 and keeping the sort; the URL follows, so a reload or a shared link shows the same rows. Each option's count (its meta) says how many orders picking it would show, and updates as you filter. Shift+click a header to sort by several columns (multi-sort). Columns marked hideable get a Columns menu. The footer slot holds a totals row. while-loading="skeleton" shows placeholder rows during slower loads, and cache-for="60" makes going back to a filter seen in the last minute instant. Without JS the fields are an ordinary GET form. Your controller must check every filter and sort key against its own list, as Usage shows.

Pass $orders, $filters, $statuses, $payments, $periods and $totals from your controller.

Orders
Order , sort ascending; Shift to add to the sort Customer Status , sort ascending; Shift to add to the sort Payment Date , sort ascending; Shift to add to the sort Amount , sort ascending; Shift to add to the sort
ORD-1014 Gus Holm Refunded PayPal Sep 2, 2026 $376.20
ORD-1051 Hana Sato Pending Card Sep 1, 2026 $454.30
ORD-1005 Ben Carter Refunded Card Aug 30, 2026 $374.50
ORD-1042 Chen Wei Paid Bank transfer Aug 29, 2026 $452.60
ORD-1033 Farah Aziz Paid PayPal Aug 26, 2026 $450.90
ORD-1024 Ana Silva Failed Card Aug 23, 2026 $449.20
ORD-1061 Ben Carter Pending Bank transfer Aug 22, 2026 $527.30
ORD-1015 Dara Okafor Refunded Card Aug 20, 2026 $447.50
64 orders $19,536.00
Show code
Blade
<x-widget.table
    id="orders"
    caption="Orders"
    :rows="$orders"
    pagination="numbers"
    multi-sort
    sort-name="order_sort"
    direction-name="order_dir"
    while-loading="skeleton"
    cache-for="60"
    empty="No orders match these filters"
    :columns="[
        ['label' => 'Order', 'key' => 'number', 'sortable' => true],
        ['label' => 'Customer', 'hideable' => true],
        ['label' => 'Status', 'key' => 'status', 'sortable' => true, 'hideable' => true],
        ['label' => 'Payment', 'hideable' => true],
        ['label' => 'Date', 'key' => 'date', 'sortable' => true, 'hideable' => true],
        ['label' => 'Amount', 'key' => 'amount', 'sortable' => true, 'align' => 'end'],
    ]"
>
    <x-slot:filters>
        <x-widget.search name="order_q" placeholder="Search orders or customers" />
        <x-widget.select name="order_status" label="Status" inner-label clearable placeholder="Any" :options="$statuses" :value="$filters['status']" class="sm:min-w-0 sm:flex-[1_1_13rem]" />
        <x-widget.select name="order_payment" label="Payment" inner-label clearable placeholder="Any" :options="$payments" :value="$filters['payment']" class="sm:min-w-0 sm:flex-[1_1_13rem]" />
        <x-widget.select name="order_period" label="Date" inner-label clearable placeholder="Any" :options="$periods" :value="$filters['period']" class="sm:min-w-0 sm:flex-[1_1_13rem]" />
    </x-slot:filters>

    @foreach ($orders as $order)
        <x-widget.table.row>
            <td>{{ $order['number'] }}</td>
            <td>{{ $order['customer'] }}</td>
            <td><x-widget.table.badge :tone="['paid' => 'success', 'pending' => 'warning', 'refunded' => 'info', 'failed' => 'error'][$order['status']]">{{ $order['status_label'] }}</x-widget.table.badge></td>
            <td>{{ $order['payment_label'] }}</td>
            <td>{{ $order['date_label'] }}</td>
            <td>{{ $order['amount'] }}</td>
        </x-widget.table.row>
    @endforeach

    <x-slot:footer>
        <tr>
            <td>{{ $totals['count'] }} {{ $totals['count'] === 1 ? 'order' : 'orders' }}</td>
            <td></td>
            <td></td>
            <td></td>
            <td></td>
            <td>{{ $totals['amount'] }}</td>
        </tr>
    </x-slot:footer>
</x-widget.table>

Clickable rows

Rows with href act as links (keyboard included) without nesting a link in every cell. Amounts sit at the end of their column so the digits line up; statuses are badges. $transactions is a paginator from your controller; see Usage.

Pass $transactions from your controller.

Recent transactions
Reference Amount Status
TX-00001 37.50 Completed
TX-00002 75.00 Completed
TX-00003 112.50 Completed
TX-00004 150.00 Completed
TX-00005 187.50 Failed
Show code
Blade
<x-widget.table caption="Recent transactions" :columns="['Reference', ['label' => 'Amount', 'align' => 'end'], 'Status']" :rows="$transactions" pagination="numbers">
    @foreach ($transactions as $transaction)
        <x-widget.table.row :href="route('transactions.show', $transaction)">
            <td>{{ $transaction->reference }}</td>
            <td>{{ $transaction->amount }}</td>
            <td><x-widget.table.badge :tone="$transaction->status === 'Failed' ? 'error' : 'success'">{{ $transaction->status }}</x-widget.table.badge></td>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

Sortable

Mark columns sortable with a key; clicking a header reloads with ?sort=amount&direction=desc (in place, with JS) and back to page 1. The table only draws the order: your controller must check sort against its own list of columns before it reaches orderBy(), as Usage shows.

Pass $payments from your controller.

Payments
Reference , sort ascending Customer Date , sort ascending Amount , sort ascending
PAY-1035 Farah Aziz 2026-02-25 $280.50
PAY-1042 Ana Silva 2026-03-08 $334.20
PAY-1049 Ben Carter 2026-03-19 $387.90
PAY-1056 Chen Wei 2026-03-30 $441.60
PAY-1063 Dara Okafor 2026-04-10 $495.30
PAY-1070 Eli Novak 2026-04-21 $549.00
Show code
Blade
<x-widget.table caption="Payments" :rows="$payments" pagination="numbers" :columns="[
    ['label' => 'Reference', 'key' => 'reference', 'sortable' => true],
    'Customer',
    ['label' => 'Date', 'key' => 'date', 'sortable' => true],
    ['label' => 'Amount', 'key' => 'amount', 'sortable' => true, 'align' => 'end'],
]">
    @foreach ($payments as $payment)
        <x-widget.table.row>
            <td>{{ $payment['reference'] }}</td>
            <td>{{ $payment['customer'] }}</td>
            <td>{{ $payment['date'] }}</td>
            <td>{{ $payment['amount'] }}</td>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

Selectable

A checkbox per row (give each row a :value) and a select-all box. Ticking any shows the bar with your bulk slot's buttons; the ticked values submit as selected[] with the form the table builds (POST with CSRF by default, to bulk-action).

Pass $invoices from your controller.

Select rows to act on them
Invoices
Invoice Client Amount Status
INV-2041 Northwind Ltd $1,250.00 Sent
INV-2042 Blue Harbor $480.00 Overdue
INV-2043 Kestrel & Co $3,900.00 Sent
INV-2044 Maple Studio $215.50 Overdue
Show code
Blade
<x-widget.table id="invoices" caption="Invoices" selectable :bulk-action="url()->current()" bulk-method="GET" :columns="['Invoice', 'Client', ['label' => 'Amount', 'align' => 'end'], 'Status']" :rows="$invoices">
    <x-slot:bulk>
        <x-widget.button type="submit" name="action" value="export" size="sm" variant="neutral" :submit-guard="false">Export</x-widget.button>
        <x-widget.button type="submit" name="action" value="remind" size="sm" :submit-guard="false">Send reminder</x-widget.button>
    </x-slot:bulk>

    @foreach ($invoices as $invoice)
        <x-widget.table.row :value="$invoice['number']" :select-label="'Select '.$invoice['number']">
            <td>{{ $invoice['number'] }}</td>
            <td>{{ $invoice['client'] }}</td>
            <td>{{ $invoice['amount'] }}</td>
            <td><x-widget.table.badge :tone="$invoice['overdue'] ? 'warning' : 'neutral'">{{ $invoice['overdue'] ? 'Overdue' : 'Sent' }}</x-widget.table.badge></td>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

Expandable rows

A details slot makes the row expandable. A chevron on the right shows which rows open, and turns when they do.

Pass $payouts from your controller.

Payouts
Reference Amount Status Details
PO-1041 812.40 Paid
PO-1042 1,624.80 Paid
PO-1043 2,437.20 Paid
Show code
Blade
<x-widget.table caption="Payouts" :columns="['Reference', ['label' => 'Amount', 'align' => 'end'], 'Status']" :rows="$payouts">
    @foreach ($payouts as $payout)
        <x-widget.table.row>
            <td>{{ $payout->reference }}</td>
            <td>{{ $payout->amount }}</td>
            <td>{{ $payout->status }}</td>
            <x-slot:details>
                <p class="text-foreground/75 text-sm">Sent to the account ending {{ $payout->account }} on {{ $payout->sent_on }}.</p>
            </x-slot:details>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

Stacked

stack turns each row into a card of label and value lines below the sm breakpoint, instead of a table you scroll sideways. Try it on a narrow screen.

Pass $orders from your controller.

Orders
Order Customer Placed Status Total
#3021 Priya Nair 12 Sep 2026 Delivered $84.20
#3022 Tom Becker 13 Sep 2026 Shipped $129.00
#3023 Lina Haddad 14 Sep 2026 Refunded $42.50
Show code
Blade
<x-widget.table caption="Orders" stack :columns="['Order', 'Customer', 'Placed', 'Status', ['label' => 'Total', 'align' => 'end']]" :rows="$orders">
    @foreach ($orders as $order)
        <x-widget.table.row>
            <td class="font-medium">{{ $order['number'] }}</td>
            <td>{{ $order['customer'] }}</td>
            <td>{{ $order['placed'] }}</td>
            <td><x-widget.table.badge :tone="$order['tone']">{{ $order['status'] }}</x-widget.table.badge></td>
            <td>{{ $order['total'] }}</td>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

Compact

density="compact" fits more rows; striped helps the eye along them; max-height keeps the header in view while the body scrolls. Badges carry each status in words, with colour only to back them up. The last column is a menu of actions per row: its heading is for screen readers only (hideLabel), and size="sm" keeps the button inside a compact row. The menu is rendered once after the table (dropdown.menu) and each row has only its trigger, whose values fill the menu's :user as it opens, so a long table carries one copy of the menu instead of one per row.

Pass $events from your controller.

Audit log
Time User Action Result Actions
09:54 [email protected] Changed role Success
09:50 [email protected] Exported report Success
09:46 [email protected] Signed in Blocked
09:42 [email protected] Reset password Pending
09:38 [email protected] Viewed invoice Viewed
09:34 [email protected] Signed in Success
09:30 [email protected] Changed role Success
09:26 [email protected] Exported report Success
09:22 [email protected] Signed in Blocked
09:18 [email protected] Reset password Pending
09:14 [email protected] Viewed invoice Viewed
09:10 [email protected] Signed in Success
09:06 [email protected] Changed role Success
09:02 [email protected] Exported report Success
Show code
Blade
<x-widget.table caption="Audit log" density="compact" striped max-height="20rem" :columns="['Time', 'User', 'Action', 'Result', ['label' => 'Actions', 'hideLabel' => true, 'align' => 'end']]" :rows="$events">
    @foreach ($events as $event)
        <x-widget.table.row>
            <td>{{ $event['time'] }}</td>
            <td>{{ $event['user'] }}</td>
            <td>{{ $event['action'] }}</td>
            <td><x-widget.table.badge :tone="$event['tone']">{{ $event['result'] }}</x-widget.table.badge></td>
            <td class="w-12 text-end">
                <x-widget.dropdown menu="audit-actions" :label="'Actions for '.$event['action'].' by '.$event['user'].' at '.$event['time']" size="sm" :values="['user' => $event['user']]" />
            </td>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

<x-widget.dropdown.menu id="audit-actions" align="end">
    <x-widget.dropdown.item icon="eye">View details</x-widget.dropdown.item>
    <x-widget.dropdown.item icon="user">View :user</x-widget.dropdown.item>
    <x-widget.dropdown.divider />
    <x-widget.dropdown.item icon="shield-check">Mark as reviewed</x-widget.dropdown.item>
</x-widget.dropdown.menu>

Loading

loading draws placeholder rows while data is on its way, e.g. during a Livewire or fetch request, and tells screen readers it's loading.

Customers
Name Email Plan Spend
Loading
Show code
Blade
<x-widget.table caption="Customers" loading :skeleton-rows="4" :columns="['Name', 'Email', 'Plan', ['label' => 'Spend', 'align' => 'end']]" />

Drawer pagination

The default pagination. Tapping the current page opens a sheet listing every page, which suits phones.

Pass $orders from your controller.

Orders
Order Customer Total
#5001 Daniel Tan 72.99
#5002 Priya Nair 125.99
#5003 Wei Ling 178.99
#5004 Omar Haddad 231.99
#5005 Sofia Reyes 284.99
Show code
Blade
<x-widget.table caption="Orders" :columns="['Order', 'Customer', ['label' => 'Total', 'align' => 'end']]" :rows="$orders">
    @foreach ($orders as $order)
        <x-widget.table.row>
            <td>{{ $order->number }}</td>
            <td>{{ $order->customer }}</td>
            <td>{{ $order->total }}</td>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

Jump pagination

Jump straight to a page, for long histories.

Pass $logins from your controller.

Login history
When Device Location
26 Sep 2026, 11:00 Safari on iPhone Singapore
26 Sep 2026, 04:00 Firefox on Windows Penang
25 Sep 2026, 21:00 App on Android Johor Bahru
25 Sep 2026, 14:00 Chrome on macOS Kuala Lumpur
25 Sep 2026, 07:00 Safari on iPhone Singapore
Show code
Blade
<x-widget.table caption="Login history" :columns="['When', 'Device', 'Location']" :rows="$logins" pagination="jump">
    @foreach ($logins as $login)
        <x-widget.table.row>
            <td>{{ $login->when }}</td>
            <td>{{ $login->device }}</td>
            <td>{{ $login->location }}</td>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

Infinite scroll

The table scrolls inside itself and loads rows at either end as you go, dropping far-off ones, so the page stays light at any size. Pair it with cursorPaginate(), which costs the same on the millionth row as the first.

Pass $transactions from your controller.

All transactions
Reference Date Amount
TX-0992150 10 Sep 2026, 03:30 472.50
TX-0992149 10 Sep 2026, 03:27 469.00
TX-0992148 10 Sep 2026, 03:24 465.50
TX-0992147 10 Sep 2026, 03:21 462.00
TX-0992146 10 Sep 2026, 03:18 458.50
TX-0992145 10 Sep 2026, 03:15 455.00
TX-0992144 10 Sep 2026, 03:12 451.50
TX-0992143 10 Sep 2026, 03:09 448.00
TX-0992142 10 Sep 2026, 03:06 444.50
TX-0992141 10 Sep 2026, 03:03 441.00
Show code
Blade
<x-widget.table caption="All transactions" :columns="['Reference', 'Date', ['label' => 'Amount', 'align' => 'end']]" :rows="$transactions" pagination="infinite">
    @foreach ($transactions as $transaction)
        <x-widget.table.row>
            <td>{{ $transaction->reference }}</td>
            <td>{{ $transaction->date }}</td>
            <td>{{ $transaction->amount }}</td>
        </x-widget.table.row>
    @endforeach
</x-widget.table>

Empty

Reference Date Amount

No transactions yet

Show code
Blade
<x-widget.table :columns="['Reference', 'Date', 'Amount']" :rows="collect()" empty="No transactions yet" />

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.table>

Prop Default Description
id null Needed with selectable, filters or hideable columns (it names their forms and menu); a paginated table gets one from its page name. Page links end in #id, so a new page opens scrolled to the table.
columns [] Labels, or arrays: ['label' => 'Amount', 'key' => 'amount', 'align' => 'end', 'sortable' => true, 'hideable' => true]. hideLabel => true keeps the heading for screen readers only, for a column whose cells say what they are (row actions). align is start (default), center or end; put numbers at the end so their digits line up. hideable lists the column in the Columns menu (add 'hidden' => true to start it hidden); up to 12 columns.
rows null A paginator (paginate(), simplePaginate() or cursorPaginate()) or any array or collection; the slot draws the rows.
striped false Tints every other row.
align-left false Kept so older markup still renders; start alignment is now the default for every column.
header true false leaves out the header row.
pagination 'drawer' With a paginator: drawer, numbers, segmented, jump, footer (rows per page), summary or infinite (scrolls inside itself, loading more as you go); false for none.
fill true Pads a short last page with blank rows, so the pagination bar stays where it was.
empty 'No data found' What the empty state says. With filters, it also offers Clear filters.
caption null The table's name for screen readers (a hidden <caption>); also names its scroll area and filters.
visible-rows 10 pagination="infinite": rows in view at once (1 to 50) before the table scrolls inside itself.
density 'comfortable' comfortable (56px rows) or compact (44px), for dense admin screens.
max-height null e.g. "24rem": the body scrolls inside the table and the header stays in view.
loading false Placeholder rows while data is on its way (e.g. while a Livewire or fetch request runs).
skeleton-rows 5 How many placeholder rows loading draws (and while-loading="skeleton", without a paginator).
while-loading 'dim' While a page, sort or filter change loads: dim (the rows stay, faded, with a spinner) or skeleton (placeholder rows).
cache-for null Seconds to keep fetched pages in memory: going back to a page, sort or filter seen within that time is instant. In memory only, never written to storage, and gone on reload.
sort null The sorted column's key. Defaults to the query string, but only for keys marked sortable. With multi-sort, a comma list in order of priority, e.g. "status,amount". Clicking a header cycles ascending, descending, then no sort.
direction null asc or desc (a comma list with multi-sort, one per key); defaults to the query string.
multi-sort false Lets people sort by several columns: Shift+click (or Shift+Enter) a header to add it; a plain click sorts by it alone.
sort-name 'sort' The query parameters the sort links set; change them when two sortable tables share a page. In a Livewire component, the properties they set.
direction-name 'direction' The same, for the direction parameter.
selectable false A checkbox per row (give rows a :value) and a bar for the bulk slot's buttons. Needs an id.
select-name 'selected' What the row checkboxes submit as: selected[].
select-model null Livewire: binds the row checkboxes to this property (wire:model), e.g. "selected", so actions get the ids.
per-page-model null Livewire, pagination="footer": binds rows per page to this property, e.g. "perPage", and goes back to page 1.
bulk-action null Where the bulk form submits the ticked rows.
bulk-method 'POST' The bulk form's method: GET, POST, PUT, PATCH or DELETE, in any case.
stack false Opt-in: below the sm breakpoint, each row becomes a card of label / value pairs instead of scrolling sideways.

Slots

<x-slot:filters>
Fields for the toolbar above the table (search, selects). It filters as you type or pick, in place, back on page 1; your controller checks every value.
<x-slot:bulk>
Buttons for the bar that appears when rows are ticked (selectable); they submit the ticked rows with the bulk form.
<x-slot:footer>
Rows under the data, e.g. <tr><td>Total</td>…</tr>, aligned with the columns; hidden while empty or loading.

<x-widget.table.badge>

Prop Default Description
tone 'neutral' neutral, success, warning, error or info.
dot true A small dot in the tone's colour before the text; false leaves it out.

<x-widget.table.row>

Prop Default Description
href null Makes the whole row a link to this URL: a click anywhere on it goes there (Ctrl/Cmd or middle click opens a new tab), and its first cell gets a real link for keyboards and screen readers. Not with a details slot.
value null With a selectable table: the value this row's checkbox submits, e.g. its id.
select-label 'Select row' Screen reader name for the checkbox, e.g. "Select TX-00042".
link-label null With href: the name of the row's link, e.g. "TX-00042". Defaults to the first cell's text.

Slots

<x-slot:details>
Content shown when the row expands: a click anywhere on it, or Enter or Space on its Details button (the chevron).

Accessibility

All 12 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What bladewell:add table writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/table/badge.blade.php Show
badge.blade.php
resources/views/components/widget/table/index.blade.php Show
index.blade.php
resources/views/components/widget/table/row.blade.php Show
row.blade.php
resources/js/widget/table/index.js Show
index.js
app/View/Widget/ElementIds.php Show
ElementIds.php