EN
Getting started

DataTable: all options

This page lists every DataTable method and setting, topic by topic. For a step-by-step guide, see DataTable; for working examples, see DataTable examples.

The table class

A table is a Livewire component that extends Acun\Ui\DataTable\DataTable. Only query() and columns() are required; everything else is optional.

The examples on this page use this orders table:

namespace App\Livewire;

use Acun\Ui\DataTable\Actions\{BulkAction, ExportAction, RowAction, TableAction, UtilityAction};
use Acun\Ui\DataTable\Column;
use Acun\Ui\DataTable\DataTable;
use Acun\Ui\DataTable\Filter;
use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;

final class OrdersTable extends DataTable
{
    public string $title = 'Orders';

    protected function query(): Builder
    {
        return Order::query();
    }

    protected function columns(): array
    {
        return [/* … */];
    }
}

Methods you can write in the table class:

Method What it does Section
query() The query of the records to list (required) Authorization and security
columns() The columns (required) Columns
filters() The filters Filters
defaultSort() The starting order Sorting
actions(), bulkActions() Row and bulk actions Actions
headerActions(), utilityActions() Toolbar buttons Toolbar
exportActions() The "Export" menu Export
authorizeTable() Who can open the table Authorization and security

Keeping it fast

  • The query runs in the database on every request; only the rows of the open page go to the browser. Do not load every record with Model::all() and filter it in PHP.
  • If needed, select only the columns you need in query() (select()) and eager load relations (with()).
  • Add database indexes to the columns you sort and filter by; the package does not change your migrations. Search columns come with their index through $table->searchColumn().
  • Export uses the table's own query; you do not write the search, filters and sorting a second time.

Columns

Columns decide which fields the table shows and how. Every column starts with Column::make('Label', 'field'):

Column::make('Order no.', 'number')->searchable()->sortable()->nowrap()->toggleable(false),
Column::make('Customer', 'customer_name')->searchable('contains')->view('admin.tables.cells.order-customer'),
Column::make('City', 'city')->hidden()->priority(4),
Column::make('Amount', 'amount')->renderUsing(fn (Order $order) => $order->formattedAmount())->align('right')->width('140px'),
Column::make('Order date', 'ordered_at')->dateTime()->sortable()->priority(3),

Column methods

Method What it does Example
searchable() The search box searches this column (Search) ->searchable('contains')
sortable() Clicking the header sorts by it ->sortable()
hidden() Starts hidden; the column menu shows it ->hidden()
toggleable(false) Always shown; the column menu cannot hide it ->toggleable(false)
priority() Hidden on narrow screens (below) ->priority(3)
width() Column width ->width('140px')
align() Alignment: left, center or right ->align('right')
nowrap() The cell stays on one line ->nowrap()
wrap() Long text wraps onto more lines, also in a scrollX() table ->wrap()->width('20rem')
money($currency, $locale) Money format; without a locale, the page's language ->money('TRY')
number($decimals) Number format ->number(2)
date($format) Date; without a format, the language's short date ->date('d.m.Y')
dateTime($format) Date and time ->dateTime()
boolean($yes, $no) Shows a true/false value as text; default "Active" / "Inactive" ->boolean('Open', 'Closed')
renderUsing() Your function produces the cell text ->renderUsing(fn (Order $order) => …)
view() Draws the cell with your own Blade file ->view('admin.tables.cells.order-status')
exportable(false) Left out of exports (Export) ->exportable(false)
exportUsing() Changes the value written to the file ->exportUsing(fn (Order $order) => $order->amount / 100)
  • The renderUsing() function receives the row, the field's value and the column: fn ($row, $value, $column). The text it returns is not read as HTML; it is shown as plain text. Use view() for cells that need HTML.
  • date() and dateTime() also format values the model does not cast to dates (strings or Unix timestamps). A value that cannot be read is shown as it is.

Drawing a cell with your own view

The view() file receives the row's model as $row and the column as $column:

{{-- resources/views/admin/tables/cells/order-status.blade.php --}}
<acun:badge :variant="$row->status->variant()">{{ $row->status->label() }}</acun:badge>

Do not give the cell content its own background color; leave it transparent. In fixed columns the package sets the color (Limitations and tips).

Hiding columns on narrow screens

By default, columns have priority(1) and show on every screen. A higher value hides the column on narrow screens:

Value When it is hidden
priority(1), priority(2) Never
priority(3) Below 38rem (608 px)
priority(4) and up Below 52rem (832 px)

The user sees the values of hidden columns through row details.

Row details (+)

Row details put a (+) button at the start of the row. The button shows all of the row's columns as "label: value" in a dialog (modal). Columns hidden by default, turned off in the column menu or hidden on a narrow screen are listed too.

protected function rowDetails(): ?string
{
    return 'responsive';   // 'always': on every screen; null: off (default)
}

// Optional: the columns and title of the dialog
protected function rowDetailsColumns(): array
{
    return $this->resolvedColumns();   // default: every column
}

protected function rowDetailsTitle(Model $row): string
{
    return $row->number.' · '.$row->customer_name;   // default: the first column's value
}
  • With 'responsive', the (+) only appears while a column is hidden because of the screen width.
  • Cell formats (view(), renderUsing(), money()…) are used in the dialog as well.
  • The dialog only opens a record from the table's own query(); another account's record cannot be opened.

Column menu

The column menu lets the user show and hide columns. The toolbar button is labeled "Columns".

  • The menu has "Show all" and "Reset to default".
  • toggleable(false) columns are always shown; the server refuses to hide them even if the browser asks.
  • The user's choice is written to the address bar when it differs from the default. To remember it for the whole session, set acun-ui.datatable.columns.persist to session.
  • When the menu appears: Toolbar.

The search box searches the searchable() columns and the searchFields() fields. The search runs on the server and uses the infrastructure from the Search guide.

Search column

Every field is searched through a separate search column that the database builds from it. The column is named {field}_search. Add it with a migration:

Schema::table('orders', function (Blueprint $table) {
    $table->searchColumn('number');          // number_search + index
    $table->searchColumn('customer_name');   // customer_name_search + index
});

Turkish letters and letter case are folded in the search column. So in every search mode "ayse" → "Ayşe", "ali" → "ALİ" and "ALI", "ismail isik" → "İsmail Işık", "ÇAĞRI" → "çağrı" are found.

Search modes

The mode only decides where in the value the term may sit:

Mode Match Index
SearchMode::Prefix (default) The value starts with the term Used
SearchMode::Exact The value equals the term Used
SearchMode::Contains The term is anywhere in the value Not used; choose it on purpose for small tables
SearchMode::FullText Every word starts a word of the value Used on MySQL

FullText uses the FULLTEXT index on MySQL; add the search column with $table->searchColumn('name', fullText: true). On other databases every word is searched like Contains.

Set the mode on the column. It can also be written as text: 'prefix', 'exact', 'contains', 'fulltext'.

use Acun\Ui\Search\SearchMode;

Column::make('Order no.', 'number')->searchable(),                          // number_search, prefix
Column::make('Customer', 'customer_name')->searchable(SearchMode::Contains),
Column::make('Customer', 'customer_name')->searchable('contains', column: 'customer_search'),

The last line reads a search column built from several fields. Add such a column in a migration with $table->searchColumn(['customer_name', 'customer_email'], 'customer_search').

Searching a field that is not a column

To search a field that is not shown as a column, write the searchFields() method. You do not need a hidden column:

use Acun\Ui\Search\SearchField;

protected function searchFields(): array
{
    return [...parent::searchFields(), SearchField::make('customer_email')];
}

How search works

  • The box searches 400 ms after the user stops typing (acun-ui.datatable.search_debounce).
  • All searched fields are added to the query as one grouped "this field or that field" condition. So the conditions in your query() stay intact.
  • % and _ in the term are not wildcards; they are searched as literal text: "50%" only finds records that contain "50%". The package adds ESCAPE '!' to every LIKE condition.
  • On SQLite, prefix search uses GLOB, where *, ? and [ are literal text as well. The behavior is the same on SQLite, MySQL, PostgreSQL and SQL Server.
  • At most 200 characters and 10 words of the term are used (acun-ui.search.max_length, max_words).
  • For another search driver (e.g. your own driver for Meilisearch), write a searchDriver() method in the table or change acun-ui.search.driver. The column definitions stay the same. See Search: drivers.
  • Hiding the search box and changing its placeholder: Toolbar.

Filters

Filters narrow the list by a field. They are listed in the filters() method:

protected function filters(): array
{
    return [
        Filter::select('Channel', 'channel')->options(OrderChannel::options())->inToolbar(),
        Filter::multiSelect('Status', 'status')->options(OrderStatus::options())->inToolbar(),
        Filter::dateRange('Order date', 'ordered_at'),
        Filter::select('City', 'city')->options(array_combine(Order::CITIES, Order::CITIES)),
    ];
}

Filter types

Method What it does Example
Filter::text() Text box; finds records that contain the value Filter::text('Product', 'product')
Filter::select() Dropdown; records equal to the chosen value Filter::select('Channel', 'channel')->options([…])
Filter::multiSelect() List with checkboxes; records equal to one of the chosen values Filter::multiSelect('Status', 'status')->options([…])
Filter::boolean() Yes / No choice Filter::boolean('Active', 'is_active')
Filter::date() A single day Filter::date('Day', 'ordered_at')
Filter::dateRange() Start and end day Filter::dateRange('Date', 'ordered_at')
Filter::number() Records equal to a number Filter::number('Quantity', 'quantity')
Filter::numberRange() Minimum and maximum value Filter::numberRange('Amount', 'amount')
  • date and dateRange compare by day. On datetime columns the whole chosen day is included; in a range the whole end day is included too.
  • Without options(), a boolean filter offers "Yes" (1) and "No" (0).
  • A value such as '0' (e.g. "No") counts as an active filter. An empty range does not.

Filters in the toolbar

By default, filters are in the drawer that the "Filters" button opens. Move a few frequently used ones to the row under the search box with inToolbar(); they are always visible there.

  • Select filters show the filter name in every option: "Channel: Web".
  • A multi-select is a dropdown with checkboxes: "Status: 2 selected". It stays open while you tick boxes.
  • Range filters are drawn as two fields.
  • The drawer lists only the filters that are not in the toolbar. If none are left, the "Filters" button is not shown.
  • The count badge on the button and "Clear" in the drawer cover only the drawer filters.
  • inDrawer() keeps a filter in the drawer explicitly (the default).

Your own condition

If the built-in comparison is not enough, write the condition yourself with applyUsing(). The function receives the query and the filter's value:

// The amount is entered in lira and stored in kuruş.
Filter::numberRange('Amount (₺)', 'amount')->applyUsing(function (Builder $query, mixed $value): void {
    if (is_numeric($value['from'] ?? null)) {
        $query->where('amount', '>=', (int) round((float) $value['from'] * 100));
    }

    if (is_numeric($value['to'] ?? null)) {
        $query->where('amount', '<=', (int) round((float) $value['to'] * 100));
    }
}),

Counting and clearing active filters

Method What it does Example
activeFilterCount() Number of active filters $this->activeFilterCount()
activeFilterCount(toolbar: …) true: only toolbar filters; false: only drawer filters $this->activeFilterCount(toolbar: false)
clearFilters() Clears filters; takes the same toolbar: parameter $this->clearFilters(toolbar: true)

The filter badge and the empty-state message use activeFilterCount().

Sorting

Sorting decides which column orders the rows. Only columns marked sortable() can be sorted.

  • The first click on a header sorts ascending, the second descending. Clicking another column starts ascending.
  • A column name from the browser is used only if it is one of the sortable() columns (Authorization and security).

Default order

defaultSort() gives the order used until the user picks a column:

protected function defaultSort(): ?array
{
    return ['field' => 'ordered_at', 'direction' => 'desc'];
}
  • The field does not have to be a sortable column.
  • "Reset sorting" returns to this order.
  • The default order is not written to the address bar; ?direction= only appears when it differs from the default.

Equal values

After the sorted column, the model's key (usually id) is added in the same direction. So rows with equal values keep one order across pages, cursor pagination and exports read in chunks. Turn it off for a grouped (groupBy) query:

protected function sortTieBreaker(Builder $query): ?string
{
    return null;
}

Actions

Actions add buttons to the table: to the toolbar, to the end of every row and to the selection bar. Each group is a method in the table class. All of them are optional and return an empty list by default.

Method Class Where
headerActions() Actions\TableAction Right end of the toolbar ("New order")
utilityActions() Actions\UtilityAction Refresh, the column menu and the "⋮" menu
exportActions() Actions\ExportAction The "Export" menu
actions() Actions\RowAction End of every row
bulkActions() Actions\BulkAction The selection bar

The browser side (full screen, shortcuts, copying, the print window, confirmation dialogs) lives in the acunDataTable component in the PRO JavaScript file. Your app's JS entry must contain import '@acunsoft/acun-ui-pro'; (JavaScript layers). If the PRO package was updated, run npm run build.

Full example

final class OrdersTable extends DataTable
{
    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')                       // a method of the table
                ->confirm('Sync the orders?', 'Sync')
                ->loadingLabel('Syncing…')
                ->successMessage('Orders updated.'),
        ];
    }

    protected function exportActions(): array
    {
        return [
            ExportAction::csv()->shortcut('mod+e'),
            ExportAction::pdf(),
            ExportAction::json()->can('export-json'),
            ExportAction::print(),
            ExportAction::copy(),
        ];
    }

    protected function utilityActions(): array
    {
        return [
            UtilityAction::refresh(),
            UtilityAction::columns(),
            UtilityAction::fullscreen(),
            UtilityAction::resetFilters(),
            UtilityAction::resetSorting(),
        ];
    }

    protected function bulkActions(): array
    {
        return [
            BulkAction::make('ship')->label('Ship')->icon('truck')
                ->action(fn (Builder $query) => $query->where('status', 'paid')->update(['status' => 'shipped']))
                ->successMessage(':count orders shipped.'),
            BulkAction::exportSelected('csv'),
            BulkAction::printSelected(),
            BulkAction::delete()->can('delete-any', Order::class)
                ->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('ship')->label('Ship')->icon('truck')
                ->visible(fn (Order $order) => $order->status === OrderStatus::Paid)
                ->action(fn (Order $order) => $order->update(['status' => OrderStatus::Shipped])),
            RowAction::delete()->can('delete'),
        ];
    }

    protected function syncOrders(): void
    {
        // …
    }
}

Shared methods

Every action class is built on TableAction and shares these methods. The name given with make('name') must be unique within a group. It may contain letters, digits, -, _, . and :.

Method What it does Example
label() Button text; without it, made from the name ->label('New order')
icon() Icon before the text (a heroicons name) ->icon('plus')
tooltip() Tip shown on hover ->tooltip('Sync with the store')
iconOnly() Icon only; the text becomes the tooltip and screen reader name ->iconOnly()
color() Semantic color (Button appearance) ->color('gray')
variant() An acun:button variant directly; wins over color() ->variant('outline')
url() A link; newTab: true opens a new tab ->url(fn () => route('orders.create'))
navigate() Opens the link without a full page reload (wire:navigate) ->navigate()
action() The function to run, or the name of a table method ->action('syncOrders')
confirm($message, $title, $button) Opens a confirmation dialog before running ->confirm('Are you sure?')
withoutConfirmation() Turns confirmation off ->withoutConfirmation()
visible(), hidden() Shows or hides by a condition ->visible(fn (Order $order) => …)
can($ability, $arguments) Laravel authorization check (Gate::allows) ->can('update')
authorize() Any other check; when it returns false there is no action ->authorize(fn () => …)
disabled() Drawn greyed out and does not run ->disabled(fn (Order $order) => …)
shortcut() Keyboard shortcut (below) ->shortcut('mod+e')
loadingLabel() Text on the button while it runs ->loadingLabel('Preparing…')
successMessage() Success notification when it finishes ->successMessage('Updated.')
attributes() Extra HTML attributes (for tests or analytics) ->attributes(['data-test' => 'new'])
order() Order; a smaller number is drawn first ->order(10)
  • url() renders a link; action() runs on the server. Do not set both.
  • Without a title and button text, confirm() uses the action's label. On a row action the message can be a function: ->confirm(fn (Order $order) => "Cancel {$order->number}?").
  • A hidden or unauthorized action is not rendered; if the browser calls it, the server returns 403. A disabled() action is drawn greyed out and also returns 403 if called.
  • On a row action, can() without arguments checks against the row itself: ->can('update') → Gate::allows('update', $order).
  • :count in successMessage() becomes the number of records in a bulk action.

Values an action receives

Action functions ask for the values they need by name or by type:

Parameter What it gives Where
$table The table component All actions
$row, $record or the model type (Order $order) The row's model Row actions
$query or $builder The query narrowed to the selection Bulk actions
$ids or $keys The keys of the selected records Bulk actions
$records The selected models, read in chunks Bulk actions
$count The number of selected records Bulk actions

Untyped parameters are filled in order: the row and the table for row actions, the query and the table for bulk actions. Any other class type comes from Laravel's service container. In a bulk action only the values you ask for are computed.

Button appearance

Every action is drawn as an acun:button. Set its color by meaning with color(); the package picks the right variant for the button's place:

color() Header and toolbar Row and bulk action
not set primary (header), white (toolbar) soft
'primary' primary soft
'secondary' secondary soft-secondary
'gray' white (white, framed) soft-secondary
'danger' danger soft-danger
'success' success soft-success
'warning' warning soft-warning
'info' info soft-info
'dark' dark soft

If you want a specific variant, variant() wins over everything:

TableAction::make('create')->label('New order')->icon('plus');                       // primary color, solid
TableAction::make('import')->label('Import')->icon('arrow-up-tray')->color('gray');  // white, framed
TableAction::make('archive')->label('Archive')->variant('outline');
TableAction::make('sync')->label('Sync')->icon('arrow-path')->iconOnly()->tooltip('Sync with the store');

Icon names are the heroicons names of the acun:icon component: plus, arrow-path, arrow-down-tray, printer, truck, eye, pencil-square, trash… The action classes only carry the name.

Header actions

Header actions sit at the right end of the toolbar. On phones the first header action stays visible and the others move into the "Actions" panel, so put the most important button first.

protected function headerActions(): array
{
    return [
        // Go to a page
        TableAction::make('create')->label('New order')->icon('plus')
            ->url(route('orders.create'))
            ->shortcut('mod+shift+n'),

        // Open in a new tab
        TableAction::make('report')->label('Monthly report')->icon('document-text')->color('gray')
            ->url(fn () => route('reports.monthly'), newTab: true),

        // Open a dialog on the page (an acun:modal or a form modal)
        TableAction::make('quick-add')->label('Quick add')->icon('bolt')->color('gray')
            ->action(fn (self $table) => $table->dispatch('acun:modal:open', name: 'order-form')),

        // Run a table method, after asking for confirmation
        TableAction::make('recalculate')->label('Recalculate')->icon('arrow-path')->color('gray')
            ->action('recalculateTotals')
            ->confirm('Recalculate the order totals?', 'Recalculate totals', 'Recalculate')
            ->loadingLabel('Recalculating…')
            ->successMessage('Totals updated.')
            ->can('recalculate', Order::class),
    ];
}

protected function recalculateTotals(): void
{
    // …
}

action('recalculateTotals') runs a method of the table. The method can be public or protected. The browser only sends the action's name; the table decides which method runs.

Row actions

Row actions are the buttons at the end of every row:

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'),

        // An action shown depending on the status
        RowAction::make('ship')->label('Ship')->icon('truck')->color('success')
            ->visible(fn (Order $order) => $order->status === OrderStatus::Paid)
            ->action(fn (Order $order) => $order->update(['status' => OrderStatus::Shipped]))
            ->successMessage('The order was shipped.'),

        // An action that opens a panel on the page
        RowAction::make('notes')->label('Notes')->icon('chat-bubble-left-ellipsis')
            ->action(fn (Order $order, self $table) => $table->dispatch('show-order-notes', id: $order->id)),

        RowAction::delete()->can('delete'),
    ];
}
  • RowAction::view($url) and RowAction::edit($url) are links.
  • RowAction::delete() asks for confirmation, deletes the record (model events run) and shows a notification. To change its texts: ->confirm('Delete this order permanently?')->successMessage('Order deleted.').
  • A row action with an icon is an icon button; its label is the screen reader name and the tooltip. Without an icon it is a small button with its label.
  • When more than three actions show in a row, the first two are buttons and the rest go into a "⋮" menu. The menu is not clipped by the table's scroll box.
  • visible(), can() and disabled() are checked separately for every row.

Selection

The selection decides which records the bulk actions run on.

  • The header checkbox selects every record that matches the search and filters, on every page. While some are selected, the box shows a dash; clicking again clears the selection.
  • To select only the visible page, set acun-ui.datatable.select_all to page.
  • Moving between pages and changing the page size keep the selection. Changing the search or a filter clears it.
  • While all records are selected, the visible rows appear ticked. Unticking a row takes it out of the selection; the bulk action then runs on "the matches minus the unticked rows". Unticking the header box clears the selection completely.
  • While all records are selected, thousands of keys are not written to the browser; the action runs on the same server-side query.
  • At most 5,000 records can be ticked one by one (acun-ui.datatable.selection.max_keys). Selecting all records has no limit.
  • While there is a selection, the selection bar (acun:selection-bar) appears above the table.

Bulk actions

Bulk actions are the buttons in the selection bar:

protected function bulkActions(): array
{
    return [
        BulkAction::make('approve')->label('Approve')->icon('check')->color('success')
            ->action(fn (Builder $query) => $query->update(['status' => 'approved']))
            ->confirm('Approve the :count selected orders?')
            ->successMessage(':count orders approved.'),

        BulkAction::exportSelected('csv'),
        BulkAction::printSelected(),
        BulkAction::delete()->can('delete-any', Order::class),
    ];
}
  • :count becomes the number of selected records in the confirmation and success texts.
  • Bulk actions ask for confirmation by default. The dialog is the acun:confirmation-dialog component; the browser's own confirm box is not used. withoutConfirmation() turns it off.
  • The selection is cleared when the action finishes; keepSelection() keeps it.
  • Row authorization with authorizeRow(): Authorization and security.

Ready-made bulk actions:

Method What it does Example
BulkAction::delete() Deletes the records one by one; model events and soft deletes apply BulkAction::delete()
BulkAction::exportSelected() Downloads the selection as csv, json or your own format; pdf opens the print page. Keeps the selection BulkAction::exportSelected('csv')
BulkAction::printSelected() Opens the selection on the print page; keeps the selection BulkAction::printSelected()

Skipping records that do not qualify

Sometimes only part of the selection qualifies (e.g. only paid orders can be shipped). Then narrow the query yourself and report the result:

BulkAction::make('ship')->label('Ship')->icon('truck')
    ->withoutConfirmation()
    ->action(function (Builder $query, int $count, self $table) {
        $shipped = (clone $query)->where('status', 'paid')->update(['status' => 'shipped']);
        $table->dispatch('toast', type: 'success', message: "{$shipped} orders shipped, ".($count - $shipped).' skipped.');
    }),

authorizeRow(), on the other hand, is an authorization rule: if even one record in the selection fails it, no record is touched (403). Use the pattern above for business rules and authorizeRow() for authorization.

Keyboard shortcuts

shortcut('mod+e') gives an action a keyboard shortcut. mod is Ctrl on Windows and Linux and ⌘ on macOS.

  • The shortcut is shown in the menu and the tooltip. It does not fire while typing in a field.
  • Modifier keys: mod, ctrl, meta, alt, shift. The last key can be a letter, digit or symbol, f1–f12, enter, delete, backspace, insert, home or end.

The older Action and BulkAction classes

Acun\Ui\DataTable\Action and Acun\Ui\DataTable\BulkAction are the classes from before the action system, and they still work:

use Acun\Ui\DataTable\Action;
use Acun\Ui\DataTable\BulkAction;

Action::make('Details', 'detail')->icon('eye')->action(fn (Order $order) => $this->dispatch('show-order', id: $order->id));
BulkAction::make('Ship', 'ship')->icon('truck')->action(fn (Builder $query) => $query->update(['status' => 'shipped']));
  • make($label, $name): the second parameter is a fixed name. Without it, the name is made from the label and changes when the label is translated.
  • icon(), style('danger'), visible(), authorize() and action() are available. The older BulkAction also takes authorizeRow().
  • An unauthorized older action is not hidden; it is drawn greyed out. Older bulk actions always ask for confirmation.
  • In new tables, use Actions\RowAction and Actions\BulkAction.

Export

Export lets users download, print or copy the records to the clipboard. List the formats in the exportActions() method:

protected function exportActions(): array
{
    return [
        ExportAction::csv()->label('CSV (.csv)')->shortcut('mod+e'),
        ExportAction::pdf(),
        ExportAction::print()->label('Print'),
        ExportAction::copy()->can('orders.export'),
    ];
}

The menu shows only the formats you list, in the order you list them. If the list is empty, there is no "Export" button.

Formats

Method What it does Example
ExportAction::csv() CSV file ExportAction::csv()
ExportAction::json() JSON file ExportAction::json()
ExportAction::pdf() Opens the print page; the user picks "Save as PDF" in the browser ExportAction::pdf()
ExportAction::print() A clean print page in a new window ExportAction::print()
ExportAction::copy() Copies the rows to the clipboard; pastes into Excel as cells ExportAction::copy()

Scope

The "Export" menu first asks which rows to take:

Scope Rows Value
Current page The page on screen current_page
Filtered records Every record matching the search and filters filtered
Selected records (n) The selection; when all records are selected, minus the unticked ones selected
All records The whole query(), without search and filters; asks for confirmation first all

The default scope: the selected records if there is a selection, the filtered records if a search or filter is active, otherwise the current page. To change it in the table:

use Acun\Ui\DataTable\Export\ExportScope;

protected function defaultExportScope(): ExportScope
{
    return ExportScope::Filtered;
}
  • A scope that does not apply right now is greyed out (e.g. "Selected records" without a selection).
  • copy() works only with the current page and the selection; it copies at most 1,000 rows (exports.copy_max_rows).
  • Every scope keeps the query() conditions and the current sort order.

Format options

Method What it does Example
scopes() The scopes the format accepts ->scopes(['filtered', 'selected'])
defaultScope() This format's default scope ->defaultScope('filtered')
filename() File name without the extension ->filename('orders')
maxRows() Maximum number of rows ->maxRows(10000)
queue(false) Builds the file right away, never in the queue ->queue(false)
driver() Your own writer class ->driver(XmlExportDriver::class)

Without a file name, the table title and the date are used, e.g. orders-2026-10-05-143000.csv.

Exported columns and values

The visible columns are written to the file. Values come as plain text from the same formats as the cells (date, number, money, boolean, renderUsing). view() cells write the field's raw value; give exportUsing() for a different value:

// On screen "₺1,249.90", in the file 1249.9 (a number)
Column::make('Amount', 'amount')
    ->renderUsing(fn (Order $order) => $order->formattedAmount())
    ->exportUsing(fn (Order $order) => $order->amount / 100),

Column::make('Status', 'status')->view('admin.tables.cells.order-status')
    ->exportUsing(fn (Order $order) => $order->status->label()),

Column::make('Internal note', 'internal_note')->exportable(false),   // not in the file

If the export uses relations, write the exportQuery() method to eager load them:

protected function exportQuery(ExportScope $scope): Builder
{
    return parent::exportQuery($scope)->with('customer');
}

File format details

  • CSV is written with a UTF-8 BOM and ; as the default separator, so Turkish Excel opens it correctly. Text starting with =, +, - or @ gets a leading ', so no formula runs when the file is opened.
  • PDF doesn't build a separate file: it opens the print page and suggests the browser's "Save as PDF" option. No extra package is needed. The row limit and the view are the print page's (print.max_rows, print.layout).
  • The print page shows at most 2,000 rows (print.max_rows). You can change its view with print.layout.

Your own format

Write a class that implements the Acun\Ui\DataTable\Export\Contracts\ExportDriver interface. The interface has three methods: extension(), mimeType() and write($rows, $columns, $stream, $options). Rows arrive one at a time; do not collect them, write them to the stream as they come.

ExportAction::make('xml')->label('XML')->driver(XmlExportDriver::class),

To use the format in every table, or to replace one of the built-in writers, add the class to the acun-ui.datatable.exports.drivers setting.

Queued export

Below 5,000 rows (exports.queue_threshold) the file downloads right away. Above that, a queued job builds the file:

  1. A "The export was queued" notification appears.
  2. While a job is pending, the table checks the status every few seconds (exports.poll_seconds).
  3. When the job is done, a notification with a download link appears.
  • CSV, JSON and your own formats can go to the queue. PDF, print and copy are always built right away; the "Current page" scope is never queued either.
  • The job signs in the requesting user on the same guard. It rebuilds the table with the search, filters, sort order and selection of that moment; authorizeTable() and the action's authorization are checked again.
  • Rows are read in chunks of 1,000 (exports.chunk_size), keeping the order. The file is written to the exports.disk disk, in the exports.directory folder.
  • The download link is signed, expires after 60 minutes (exports.expire_minutes) and gives the file only to the user who started the job.
  • Old files are deleted after every queued job and by the acun-ui:prune-exports command (Configuration).

Requirements:

  • A queue worker: php artisan queue:work. If you set a custom queue name (exports.queue), add --queue=exports.
  • A cache store shared by the web server and the worker: database, redis, file… (not array). The job status is kept there (exports.cache_store).
  • The values the table's query() needs must be in public properties; the worker does not run mount(). Rebuild non-public values in the restoringFromSnapshot() method.

Toolbar

The toolbar is the box above the table: page size, search, filters, tools and header actions live there. On desktop, from left to right:

[ 25 ] [ search ]  …  [ ⏷ ] [ ↻ ] [ custom tools ] [ ⊞ ] [ ⤓ ] [ ⋮ ]  [ + New order ]
[ toolbar filters (Status: All ▾) (Channel: All ▾) … ]

Filters (⏷), Refresh (↻), the column menu (⊞) and Export (⤓) are icon-only. Their names appear as tooltips on hover. The number of active filters sits as a badge on the corner of the Filters icon.

Showing and hiding parts

You turn every part on or off from the table class; you do not need to publish views.

Part When it shows How to change it
Title and record count When $title is set public string $title = 'Orders';
Header actions When headerActions() is not empty Header actions
Search box When there is something to search showsSearch(), searchPlaceholder()
Filters button When the drawer has filters inToolbar() filters sit in the row below
Refresh When UtilityAction::refresh() is listed utilityActions()
Column menu When utilityActions() is empty or contains UtilityAction::columns() showsColumnPicker()
Export When exportActions() is not empty exportActions()
Page size Always showsPerPageSelector()
"⋮" menu When full screen, a reset or a custom tool is listed utilityActions()

Example: no column menu and no Export.

// Export: do not write exportActions(), or return [].

// Column menu: turn it off.
protected function showsColumnPicker(): ?bool
{
    return false;
}

When showsColumnPicker() returns null (the default), the rule in the table applies. false always hides it, true always shows it.

Example: a plain table with only search.

protected function showsPerPageSelector(): bool
{
    return false;   // no page size box; defaultPerPage() sets the size
}

protected function searchPlaceholder(): string
{
    return 'Search order no. or customer';
}

To remove the search box completely, have showsSearch() return false. If no column is searchable() and searchFields() is empty, the box does not show anyway.

Table tools

utilityActions() decides which tools appear; a tool that is not listed is not drawn:

protected function utilityActions(): array
{
    return [
        UtilityAction::refresh(),
        UtilityAction::columns(),
        UtilityAction::fullscreen(),
        UtilityAction::resetFilters(),
        UtilityAction::resetSorting(),

        // Your own tool: in the "⋮" menu
        UtilityAction::make('clear-cache')->label('Clear cache')->icon('trash')
            ->action(fn () => Cache::tags('orders')->flush())
            ->successMessage('Cache cleared.'),

        // As an icon button in the toolbar
        UtilityAction::make('help')->label('Help')->icon('question-mark-circle')->iconOnly()
            ->url(route('help.orders'), newTab: true)
            ->inline(),
    ];
}
Tool What it does Where
refresh() Reloads the rows; if the page no longer exists, goes to the last page Toolbar
columns() The column menu Toolbar
fullscreen() Opens the table full screen; Esc or "Exit full screen" closes it "⋮" menu
resetFilters() Clears the search and every filter "⋮" menu
resetSorting() Returns to the defaultSort() order "⋮" menu
make('name') Your own tool; with inline() it sits in the toolbar "⋮" menu
  • Full screen spreads the table over the whole screen with the page background and locks page scrolling.
  • If you list only [UtilityAction::columns()], there is no "⋮" menu.
  • order() changes the order of the tools.

Toolbar on phones

On phones (below 640 px) one row stays under the search box: the page size, the first header action and an "Actions" button. Every other tool moves into the panel this button opens, stacked with its text. There is no horizontal overflow at 375 px. This layout is used when the table has at least one header, export or utility action.

While loading, the clicked button is disabled and shows a spinner. If you set loadingLabel(), that text shows too.

Pagination

The pagination bar sits under the table: "1 - 25 / 5000 results" on the left; previous, page numbers and next on the right.

  • It stays in place when there are no results ("No results").
  • On phones only previous, current and next show.
  • Changing the page scrolls to the top of the list.
Method What it does Example
perPageOptions() Page size options (public) return [20, 50, 100];
defaultPerPage() Starting size; must be one of the options return 50;
showsPerPageSelector() Shows or hides the page size box return false;
paginationOnEachSide() How many numbers show on each side of the current page (default 1) return 2;
paginationMode() Pagination type return PaginationMode::Cursor;
paginationView() Your own pagination view (public) return 'tables.pagination-compact';
use Acun\Ui\DataTable\PaginationMode;

public function perPageOptions(): array
{
    return [20, 50, 100];
}

protected function defaultPerPage(): int
{
    return 50;
}

protected function paginationMode(): PaginationMode
{
    return PaginationMode::Simple;
}

Configuration for every table: acun-ui.datatable.per_page (options, default [10, 25, 50, 100]), default_per_page (25) and pagination. ?perPage= in the address bar can only be one of the options; any other value falls back to the default.

Pagination type

Type What it does Configuration value
PaginationMode::LengthAware (default) Knows the total page count; page numbers show paginate
PaginationMode::Simple Only previous and next simple
PaginationMode::Cursor The fastest; no numbers or total, only previous and next cursor

Counting the total is slow on very large tables; choose Simple or Cursor then.

Texts

"Showing", "results", "No results", "Previous" and "Next" are translation keys. Change them by writing the same key in your app's lang/en.json file (Languages and translation):

{
    "Showing": "Listing",
    "results": "records",
    "No results": "No records found"
}

Pagination view

Page numbers and the previous/next buttons are drawn with acun:button (white), the current page in the primary color. There are two ways to change the bar's HTML:

  1. In the whole app: publish the views with php artisan vendor:publish --tag=acun-ui-views and edit resources/views/vendor/acun-ui/components/ui/pagination.blade.php. DataTable and WithListing lists use this view.

  2. In one table only: give your own view with paginationView(). Like Laravel's pagination views, it receives $paginator and $elements:

    public function paginationView(): string
    {
        return 'tables.pagination-compact';
    }
    
    {{-- resources/views/tables/pagination-compact.blade.php --}}
    <nav class="flex items-center justify-end gap-2" aria-label="Pagination">
        <acun:button variant="ghost" size="sm" wire:click="previousPage" :disabled="$paginator->onFirstPage()">Previous</acun:button>
        <span class="text-sm text-gray-600 dark:text-zinc-400">{{ $paginator->currentPage() }} / {{ $paginator->lastPage() }}</span>
        <acun:button variant="ghost" size="sm" wire:click="nextPage" :disabled="! $paginator->hasMorePages()">Next</acun:button>
    </nav>
    

In lists written by hand with WithListing, perPageOptions() and paginationView() do the same job (List pages).

Appearance and colors

The table is drawn with the theme's own components: acun:table, acun:input, acun:filter-button, acun:drawer, acun:selection-bar, acun:pagination. So it follows the theme's primary color and dark mode on its own; you do not need to add colors to the table.

  • The primary color is brand; light mode uses gray shades and dark mode zinc shades. Details: Customization.
  • The PRO package's acun-ui-pro.css only adds the rules for hiding columns on narrow screens, scrolling, the fixed header and fixed columns.

Title

$title shows a title and the record count above the table; when it is empty, neither shows:

public string $title = 'Orders';

The title is also used on the print page and in the file name. It cannot be changed from the browser. For a translated title, set it in mount():

public function mount(): void
{
    $this->title = __('Orders');

    parent::mount();
}

Empty state

emptyState() gives the title and description shown when there are no results:

protected function emptyState(): array
{
    return $this->search !== ''
        ? ['title' => 'No orders match your search.', 'description' => 'Try another order no. or customer.']
        : ['title' => 'No orders yet.', 'description' => 'They will appear here when the first order arrives.'];
}

While a search or filter is active, a "Clear filters" button is added automatically.

Row density

Row density sets the cell spacing using the theme's spacing scale. It is a setting of the table, not a tool the user changes:

protected function density(): string
{
    return 'compact';   // compact, normal or comfortable
}

Without it, acun-ui.datatable.density.default (normal) is used. An invalid value counts as normal.

Overriding views

To change the HTML completely, publish the PRO views. Try the PHP options on this page first; publishing views is the last resort.

php artisan vendor:publish --tag=acun-ui-pro-views

The files are copied to resources/views/vendor/acun-ui-pro/components/data-table/. Keep only the ones you want to change and delete the rest; deleted files keep coming from the package. Files you published are not updated when the package is updated.

File Contents
toolbar.blade.php, search.blade.php Title, search, buttons, page size
actions/button.blade.php Header and toolbar button
actions/export-menu.blade.php, actions/export-panel.blade.php The "Export" menu
actions/utility-menu.blade.php The "⋮" menu
actions/row-actions.blade.php, actions/row-button.blade.php, actions/row-menu.blade.php Row actions
bulk-actions.blade.php Selection bar and bulk actions
column-picker.blade.php Column menu
header.blade.php, row.blade.php Table header and rows
row-details.blade.php Row details dialog
filters.blade.php, filter-control.blade.php, toolbar-filters.blade.php Filter drawer and toolbar filters
empty-state.blade.php, pagination.blade.php Empty state and pagination
print.blade.php Print page

Fixed header and columns

Four optional methods decide how the table behaves while scrolling. All are off by default; a table that uses none of them renders the same HTML. Working examples of each: DataTable examples.

Method What it does Example
scrollX(): bool Cells stay on one line; a wide table scrolls sideways in its own box return true;
scrollY(): ?string Gives the box a height; the table scrolls down inside it, the header stays on top return '400px';
fixedHeader(): bool While the page scrolls, the header row stays under the top bar return true;
fixedColumns(): array The leading columns and the actions column stay in place while scrolling sideways return ['start' => 1, 'end' => true];

scrollX() and scrollY() work with CSS only. fixedHeader() and fixedColumns() use the acunDataTableFixed component in the PRO JavaScript file (import '@acunsoft/acun-ui-pro' or @acunUiScripts). Without the JS, the table still works; only the header and columns do not stay fixed, and the browser console reports the missing JS (If the PRO JavaScript isn't loaded).

Scrolling

protected function scrollX(): bool
{
    return true;
}

protected function scrollY(): ?string
{
    return '400px';   // any CSS length: '28rem', 'min(70vh, 42rem)'…
}
  • scrollX() keeps every cell on one line. To let a long text column wrap, use wrap() on that column and give it a width: Column::make('Note', 'note')->wrap()->width('20rem').
  • scrollY() keeps the box at that height; the box scrolls vertically and the header row sticks to its top. Both together give a box that scrolls in both directions.
  • The older settings keep working: public bool $stickyHeader = true; in a table and acun-ui.datatable.sticky_header for every table are the same as scrollY('min(70vh, 42rem)'). If scrollY() returns a value, that value is used.

Fixed header

protected function fixedHeader(): bool
{
    return true;
}
  • When the header row reaches the top bar, a copy of the header appears there. At the end of the table the copy moves up with the table and disappears; nothing on the page shifts.
  • The sort arrows and the "select all" box in the copy work; the click is passed to the button in the real header. Keyboard and screen reader users use the real header; the copy is hidden from screen readers and is not in the tab order.
  • The copy is rebuilt after every Livewire update. Column widths and horizontal scrolling match the real table.
  • The panel's top bar (.panel-topbar, in the fixed menu layout) is found automatically. If your own layout has a fixed bar, give it the data-ac-sticky-top attribute. For a gap between the header and the bar: .ac-data-table { --ac-data-table-fixed-header-gap: 8px; }.
  • In full screen the header stays at the top edge of the screen. It works on phones too.
  • Used together with scrollY(), scrollY() wins: the header is already fixed inside the box, so the page copy is not drawn.

Fixed columns

protected function fixedColumns(): array
{
    return ['start' => 1, 'end' => true];
}
  • start: the number of data columns on the left (at most 10). The selection checkbox and the row details (+) column come first, so they stay fixed too. If start is larger than the number of visible columns, all columns are fixed. If the first column is hidden through the column menu, the next column is fixed.
  • end: keeps the actions column on the right. It does nothing when the table has no row actions (actions()).
  • The width of fixed columns is measured in the browser. It is recalculated when the screen size, the density (density()), the column menu or priority() columns hidden on narrow screens change.
  • While content passes underneath, a light shadow shows on the edge of the fixed columns.
  • On phones (below 640 px) only the left columns stay fixed; the actions column scrolls with the table so it does not take up space on a narrow screen. Keep start small on phones (usually 1).
  • Using it together with scrollX() is recommended.

Combining them

Combination Result
scrollX() + scrollY() A box that scrolls both ways, header on top
scrollX() + scrollY() + fixedColumns() The same box; fixed columns on the left and right, corner cells on top
fixedHeader() + fixedColumns() Fixed header on the page; the copy has the fixed columns in the same place
fixedHeader() + scrollY() scrollY() wins; the header is fixed inside the box
Row details, selection bar, density, full screen Work with all of them

Limitations and tips

  • Solid background: Fixed cells get a solid background to cover the content passing underneath. A normal row uses the box color, a hovered row the row color, and the header the header color. If you color a row with your own CSS (e.g. a selected row), also give the same color to the --ac-data-table-row-bg variable:

    .ac-data-table tbody tr:has(input:checked) {
        background-color: var(--color-brand-50);
        --ac-data-table-row-bg: var(--color-brand-50);
    }
    
  • Custom cell views: Do not give the content of a view() cell its own background color; leave it transparent. A color in the content breaks the row's hover color and dark mode.

  • Overflowing boxes: Do not put the table inside a box with overflow: hidden or overflow: auto; the fixed header would stick to that box. Use overflow: clip for rounded corners.

  • Several tables on one page: Only the header of the table whose body is on screen stays fixed. So that they do not share address bar values (?q=, ?page=), write a queryString() method in the second table or turn off acun-ui.datatable.url_state.

  • Speed: With the feature off, no JS runs. With it on, scrolling is handled at most once per frame; measuring only happens when the size changes and after a Livewire update. Even on very long pages (100+ rows) the cost is only the number of header cells.

  • Printing: The fixed header copy is hidden when printing.

Configuration

The defaults for every table are in the datatable section of config/acun-ui.php. To copy the file into your app:

php artisan vendor:publish --tag=acun-ui-config
'datatable' => [
    'per_page' => [10, 25, 50, 100],      // page size options
    'default_per_page' => 25,
    'search_debounce' => 400,             // search box delay (ms)
    'url_state' => true,                  // search, sorting, filters… go to the address bar
    'sticky_header' => false,             // older name: scrollY('min(70vh, 42rem)') on every table
    'pagination' => 'paginate',           // paginate | simple | cursor
    'select_all' => 'all',                // header checkbox: all (every match) | page (visible page)
    'selection' => ['max_keys' => 5000],  // most records that can be ticked one by one
    'actions' => ['enabled' => true],     // false: header, export and utility actions are off
    'exports' => [
        'enabled' => true,                // false: export, copy and print are off
        'queue_threshold' => 5000,        // above this many rows the file is built in the queue
        'chunk_size' => 1000,             // rows are read in chunks of this size
        'disk' => 'local',
        'directory' => 'exports',
        'queue' => null,                  // queue name (null: the default)
        'connection' => null,             // queue connection (null: the default)
        'expire_minutes' => 60,           // lifetime of the download link
        'poll_seconds' => 3,              // how often the table checks a queued job
        'cache_store' => null,            // cache that holds the job status; must be shared with the worker
        'csv_delimiter' => ';',
        'copy_max_rows' => 1000,
        'per_minute' => 30,               // prints, downloads and copies per user per minute
        'drivers' => [
            'csv' => CsvExportDriver::class,
            'json' => JsonExportDriver::class,
        ],
    ],
    'print' => ['layout' => 'acun-ui-pro::components.data-table.print', 'max_rows' => 2000, 'token_minutes' => 10],
    'density' => ['default' => 'normal'],  // compact | normal | comfortable
    'columns' => ['persist' => 'none'],    // none | session
    'routes' => ['middleware' => ['web', 'auth'], 'prefix' => 'acun-ui/data-table'],
],
  • If you published an older config/acun-ui.php, the package defaults are used for the missing keys.
  • The download and print routes are signed and give the file only to its owner. If you use a different guard, set it in routes.middleware, e.g. ['web', 'auth:admin'].
  • The print link is valid for 10 minutes (print.token_minutes).
  • Search settings are in the acun-ui.search section: the Search guide.
  • A short description of every key: the Configuration page.

Address bar

The search (q), sorting (sort, direction), filters (filters), page size (perPage), column choice (columns) and page number are written to the address bar. Reloading the page or sharing the link opens the same list.

  • Each value only appears when it differs from the default.
  • To turn it off, set url_state to false, or write your own queryString() method in the table.

Pruning old files

To delete expired export files regularly, schedule the command:

// routes/console.php
Schedule::command('acun-ui:prune-exports')->hourly();

Authorization and security

DataTable checks every request from the browser again on the server. The package does not impose its own permission system; you give the rules with authorizeTable(), query() and action authorization.

Access to the table

authorizeTable() decides who can open the table:

protected function authorizeTable(): void
{
    $this->authorize('viewAny', Order::class);
}

The method runs on the first load and on every later Livewire request, before property updates and action calls. A user whose access is revoked during the session can no longer run any action.

Which records are visible

query() decides which records the user sees. In apps where every user, or every customer account, sees only its own records, put that condition here:

protected function query(): Builder
{
    return Order::query()->where('user_id', auth()->id());
}
  • Row actions only run on a record inside query(); sending another record's key returns 404.
  • Bulk actions and exports always run inside query() and with the active filters. Keys from the browser are added on top of this query; records outside it are never touched.
  • The row details dialog also opens only records inside query().

How actions are checked

Hiding a button is not security on its own. That is why the package checks every call again on the server:

  • Every Livewire call (runTableAction, runRowAction, runBulkAction, exportTable, copyRows, preparePrint) finds the action again on the server. An unknown action returns 404; a hidden, unauthorized or disabled one returns 403.
  • authorizeTable() runs before them on every request.
  • The value an action returns is not sent to the browser; only redirects and file downloads go back.

Row authorization in bulk actions

authorize() runs on the server for the whole action, authorizeRow() for every targeted record:

BulkAction::delete()
    ->authorize(fn () => auth()->user()->can('delete-any', Order::class))
    ->authorizeRow(fn (Order $order) => auth()->user()->can('delete', $order)),
  • If even one selected record fails authorizeRow(), the action does not run at all and returns 403. Records are not silently skipped.
  • While all records are selected, the check runs on every matching record, in chunks.
  • If a row action has a per-record rule (e.g. can('delete')), give the same rule to the bulk action with authorizeRow(). Otherwise the bulk action could be used to get around the row rule.

Values from the browser

  • Sorting only happens on sortable() columns; a column name from the browser is never passed straight into the query. The direction can only be asc or desc.
  • The page size can only be one of the perPageOptions(). Any other value from the address bar (e.g. -1) falls back to the default.
  • Filter values are checked by type: a select filter takes only declared options, a range filter only from and to values. Undeclared filters are ignored.
  • At most 200 characters and 10 words of the search term are used.
  • The table's $title and $stickyHeader properties cannot be changed from the browser.
  • The number of records that can be ticked one by one is limited by selection.max_keys.

Export limits

  • One user can start at most 30 prints, downloads or copies per minute (exports.per_minute). Above the limit a warning notification appears.
  • Download and print links are signed, expire and give the file only to its owner.
  • exports.enabled = false turns off the export menu, bulk printing and exporting the selection together.

Note

The older name AcunDataTable still works but will be removed; use DataTable in new tables. A full example in the older style is in the package: examples/Livewire/OrdersTable.php.

Acun UIDesigned for people.