DataTable engine
A ready-made Livewire table whose columns, filters and actions are defined in a PHP class. Search, filters, sorting, selection and pagination run on the server; only the visible page is sent to the browser. Detailed guide: DataTable.
Table class
Extend the DataTable class (or its alias AcunDataTable); query() and columns() are required. Record visibility is limited by the scope in query(), access to the table by authorizeTable(). Use it on a page with <livewire:orders-table />.
use Acun\Ui\DataTable\DataTable;
final class OrdersTable extends DataTable
{
public string $title = 'Orders';
protected function query(): Builder
{
return Order::query()->visibleTo(auth()->user())->select(['id', 'number', 'customer_name', 'is_paid']);
}
protected function columns(): array
{
return [
Column::make('Order no.', 'number')->searchable()->sortable(),
Column::make('Customer', 'customer_name')->searchable(SearchMode::Contains)->sortable(),
Column::make('Paid', 'is_paid')->boolean(),
];
}
protected function authorizeTable(): void
{
$this->authorize('viewAny', Order::class);
}
}
| Prop | Values |
|---|---|
query() | Required: the base query |
columns() | Required: list of Column |
$title | Title in the toolbar |
Columns
Defined with Column::make(label, field); for a relation, the field can use dot notation. Formatters write the value in the format of the app language. The output of renderUsing() is escaped; for cells that contain HTML, use a view() that receives $row and $column. priority() sets when the column is hidden on narrow screens: 1 and 2 are always visible, 3 is hidden below 38rem (608px), 4 and up below 52rem (832px).
Column::make('Amount', 'total')->money('TRY')->align('right')->width('140px');
Column::make('Quantity', 'quantity')->number()->align('center');
Column::make('Delivery date', 'delivered_on')->date()->sortable()->priority(3);
Column::make('Created', 'created_at')->dateTime()->hidden();
Column::make('Customer', 'customer.name')->nowrap();
Column::make('Agent', 'agent_id')->renderUsing(fn ($row, $value) => $row->agent?->full_name ?? '—');
Column::make('Status', 'is_active')->view('tables.cells.status');
| Prop | Values |
|---|---|
sortable() · searchable(mode) | Adds the column to the sort and search lists |
hidden() · width() · align() · nowrap() | Appearance; align: left | center | right |
wrap() | Long text wraps to the next line, also in a scrollX() table; use it with width() |
priority(n) | 1 | 2 (always visible) · 3 (hidden < 38rem) · 4 and up (hidden < 52rem) |
date() · dateTime() · number() · money() · boolean() | Date, date and time, number and currency in the app language's format · Active/Inactive |
renderUsing(fn) · view(name) | Custom cell content |
Search
The search box waits 400 ms and then searches on the server. Searchable columns are joined with "or" inside a single grouped condition; the query() scope is preserved. Each column is searched in the normalized {field}_search column added in a migration with searchColumn(); every mode ignores Turkish characters and letter case ("ayse" → Ayşe). Choose the mode by data type. Add fields that are not shown as columns with searchFields(). When the search changes, the page resets to the first and the selection is cleared.
Column::make('Tax number', 'tax_number')->searchable(SearchMode::Exact);
Column::make('Code', 'custom_code')->searchable(); // Prefix (default)
Column::make('Address', 'address')->searchable(SearchMode::Contains);
Column::make('Full name', 'first_name')->searchable('contains', column: 'full_name_search');
protected function searchFields(): array
{
return [...parent::searchFields(), SearchField::make('email')];
}
| Prop | Values |
|---|---|
SearchMode | Prefix (default, indexed) | Exact (indexed) | Contains (scan) | FullText (MySQL FULLTEXT; contains on other databases) |
searchable(mode, column) | column: a search column generated from several sources |
searchFields() | Also searches fields that are not columns |
Filters
By default, filters are listed in the panel opened with the Filters button in the toolbar. select and boolean come with an "All" option, and a boolean without options shows Yes/No. Range filters show two fields; in a number range they carry the "Min" and "Max" hints. Date filters compare by day. For a project-specific condition, use applyUsing().
protected function filters(): array
{
return [
Filter::select('Status', 'is_active')->options(['1' => 'Active', '0' => 'Inactive']),
Filter::multiSelect('Region', 'region')->options(['east' => 'East', 'west' => 'West']),
Filter::dateRange('Delivery date', 'delivered_on'),
Filter::numberRange('Amount', 'total'),
Filter::boolean('Gift wrapped', 'is_gift'),
Filter::text('Customer', 'customer')
->applyUsing(fn (Builder $query, string $value) => $query->whereHas('customer', fn ($q) => $q->where('name', 'like', "%{$value}%"))),
];
}
| Prop | Values |
|---|---|
Filter:: | text | select | multiSelect | boolean | date | dateRange | number | numberRange |
options([...]) | Options for select, multiSelect and boolean |
applyUsing(fn ($query, $value)) | Replaces the default condition |
Toolbar filters
Move frequently used filters out of the panel into the toolbar with inToolbar(): they stay visible on their own row below the title, the search and the buttons. Each one is a single bordered group with its label on the left (e.g. Status | All). A multi-select opens a menu with checkboxes; its summary reads "All" or "N selected". The panel lists only the filters that are not in the toolbar; the badge on the Filters button counts only those, and "Clear filters" in the panel clears only those. When all filters are in the toolbar, the Filters button is not shown.
protected function filters(): array
{
return [
Filter::select('Status', 'is_active')->options(['1' => 'Active', '0' => 'Inactive'])->inToolbar(),
Filter::multiSelect('Region', 'region')->options(['east' => 'East', 'west' => 'West', 'south' => 'South'])->inToolbar(),
Filter::dateRange('Delivery date', 'delivered_on'), // stays in the panel
];
}
| Prop | Values |
|---|---|
inToolbar() | Shows the filter in the toolbar |
inDrawer() | Keeps it in the Filters panel (the default; for writing it explicitly) |
toggleFilterValue($key, $value) | The method the multi-select menu calls; it accepts only a defined multi-select filter and one of its defined options, then resets to the first page and clears the selection |
activeFilterCount(true | false) | Counts only the toolbar (true) or panel (false) filters; all of them without an argument |
clearFilters(true | false) | Clears only the toolbar or panel filters; all of them without an argument |
Row actions
Listed in the "•••" menu at the end of each row. visible() hides the action on that row, authorize() disables the button; both are checked again on the server when the action runs. The action receives ($row, $table); style('danger') shows it in red. icon() takes an acun:icon name and puts a small (1rem) icon before the label.
protected function actions(): array
{
return [
Action::make('Edit')
->icon('pencil-square')
->authorize(fn (Order $order) => auth()->user()->can('update', $order))
->action(fn (Order $order) => $this->redirectRoute('orders.edit', $order)),
Action::make('Delete')
->icon('trash')
->style('danger')
->visible(fn (Order $order) => $order->isCancelled())
->authorize(fn (Order $order) => auth()->user()->can('delete', $order))
->action(fn (Order $order) => $order->delete()),
];
}
| Prop | Values |
|---|---|
icon(name) | Name of the acun:icon before the label (e.g. pencil-square) |
visible(fn) | Whether to show it on the row |
authorize(fn) | Permission to run it |
style() | danger: red |
action(fn ($row, $table)) | The action |
Bulk actions and row authorization
When a selection is made, a selection bar appears above the table; "Select all N records matching the filters" selects all results. The action receives a query containing the selected records and runs after a confirmation prompt. authorize() checks the action as a whole and authorizeRow() checks each targeted record on the server: if even one record fails, no record is touched and a 403 is returned. As with row actions, icon() adds an icon before the label on the button.
protected function bulkActions(): array
{
return [
BulkAction::make('Mark as shipped')
->icon('truck')
->authorize(fn () => auth()->user()->can('updateAny', Order::class))
->action(fn (Builder $query) => $query->update(['status' => OrderStatus::Shipped])),
BulkAction::make('Delete')
->icon('trash')
->style('danger')
->authorize(fn () => auth()->user()->can('deleteAny', Order::class))
->authorizeRow(fn (Order $order) => auth()->user()->can('delete', $order))
->action(fn (Builder $query) => $query->delete()),
];
}
| Prop | Values |
|---|---|
icon(name) | Name of the acun:icon before the label on the button (e.g. truck) |
style() | danger: red button |
authorize(fn) | Permission for the action as a whole |
authorizeRow(fn ($row)) | Permission for each record; if one fails, the action stops |
action(fn ($query, $table)) | Query of the selected records |
Actions: toolbar, export and utilities
Override the headerActions(), exportActions() and utilityActions() methods in the table class; all of them are optional. On desktop, the page size and search sit on the left; Filters, Refresh, Columns, Export, the "⋮" menu and, last, the header actions sit on the right; on a phone, the first header action and an "Actions" button remain, and the button opens the other tools in a list. A hidden (visible) or unauthorized (can, authorize) action is not rendered, and if it is called from the browser, the server returns 403. action() takes a closure or the name of a (public or protected) method of the table. The browser side lives in the acunDataTable component in the PRO JS (import '@acunsoft/acun-ui-pro'). Details: the DataTable: all options guide, Actions section.
use Acun\Ui\DataTable\Actions\ExportAction;
use Acun\Ui\DataTable\Actions\TableAction;
use Acun\Ui\DataTable\Actions\UtilityAction;
protected function headerActions(): array
{
return [
TableAction::make('create')->label('New order')->icon('plus')
->url(fn () => route('orders.create'))
->can('create', Order::class)
->shortcut('mod+shift+n'),
TableAction::make('sync')->label('Sync')->icon('arrow-path')->color('gray')
->action('syncOrders') // protected function syncOrders(): void
->confirm('Sync the orders?')
->loadingLabel('Syncing…')
->successMessage('Orders updated.'),
];
}
protected function exportActions(): array
{
return [
ExportAction::csv()->shortcut('mod+e'),
ExportAction::pdf(), // the print page → "Save as PDF" in the browser
ExportAction::json()->can('export-json'),
ExportAction::print(),
ExportAction::copy(), // current page or selection, as tab-separated text
];
}
protected function utilityActions(): array
{
return [
UtilityAction::refresh(),
UtilityAction::columns(),
UtilityAction::fullscreen(),
UtilityAction::resetFilters(),
UtilityAction::resetSorting(),
];
}
protected function defaultSort(): ?array
{
return ['field' => 'created_at', 'direction' => 'desc'];
}
| Prop | Values |
|---|---|
label() · icon() · color() · variant() · tooltip() | Appearance; icon is an acun:icon name |
visible() · hidden() · disabled() | A bool or a closure; a hidden action is not rendered and does not run |
can($ability, $arguments) · authorize(fn) | Gate::allows or a custom check; on the server, on every call |
url($url, newTab: true) · action(fn | 'method') | A link or an action |
confirm($message, $title, $button) | Confirmation with acun:confirmation-dialog |
shortcut('mod+e') · loadingLabel() · successMessage() | Shortcut (mod: Ctrl / ⌘), loading text, success notification |
defaultSort() | The order used until a column is chosen; "Reset sorting" returns to it |
density() | Row density: compact, normal or comfortable (not in the user menu) |
rowDetails() | (+) at the start of the row: all the fields of the row in a window; responsive · always · null |
Export scope and queue
The "Export" menu first asks for the scope: Current page, Filtered records, Selected records (n) or All records (asks for confirmation). Default: the selection if there is one, the filtered records if there is a search or filter, otherwise the current page. Every scope keeps the query() scope and the sort; rows are read in chunks through the table's own buildQuery() pipeline. The columns are the visible columns, written as plain text in the same format as the cells. An export above queue_threshold is prepared by a queued job on behalf of the same user; when it finishes, the notification shows a signed download link that only its owner can use (php artisan queue:work is required). PDF opens the print page and the browser saves the file; no extra package is needed. CSV is written with a UTF-8 BOM and ;.
Column::make('Amount', 'amount')->money('TRY')->exportUsing(fn (Order $order) => $order->amount);
Column::make('Map', 'location')->view('cells.map')->exportable(false);
Column::make('Code', 'custom_code')->toggleable(false); // cannot be hidden from the Columns menu
// Your own format: Contracts\ExportDriver (extension, mimeType, write)
ExportAction::make('xml')->label('XML')->icon('code-bracket')->driver(XmlExportDriver::class);
// Eager load relations into the export query
protected function exportQuery(ExportScope $scope): Builder
{
return parent::exportQuery($scope)->with('customer');
}
| Prop | Values |
|---|---|
queue_threshold | Up to 5000 rows downloads right away; above that, it is queued |
exportUsing(fn ($row, $value)) | The exported value of the column |
exportable(false) · toggleable(false) | Not exported · always visible |
ExportScope | current_page · filtered · selected · all |
Bulk and row actions (new classes)
Actions\BulkAction and Actions\RowAction are created by name (make('approve')). Presets: BulkAction::delete(), exportSelected('csv'), printSelected(); RowAction::view($url), edit($url), delete(). A bulk action closure can receive $query, $ids, $records and $count, and asks for confirmation by default. When more than three actions are visible on a row, the first two are buttons and the rest are in the "⋮" menu. The old Action and BulkAction keep working as before.
use Acun\Ui\DataTable\Actions\BulkAction;
use Acun\Ui\DataTable\Actions\RowAction;
protected function bulkActions(): array
{
return [
BulkAction::make('ship')->label('Mark as shipped')->icon('truck')
->action(fn (Builder $query) => $query->update(['status' => OrderStatus::Shipped]))
->successMessage(':count orders marked as shipped.'),
BulkAction::exportSelected('csv'),
BulkAction::printSelected(),
BulkAction::delete()->authorizeRow(fn (Order $order) => auth()->user()->can('delete', $order)),
];
}
protected function actions(): array
{
return [
RowAction::view(fn (Order $order) => route('orders.show', $order)),
RowAction::edit(fn (Order $order) => route('orders.edit', $order))->can('update'),
RowAction::make('archive')->label('Archive')->icon('archive-box')
->visible(fn (Order $order) => ! $order->isArchived())
->action(fn (Order $order) => $order->archive()),
RowAction::delete()->can('delete'),
];
}
Empty state
The default texts are "No results found." when there is a search or filter and "No records yet." when there is not, both in the app language. Override the emptyState() method for your own texts.
protected function emptyState(): array
{
return $this->search !== '' || $this->activeFilterCount() > 0
? ['title' => 'No matching orders.', 'description' => 'Change the search or the filters.']
: ['title' => 'No orders yet.', 'description' => 'Create the first order with the New order button.'];
}
Example: Basic
The smallest DataTable: query() says which records to list and columns() which columns. Search, sorting and pagination come automatically. Demo: https://demo.acunsoft.test/admin/tables/datatable/basic
final class BasicOrdersTable extends DataTable
{
protected function query(): Builder
{
return Order::query();
}
protected function columns(): array
{
return [
Column::make('Order no.', 'number')->searchable()->sortable(),
Column::make('Customer', 'customer_name')->searchable('contains')->sortable(),
Column::make('Amount', 'amount')->money('TRY')->align('right')->sortable(),
Column::make('Order date', 'ordered_at')->dateTime()->sortable(),
];
}
}
Example: Responsive
priority(3) columns are hidden below 38rem and priority(4) columns below 52rem; rowDetails('responsive') puts a (+) at the start of the row only while a column is hidden and shows the hidden fields in a window. Narrow the window or open it on a phone. Demo: https://demo.acunsoft.test/admin/tables/datatable/responsive
protected function columns(): array
{
return [
Column::make('Order no.', 'number')->sortable(),
Column::make('Customer', 'customer_name'),
Column::make('Status', 'status')->view('tables.cells.order-status')->priority(3),
Column::make('Product', 'product')->priority(4),
Column::make('City', 'city')->priority(4),
];
}
protected function rowDetails(): ?string
{
return 'responsive';
}
Example: Scrolling table
scrollX() keeps cells on a single line and scrolls a wide table sideways (Column::wrap() columns wrap to the next line); scrollY() gives the box a height, so the table scrolls down inside the box and the header row stays at the top. It works with CSS; no JS is needed. Demo: https://demo.acunsoft.test/admin/tables/datatable/scroll
protected function scrollX(): bool
{
return true;
}
protected function scrollY(): ?string
{
return '400px'; // '28rem', 'min(70vh, 42rem)' …
}
Example: Server-side (Ajax)
Every DataTable works like this: a search, filter, sort or page change is a Livewire (Ajax) request; the package adds it to the query() query as SQL, the query runs in the database and only the rows of the open page return to the browser. 5,000 records and 5 million records open at the same speed. Demo: https://demo.acunsoft.test/admin/tables/datatable/server-side
protected function query(): Builder
{
return Order::query(); // search, filters, sorting and pagination are added to this
}
protected function filters(): array
{
return [
Filter::multiSelect('Status', 'status')->options(OrderStatus::options())->inToolbar(),
Filter::dateRange('Order date', 'ordered_at'),
];
}
Example: Fixed header
While the page scrolls, the header row stays below the top bar; at the end of the table it leaves together with the table. Sorting and "select all" work from the pinned header, in full screen and on phones too. When given together with scrollY(), scrollY() wins. Requires the PRO JS. Demo: https://demo.acunsoft.test/admin/tables/datatable/fixed-header
protected function fixedHeader(): bool
{
return true;
}
Example: Fixed columns
While the table scrolls sideways, start keeps the selection, the (+) and the first n data columns on the left, and end keeps the actions column on the right; a shadow appears at the edge while content passes underneath. On phones (below 640 px) only the left ones are fixed. Can be used together with scrollX() and fixedHeader(). Requires the PRO JS. Demo: https://demo.acunsoft.test/admin/tables/datatable/fixed-columns
protected function fixedColumns(): array
{
return ['start' => 1, 'end' => true];
}
protected function scrollX(): bool
{
return true;
}
Example: Buttons (export)
CSV, JSON, PDF ("Save as PDF" from the print page), print and copy. The scope is chosen in the Export menu (this page, those matching the filters, the selected ones, all); the file is prepared on the server with the table's query, and large files are prepared in the queue and arrive with a download link. Demo: https://demo.acunsoft.test/admin/tables/datatable/buttons
protected function exportActions(): array
{
return [ExportAction::csv(), ExportAction::pdf(), ExportAction::json(), ExportAction::print(), ExportAction::copy()];
}
protected function utilityActions(): array
{
return [UtilityAction::refresh(), UtilityAction::columns()];
}
Configuration
General settings are in the config/acun-ui.php file, published with php artisan vendor:publish --tag=acun-ui-config. The page size can only be a value from the per_page list; any other value falls back to the default.
return [
'datatable' => [
'per_page' => [10, 25, 50, 100],
'default_per_page' => 25,
'search_debounce' => 400, // ms
'url_state' => true, // search, sorting, filters, columns in the URL
'sticky_header' => false, // true: scrollY('min(70vh, 42rem)') on every table; per table, $stickyHeader
'pagination' => 'paginate', // paginate · simple · cursor
'actions' => ['enabled' => true],
'exports' => [
'enabled' => true,
'queue_threshold' => 5000, // above this, a queued job
'chunk_size' => 1000,
'disk' => 'local',
'directory' => 'exports',
'queue' => null,
'connection' => null,
'expire_minutes' => 60, // lifetime of the download link
'poll_seconds' => 3,
'cache_store' => null, // cache shared with the worker
'csv_delimiter' => ';',
'copy_max_rows' => 1000,
],
'print' => ['layout' => 'acun-ui-pro::components.data-table.print', 'max_rows' => 2000], // PDF opens this page too
'density' => ['default' => 'normal'], // compact · normal · comfortable; a table changes it with density()
'columns' => ['persist' => 'none'], // none · session
'routes' => ['middleware' => ['web', 'auth'], 'prefix' => 'acun-ui/data-table'],
],
];
Props and slots
The values the component accepts.
| Prop | Default | Description |
|---|---|---|
$title | '' | Title in the toolbar and accessible name of the table |
$stickyHeader | config: false | Old name: same as scrollY('min(70vh, 42rem)') |
scrollX() | false | Cells on a single line; a wide table scrolls sideways in its box (Column::wrap() columns wrap to the next line) |
scrollY() | null | Box height ('400px'): the table scrolls down inside the box, the header stays at the top |
fixedHeader() | false | While the page scrolls, the header row stays below the top bar; together with scrollY(), scrollY() wins (PRO JS) |
fixedColumns() | [] | ['start' => n, 'end' => true]: the selection, the (+) and the first n columns stay on the left, the actions on the right (PRO JS) |
query() | — | Required. The base query; the record scope goes here |
columns() | — | Required. List of Column |
filters() | [] | List of Filter |
headerActions() | [] | Toolbar actions (Actions\TableAction) |
exportActions() | [] | Export menu (Actions\ExportAction) |
utilityActions() | [] | Refresh, Columns, full screen, reset (Actions\UtilityAction); when empty, the column menu still shows |
actions() | [] | Row actions (Actions\RowAction or the old Action) |
bulkActions() | [] | Bulk actions (Actions\BulkAction or the old BulkAction) |
defaultSort() | null | ['field' => …, 'direction' => 'desc']: the order used until a column is chosen |
density() | config density.default ('normal') | 'compact' · 'normal' · 'comfortable': row density of the table |
showsSearch() | true when there is a field to search | Search box; searchPlaceholder() gives its text |
showsColumnPicker() | null (automatic) | false: no Columns button, true: always |
showsPerPageSelector() | true | Page size box ("25") |
perPageOptions() / defaultPerPage() | config per_page / default_per_page | Table-specific page size options and the size on load |
paginationOnEachSide() | 1 | Number of page numbers on each side of the current page |
rowDetails() | null | 'responsive' (only when a column is hidden) · 'always': row details window |
paginationMode() / paginationView() | config pagination / acun:pagination | Pagination type (paginate · simple · cursor) and its view |
exportQuery(ExportScope) | — | Export query; override it for eager loading |
authorizeTable() | — | Runs on the first load and on every later Livewire request |
emptyState() | — | ['title' => …, 'description' => …] |
buildQuery() | — | The query with search, filters and sorting applied (e.g. for export) |
activeFilterCount(?bool $toolbar) | null | Number of active filters (a "0" value also counts); null: all, true: toolbar, false: panel |
clearFilters(?bool $toolbar) | null | Clears the filters, resets to the first page and clears the selection; null: all, true: toolbar, false: panel |
toggleFilterValue($key, $value) | — | Checks or unchecks an option in a multi-select filter; an undefined filter or option is ignored |
URL state | — | q · sort · direction · filters · perPage · columns (when url_state is on) |