Wiretables

Wiretables is a small set of anonymous Blade components for data tables in Laravel with Livewire and Alpine. You still write the table yourself, row by row and cell by cell; the package supplies the frame, the styles and the few behaviours every admin list ends up rebuilding: columns that step aside on a phone, sortable headers, a "Load more" button and a menu of actions on each row.
Every cell is a slot, so anything valid in Blade is a valid cell. There is no column class and nothing to configure in PHP. The only PHP in the package is two optional Livewire traits.
Wiretables or wirelist
Wirelist is the sibling for lists without column headers: records drawn as rows of text, as cards or as a grid. It is built on wiretables and shares its menu, "Load more", traits and themes.
The deciding question is whether people compare records down a column. If they read dates, statuses or amounts across many rows and might sort by them, use a table. If each record is read on its own, use wirelist.
There are no filters, bulk checkboxes or CSV export. The table is your own Blade, so adding any of them does not fight the package.
Installation
composer require edulazaro/wiretables
It needs PHP 8.2+, Laravel 11+, and Livewire 3 or 4 for the traits. The components register under x-wiretable with no config file. Import the core stylesheet and at most one theme:
/* resources/css/app.css, at the very top */ @import "../../vendor/edulazaro/wiretables/resources/css/wiretables-core.css"; @import "../../vendor/edulazaro/wiretables/resources/css/themes/claude.css";
An @import placed after other rules is dropped and the table renders unstyled with no error. To serve the files with a <link> instead, run php artisan vendor:publish --tag=wiretables-assets (they land in public/vendor/wiretables/css) and publish again with --force after an update.
The row menu uses Alpine's x-teleport and x-anchor. Livewire ships both, so there is nothing to install unless you load Alpine on its own, in which case register @alpinejs/anchor before Alpine.start().
A first table
<x-wiretable> <x-slot:head> <x-wiretable.th>Order</x-wiretable.th> <x-wiretable.th shrink>Status</x-wiretable.th> <x-wiretable.th hide="lg" shrink>Placed</x-wiretable.th> <x-wiretable.th shrink align="right">Total</x-wiretable.th> <x-wiretable.th actions /> </x-slot:head> @forelse ($orders as $order) <x-wiretable.row wire:key="order-{{ $order->id }}"> <x-wiretable.td> <x-wiretable.primary :title="$order->reference" :subtitle="$order->customer->name" :href="route('orders.show', $order)" navigate> <p class="lg:hidden">Placed {{ $order->created_at->format('d/m/Y') }}</p> </x-wiretable.primary> </x-wiretable.td> <x-wiretable.td shrink>{{ $order->status->label() }}</x-wiretable.td> <x-wiretable.td hide="lg" shrink>{{ $order->created_at->format('d/m/Y') }}</x-wiretable.td> <x-wiretable.td shrink align="right">{{ $order->total->format() }}</x-wiretable.td> <x-wiretable.td actions> <x-wiretable.menu :label="'Options for order '.$order->reference"> <x-wiretable.menu-item :href="route('orders.show', $order)">View order</x-wiretable.menu-item> </x-wiretable.menu> </x-wiretable.td> </x-wiretable.row> @empty <x-wiretable.empty colspan="5">No orders yet.</x-wiretable.empty> @endforelse <x-slot:footer>{{ $orders->links() }}</x-slot:footer> </x-wiretable>
The layout model is one flexible column and the rest compact: the first column takes the space left over, and every other column carries shrink. A cell repeats the options of its header, because a <td> cannot know which header it sits under. Give each row a wire:key so Livewire matches rows between renders.
x-wiretable.primary draws the record: a bold title, an optional muted subtitle (skipped when empty) and an optional leading slot for an avatar. The title is a link with href (add navigate for wire:navigate), a button with action (an Alpine expression), or plain text with neither. x-wiretable.empty needs colspan equal to the number of columns, actions included. The footer slot is optional.
To make the whole row the target, put href (plus navigate) or action on x-wiretable.row instead of relying on the title alone. The row then takes the pointer and the keyboard focus, and opens on Enter. It stays out of the way of what lives inside it: the row menu, a link in a cell, a checkbox or a select still do their own thing, a drag that selects text opens nothing, and Ctrl, Cmd or the middle button send an href to a new tab. Mark anything else that must not open the row with data-wtb-ignore.
Columns on a phone
A table here never needs to scroll sideways on a phone. Four options shape a column, set on the header and on each cell of that column:
| Option | Effect |
|---|---|
hide="md" |
Visible from that breakpoint up: sm 40rem, md 48rem, lg 64rem, xl 80rem, Tailwind's widths. |
shrink |
As narrow as its content, no wrapping. |
align="right" |
For figures. On a sortable header the arrow moves to the inner side. |
actions |
The narrow last column for the row menu; its header text is for screen readers. |
The phone summary
A hidden column's value does not have to vanish. Whatever goes in the default slot of x-wiretable.primary appears under the subtitle, so repeat the hidden data there and hide it again where the column returns, as the lg:hidden line above does. The breakpoints match Tailwind's exactly, so lg:hidden switches at the same width as hide="lg".
Rows as cards
When every value matters on a phone, set stack on the table instead. Below that breakpoint each row becomes a card, every cell is a line, and label on a cell is printed above its value:
<x-wiretable stack="md" expandable> ... <x-wiretable.td shrink align="right" label="Price">{{ $booking->price }}</x-wiretable.td> <x-wiretable.td hide="md" label="Requests">{{ $booking->requests }}</x-wiretable.td> ... </x-wiretable>
expandable folds the cells that have hide behind a chevron in the actions cell (so every row needs that cell); compact puts the cells without hide on one line with the actions. Above the breakpoint it is the same table as before. rows="separated" draws each row as its own card on every screen, and flush removes the frame for a table that already sits inside a panel.
The row menu
<x-wiretable.td actions> <x-wiretable.menu :label="'Actions for '.$member->name"> <x-wiretable.menu-item :href="route('team.members.edit', $member)" wire:navigate>Edit profile</x-wiretable.menu-item> <x-wiretable.menu-item wire:click="resendInvite({{ $member->id }})">Resend invitation</x-wiretable.menu-item> <x-wiretable.menu-separator /> <x-wiretable.menu-item wire:click="confirmRemoval({{ $member->id }})" danger>Remove from team…</x-wiretable.menu-item> </x-wiretable.menu> </x-wiretable.td>
labelis required: the trigger is an icon, and "Actions for Marta Vidal" tells a screen reader which row it is.- An item with
hrefis a link; without it, a<button>forwire:clickorx-on:click. Choosing any item closes the menu. dangerpaints the item red (--wtb-danger). Keep it for destructive actions, last, after a separator.- The
leadingslot puts an icon before the text; an<svg>there is sized to 18px and follows the item's colour. - The items' text is left-aligned by default.
align="right"on the menu aligns it to the right, for interfaces whose menus all read that way.
The panel is teleported to the end of <body> and anchored to its button, so a scrolling table, an overflow: hidden card or a modal cannot clip it. The consequence: it only sees a theme set on <html> or <body>. It sits at z-index: 40; raise --wtb-menu-z-index if a sticky header covers it.
Sorting
Add WithSorting to the component and list the keys a header may sort by. The trait never touches the query: your render() maps a key to a column, so the browser can never choose one.
use EduLazaro\Wiretables\Concerns\WithSorting; class Shipments extends Component { use WithPagination, WithSorting; protected function sortable(): array { return ['shipped', 'weight']; } public function render() { $column = ['shipped' => 'shipped_at', 'weight' => 'weight_grams'][$this->sort] ?? null; return view('livewire.shipments', [ 'shipments' => Shipment::query() ->when($column, fn ($q) => $q->orderBy($column, $this->direction)) ->when(! $column, fn ($q) => $q->latest('shipped_at')) ->paginate(30), ]); } }
<x-wiretable.th shrink align="right" sortable="weight" :sort="$sort" :direction="$direction">Weight</x-wiretable.th>
Each click cycles ascending, descending and unsorted. $sort and $direction live in the URL, unknown keys are ignored, and sorting resets the page. method makes the header call something other than sortBy.
Load more
For a feed or an inbox read as one growing list, use WithLoadMore and the button:
use EduLazaro\Wiretables\Concerns\WithLoadMore; public function render() { return view('livewire.inbox', [ 'tickets' => $this->loadMoreFrom(Ticket::query()->latest()), ]); }
<x-wiretable.load-more :show="$hasMore" />
loadMoreFrom() reads every row from the top up to the current page, plus one to set $hasMore, so the list stays consistent when rows are added or removed while someone reads. Override perLoad() to change the page size (30). The page count is #[Locked], any property update starts again from the first page, and sorting does too.
Theming
Load the theme's file after the core and name it on <html>:
<html lang="{{ app()->getLocale() }}" data-wire-theme="toxic">
The themes are soft, glass, gradient, neon, minimal, claude, chatgpt, studio, synthwave, megaflow, brutalist and toxic. With none, the table is neutral grey with a blue accent and follows the system's dark mode. Force dark with data-wire-theme-mode="dark" or the .dark class; neon, synthwave and toxic are always dark.
The attribute is shared with wiremodal, wiretoast and the other wire* packages, so one attribute on <html> makes modals, toasts and tables agree.
Every value is a custom property in two layers. --wire-* is the family palette, shared by all wire* packages; --wtb-* is the table's own. Set them under a theme name:
[data-wire-theme="ledger"] { --wire-bg: #fbf8f1; --wire-text: #2b2a26; --wire-border: #e6dfcf; --wire-accent: #2f6f4f; --wtb-head-bg: #f3eddf; --wtb-head-text-transform: uppercase; --wtb-head-letter-spacing: 0.06em; --wtb-cell-padding: 10px 16px; }
To style one table, give <x-wiretable> a class and set the tokens on it.
Props reference
| Component | Props |
|---|---|
x-wiretable |
stack (sm, md, lg, xl), expandable, compact, rows (separated), flush. Slots: head, default, footer. |
x-wiretable.th |
hide, shrink, actions, align, sortable, sort, direction, method (sortBy) |
x-wiretable.row |
href, navigate, action (the whole row opens it) |
x-wiretable.td |
hide, shrink, actions, align, label |
x-wiretable.primary |
title (required), subtitle, href, navigate, action. Slots: leading, default. |
x-wiretable.empty |
colspan (required) |
x-wiretable.menu |
label (required), align (left or right, default left) |
x-wiretable.menu-item |
href, danger. Slots: leading, default. |
x-wiretable.menu-separator |
none |
x-wiretable.load-more |
show (true), method (loadMore). The slot replaces the text. |
Any attribute that is not a prop lands on the component's element. The three strings the package says itself (actions header, fold button, "Load more") ship in English and Spanish; php artisan vendor:publish --tag=wiretables-lang copies them to lang/vendor/wiretables.
built and maintained by Edu Lazaro · MIT license