Table
A full-featured data table built with a PHP fluent builder API. Sortable columns, multi-type filters, typed row and bulk actions, column visibility persisted to localStorage, and a flexible paginator abstraction. All interactions (sort, filter, paginate) are handled via AJAX without full-page navigation.
Table
Build a table with a PHP fluent API — Table::make() — and render it with a single Blade tag. Pass column, filter, action, and bulk-action definitions in PHP; the component handles all markup, sort state, column visibility, and pagination automatically.
<?php use App\Contracts\UserRepository; use App\Tables\TableBasicBuilder; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Pajak\Ui\Table\Services\TableRequestParser; class UserController extends Controller { public function __construct(private readonly TableBasicBuilder $tableBasic) { } public function index(Request $request): View { return view('users.index', $this->tableBasic->build($request)); } } // TableBasicBuilder — app/Tables/TableBasicBuilder.php class TableBasicBuilder { public function __construct( private readonly UserRepository $users, private readonly TableRequestParser $parser, ) {} public function build(Request $request): array { $table = Table::make('users') ->dataUrl(route('users.table')) ->searchable() ->columns([ TextColumn::make('name')->label('Name')->sortable(), TextColumn::make('role')->label('Role')->sortable(), StatusColumn::make('status')->label('Status')->colors([ 'active' => 'green', 'inactive' => 'gray', 'pending' => 'yellow', ]), DateColumn::make('joined')->label('Joined')->sortable(), ]) ->actions([ LinkAction::make('edit')->label('Edit')->url(fn($row) => route('users.edit', $row))->inlineOnly(), ]); $tableRequest = $this->parser->parse($request); $rows = $this->users->all(); if ($tableRequest->search) { $query = mb_strtolower($tableRequest->search); $rows = $rows->filter(fn($user) => str_contains(mb_strtolower($user->name), $query) || str_contains(mb_strtolower($user->role), $query)); } if ($tableRequest->sort) { $rows = $tableRequest->sort->direction->value === 'desc' ? $rows->sortByDesc(fn($user) => $user->{$tableRequest->sort->column}) : $rows->sortBy(fn($user) => $user->{$tableRequest->sort->column}); } $basicTable = $table; $basicPaginator = ArrayPaginator::fromCollection($rows->values(), 10, $tableRequest->page); return ['basicTable' => $basicTable, 'basicPaginator' => $basicPaginator]; } }
<x-pajak-table::table :table="$basicTable" :paginator="$basicPaginator" />
Pagination
Pass an ArrayPaginator or EloquentPaginator as :paginator. Both implement the same TablePaginator contract. Use ->perPageOptions([5, 10, 25]) on the builder to expose a per-page selector — the current $tableRequest->perPage value is what the paginator receives.
| Alice Martin | Admin | 15.01.2022 |
| Bob Chen | Editor | 08.03.2022 |
| Carol Novak | Viewer | 22.05.2022 |
| David Park | Editor | 30.07.2022 |
| Eva Müller | Viewer | 11.09.2022 |
<?php use App\Contracts\UserRepository; use App\Tables\TablePaginationBuilder; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Pajak\Ui\Table\Services\TableRequestParser; class UserController extends Controller { public function __construct(private readonly TablePaginationBuilder $tablePagination) { } public function index(Request $request): View { return view('users.index', $this->tablePagination->build($request)); } } // TablePaginationBuilder — app/Tables/TablePaginationBuilder.php class TablePaginationBuilder { public function __construct( private readonly UserRepository $users, private readonly TableRequestParser $parser, ) {} public function build(Request $request): array { $table = Table::make('users') ->dataUrl(route('users.table')) ->searchable() ->columns([ TextColumn::make('name')->label('Name')->sortable(), TextColumn::make('role')->label('Role')->sortable(), DateColumn::make('joined')->label('Joined')->sortable(), ]) ->perPageOptions([5, 10, 25]); $tableRequest = $this->parser->parse($request); $perPage = $request->has('per_page') ? $tableRequest->perPage : 5; $rows = $this->users->all(); if ($tableRequest->search) { $query = mb_strtolower($tableRequest->search); $rows = $rows->filter(fn($user) => str_contains(mb_strtolower($user->name), $query) || str_contains(mb_strtolower($user->role), $query)); } if ($tableRequest->sort) { $rows = $tableRequest->sort->direction->value === 'desc' ? $rows->sortByDesc(fn($user) => $user->{$tableRequest->sort->column}) : $rows->sortBy(fn($user) => $user->{$tableRequest->sort->column}); } $paginationTable = $table; $paginationPaginator = ArrayPaginator::fromCollection($rows->values(), $perPage, $tableRequest->page); return ['paginationTable' => $paginationTable, 'paginationPaginator' => $paginationPaginator]; } }
<x-pajak-table::table :table="$paginationTable" :paginator="$paginationPaginator" />
Column types
Every built-in column variant in one table: AvatarColumn with initials, TwoLineColumn with primary/secondary text, StatusColumn with a color map, BadgeColumn for type chips, AmountColumn for sign-aware money, and DateColumn with configurable format. Columns declared ->hidden() are off by default but user-toggleable.
| Avatar | Two-line | Status | Badge | ||
|---|---|---|---|---|---|
|
A
Alice Martin
Product Designer
|
Alice Martin
Product Designer
|
paid | design | $ 4,200.00 | 15.01.2024 |
|
B
Bob Chen
Backend Engineer
|
Bob Chen
Backend Engineer
|
pending | backend | $ 5,800.50 | 08.03.2024 |
|
C
Carol Novak
QA Engineer
|
Carol Novak
QA Engineer
|
cancelled | qa | $− 120.00 | 22.05.2024 |
|
D
David Park
Tech Lead
|
David Park
Tech Lead
|
paid | backend | $ 9,100.00 | 30.07.2024 |
|
E
Eva Müller
Frontend Engineer
|
Eva Müller
Frontend Engineer
|
pending | frontend | $ 3,350.75 | 11.09.2024 |
<?php use App\Contracts\MemberRepository; use App\Tables\TableCellTypesBuilder; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Pajak\Ui\Table\Services\TableRequestParser; class MemberController extends Controller { public function __construct(private readonly TableCellTypesBuilder $tableCellTypes) { } public function index(Request $request): View { return view('members.index', $this->tableCellTypes->build($request)); } } // TableCellTypesBuilder — app/Tables/TableCellTypesBuilder.php class TableCellTypesBuilder { public function __construct( private readonly MemberRepository $members, private readonly TableRequestParser $parser, ) {} public function build(Request $request): array { $table = Table::make('members') ->dataUrl(route('members.table')) ->columns([ AvatarColumn::make('name')->label('Avatar')->image('avatar')->subtitle('role'), TwoLineColumn::make('name')->label('Two-line')->secondary('role'), StatusColumn::make('status')->label('Status')->colors([ 'paid' => 'green', 'pending' => 'yellow', 'cancelled' => 'red', ]), BadgeColumn::make('type')->label('Badge')->colors([ 'design' => 'blue', 'backend' => 'gray', 'qa' => 'yellow', 'frontend' => 'green', ]), AmountColumn::make('amount')->label('Amount')->sortable(), DateColumn::make('joined')->label('Date')->sortable(), ]); $tableRequest = $this->parser->parse($request); $cellTypesTable = $table; $cellTypesPaginator = ArrayPaginator::fromCollection($this->members->all(), 10, $tableRequest->page); return ['cellTypesTable' => $cellTypesTable, 'cellTypesPaginator' => $cellTypesPaginator]; } }
<x-pajak-table::table :table="$cellTypesTable" :paginator="$cellTypesPaginator" />
Filters
Four filter types are available: TextFilter (contains / equals operators), SelectFilter (multi-select checkboxes), DateFilter (from/to date pickers), and NumberFilter (min/max). Active filters render as removable chips in the toolbar. Apply them server-side with TableFilters::apply($items, $tableRequest->filters).
| Status | ||||
|---|---|---|---|---|
| Alice Martin | Engineering | active | $ 72,000 | 01.03.2023 |
| Bob Chen | Design | active | $ 64,000 | 15.07.2022 |
| Carol Novak | Marketing | inactive | $ 58,000 | 20.11.2021 |
| David Park | Engineering | active | $ 95,000 | 08.04.2020 |
| Eva Müller | HR | pending | $ 51,000 | 10.01.2024 |
| Frank Osei | Engineering | active | $ 88,000 | 03.09.2019 |
Name
Department
Min salary
Hired after
<?php use App\Contracts\EmployeeRepository; use App\Tables\TableFiltersChipsBuilder; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Pajak\Ui\Table\Services\TableFilters; use Pajak\Ui\Table\Services\TableRequestParser; class EmployeeController extends Controller { public function __construct(private readonly TableFiltersChipsBuilder $tableFiltersChips) { } public function index(Request $request): View { return view('employees.index', $this->tableFiltersChips->build($request)); } } // TableFiltersChipsBuilder — app/Tables/TableFiltersChipsBuilder.php class TableFiltersChipsBuilder { public function __construct( private readonly EmployeeRepository $employees, private readonly TableRequestParser $parser, private readonly TableFilters $tableFilters, ) {} public function build(Request $request): array { $filters = [ TextFilter::make('name')->label('Name'), SelectFilter::make('department')->label('Department')->options([ 'Engineering' => 'Engineering', 'Design' => 'Design', 'Marketing' => 'Marketing', 'HR' => 'HR', ]), NumberFilter::make('salary')->label('Min salary'), DateFilter::make('hired')->label('Hired after'), ]; $table = Table::make('employees') ->dataUrl(route('employees.table')) ->searchable() ->columns([ TextColumn::make('name')->label('Name')->sortable(), TextColumn::make('department')->label('Department')->sortable(), StatusColumn::make('status')->label('Status')->colors([ 'active' => 'green', 'inactive' => 'gray', 'pending' => 'yellow', ]), AmountColumn::make('salary')->label('Salary')->sortable(), DateColumn::make('hired')->label('Hired')->sortable(), ]) ->filters($filters); $tableRequest = $this->parser->parse($request); $rows = $this->tableFilters->apply($this->employees->all(), $tableRequest->filters); if ($tableRequest->search) { $query = mb_strtolower($tableRequest->search); $rows = $rows->filter(fn($employee) => str_contains(mb_strtolower($employee->name), $query) || str_contains(mb_strtolower($employee->department), $query)); } if ($tableRequest->sort) { $rows = $tableRequest->sort->direction->value === 'desc' ? $rows->sortByDesc(fn($employee) => $employee->{$tableRequest->sort->column}) : $rows->sortBy(fn($employee) => $employee->{$tableRequest->sort->column}); } $filtersChipsTable = $table; $filtersChipsPaginator = ArrayPaginator::fromCollection($rows->values(), 10, $tableRequest->page); return ['filtersChipsTable' => $filtersChipsTable, 'filtersChipsPaginator' => $filtersChipsPaginator]; } }
<x-pajak-table::table :table="$filtersChipsTable" :paginator="$filtersChipsPaginator" />
Row actions
Four action types are available: LinkAction for navigation, FormAction for POST/DELETE submissions, ConfirmAction for destructive operations that need a dialog, and ModalAction to open an existing modal by ID. Use ->inlineOnly() or ->overflowOnly() to control placement, and ->visibleIf(fn($row) => ...) for conditional visibility. Mark an action ->danger() to render it in red.
<?php use App\Contracts\InvoiceRepository; use App\Tables\TableActionsBuilder; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Pajak\Ui\Table\Services\TableFilters; use Pajak\Ui\Table\Services\TableRequestParser; class InvoiceController extends Controller { public function __construct(private readonly TableActionsBuilder $tableActions) { } public function index(Request $request): View { return view('invoices.index', $this->tableActions->build($request)); } } // TableActionsBuilder — app/Tables/TableActionsBuilder.php class TableActionsBuilder { public function __construct( private readonly InvoiceRepository $invoices, private readonly TableRequestParser $parser, private readonly TableFilters $tableFilters, ) {} public function build(Request $request): array { $filters = [ SelectFilter::make('status')->label('Status')->options([ 'paid' => 'Paid', 'pending' => 'Pending', 'draft' => 'Draft', ]), ]; $table = Table::make('invoices') ->dataUrl(route('invoices.table')) ->columns([ TextColumn::make('number')->label('Invoice')->sortable(), TextColumn::make('client')->label('Client'), StatusColumn::make('status')->label('Status')->sortable()->colors([ 'paid' => 'green', 'pending' => 'yellow', 'draft' => 'gray', ]), AmountColumn::make('amount')->label('Amount')->sortable(), DateColumn::make('due')->label('Due date')->sortable(), ]) ->filters($filters) ->actions([ LinkAction::make('view')->label('View')->url(fn($row) => route('invoices.show', $row))->inlineOnly(), LinkAction::make('edit')->label('Edit')->url(fn($row) => route('invoices.edit', $row)), ConfirmAction::make('delete') ->label('Delete') ->url(fn($row) => route('invoices.destroy', $row)) ->method(Method::Delete) ->danger() ->confirmTitle('Delete invoice?') ->confirmMessage('This will permanently remove the invoice and cannot be undone.') ->confirmButton('Delete'), ]); $tableRequest = $this->parser->parse($request); $rows = $this->tableFilters->apply($this->invoices->all(), $tableRequest->filters); if ($tableRequest->sort) { $rows = $tableRequest->sort->direction->value === 'desc' ? $rows->sortByDesc(fn($invoice) => $invoice->{$tableRequest->sort->column}) : $rows->sortBy(fn($invoice) => $invoice->{$tableRequest->sort->column}); } $actionsTable = $table; $actionsPaginator = ArrayPaginator::fromCollection($rows->values(), 10, $tableRequest->page); return ['actionsTable' => $actionsTable, 'actionsPaginator' => $actionsPaginator]; } }
<x-pajak-table::table :table="$actionsTable" :paginator="$actionsPaginator" />
Modal action — opening a modal from a row action
Use ModalAction to open an existing <x-pajak::modal> when a row action is clicked. Pass a ->modalId() closure that returns a per-row ID, then render a matching modal for each row. The table wires the trigger automatically via data-pajak-modal-open.
| Client | |||||
|---|---|---|---|---|---|
| INV-1001 | Acme Corp | paid | 1,250.00 € | 01.02.2024 |
|
| INV-1002 | Globex Ltd | pending | 875.50 € | 15.03.2024 |
|
| INV-1003 | Initech | draft | 2,100.00 € | 30.04.2024 |
|
| INV-1004 | Umbrella Inc | paid | 340.00 € | 10.05.2024 |
|
| INV-1005 | Hooli | pending | 5,600.00 € | 22.06.2024 |
|
<?php use App\Contracts\InvoiceRepository; use App\Tables\TableModalActionBuilder; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Pajak\Ui\Table\Services\TableRequestParser; class InvoiceController extends Controller { public function __construct(private readonly TableModalActionBuilder $tableModalAction) { } public function index(Request $request): View { return view('invoices.index', $this->tableModalAction->build($request)); } } // TableModalActionBuilder — app/Tables/TableModalActionBuilder.php class TableModalActionBuilder { public function __construct( private readonly InvoiceRepository $invoices, private readonly TableRequestParser $parser, ) {} public function build(Request $request): array { $table = Table::make('invoices') ->dataUrl(route('invoices.table')) ->columns([ TextColumn::make('number')->label('Invoice')->sortable(), TextColumn::make('client')->label('Client'), StatusColumn::make('status')->label('Status')->sortable()->colors([ 'paid' => 'green', 'pending' => 'yellow', 'draft' => 'gray', ]), AmountColumn::make('amount')->label('Amount')->sortable(), DateColumn::make('due')->label('Due date')->sortable(), ]) ->actions([ ModalAction::make('send') ->label('Send') ->modalId(fn($invoice) => sprintf('send-invoice-%d', $invoice->id)) ->inlineOnly(), ConfirmAction::make('delete') ->label('Delete') ->url(fn($invoice) => route('invoices.destroy', $invoice)) ->method(Method::Delete) ->danger() ->confirmTitle('Delete invoice?') ->confirmMessage('This will permanently remove the invoice and cannot be undone.') ->confirmButton('Delete'), ]); $tableRequest = $this->parser->parse($request); $invoices = $this->invoices->all(); if ($tableRequest->sort) { $invoices = $tableRequest->sort->direction->value === 'desc' ? $invoices->sortByDesc(fn($invoice) => $invoice->{$tableRequest->sort->column}) : $invoices->sortBy(fn($invoice) => $invoice->{$tableRequest->sort->column}); } $modalActionTable = $table; $modalActionPaginator = ArrayPaginator::fromCollection($invoices->values(), 10, $tableRequest->page); $modalActionInvoices = $invoices->values(); return ['modalActionTable' => $modalActionTable, 'modalActionPaginator' => $modalActionPaginator, 'modalActionInvoices' => $modalActionInvoices]; } }
@use('Pajak\Ui\Common\Enums\Variant')
@foreach($modalActionInvoices as $invoice)
<x-pajak::modal :id="'send-invoice-' . $invoice->id" :title="'Send ' . $invoice->number">
<x-slot:description>
Send this invoice to <strong>{{ $invoice->client }}</strong>. They will receive an email with a payment link.
</x-slot:description>
<x-slot:footer>
<x-pajak::button :variant="Variant::Ghost" data-pajak-modal-close>Cancel</x-pajak::button>
<x-pajak::button>Send invoice</x-pajak::button>
</x-slot:footer>
</x-pajak::modal>
@endforeach
<x-pajak-table::table :table="$modalActionTable" :paginator="$modalActionPaginator" />
Bulk actions
Add ->bulkActions([...]) to enable row checkboxes and a floating bulk-action bar. Any action type works as a bulk action. The bar appears once at least one row is selected and reports the selection count. Per-row actions can be combined with bulk actions on the same table.
| Name | |||||
|---|---|---|---|---|---|
|
A
Alice Martin
|
Admin | active | 15.01.2022 | ||
|
B
Bob Chen
|
Editor | active | 08.03.2022 | ||
|
C
Carol Novak
|
Viewer | inactive | 22.05.2022 | ||
|
D
David Park
|
Editor | active | 30.07.2022 | ||
|
E
Eva Müller
|
Viewer | pending | 11.09.2022 | ||
|
F
Frank Osei
|
Admin | active | 03.01.2023 | ||
|
G
Grace Kim
|
Editor | active | 19.03.2023 | ||
|
H
Hana Dvořák
|
Viewer | inactive | 07.05.2023 | ||
|
I
Ivan Torres
|
Editor | active | 24.07.2023 | ||
|
J
Jana Novotná
|
Viewer | active | 15.09.2023 |
Name
Role
Status
<?php use App\Contracts\UserRepository; use App\Tables\TableBulkActionsBuilder; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Pajak\Ui\Table\Services\TableFilters; use Pajak\Ui\Table\Services\TableRequestParser; class UserController extends Controller { public function __construct(private readonly TableBulkActionsBuilder $tableBulkActions) { } public function index(Request $request): View { return view('users.index', $this->tableBulkActions->build($request)); } } // TableBulkActionsBuilder — app/Tables/TableBulkActionsBuilder.php class TableBulkActionsBuilder { public function __construct( private readonly UserRepository $users, private readonly TableRequestParser $parser, private readonly TableFilters $tableFilters, ) {} public function build(Request $request): array { $filters = [ TextFilter::make('name')->label('Name'), SelectFilter::make('role')->label('Role')->options([ 'Admin' => 'Admin', 'Editor' => 'Editor', 'Viewer' => 'Viewer', ]), SelectFilter::make('status')->label('Status')->options([ 'active' => 'Active', 'inactive' => 'Inactive', 'pending' => 'Pending', ]), ]; $table = Table::make('users') ->dataUrl(route('users.table')) ->searchable() ->columns([ AvatarColumn::make('name')->label('Name')->image('avatar'), TextColumn::make('role')->label('Role')->sortable(), StatusColumn::make('status')->label('Status')->sortable()->colors([ 'active' => 'green', 'inactive' => 'gray', 'pending' => 'yellow', ]), DateColumn::make('joined')->label('Joined')->sortable(), ]) ->filters($filters) ->actions([ LinkAction::make('edit')->label('Edit')->url(fn($row) => route('users.edit', $row))->inlineOnly(), ConfirmAction::make('deactivate') ->label('Deactivate') ->url(fn($row) => route('users.deactivate', $row)) ->danger() ->confirmTitle('Deactivate user?') ->confirmMessage('The user will lose access immediately.') ->confirmButton('Deactivate'), ]) ->bulkActions([ FormAction::make('export') ->label('Export selected') ->url(fn($row) => route('users.export')) ->method(Method::Post), ConfirmAction::make('bulk-delete') ->label('Delete selected') ->url(fn($row) => route('users.bulk-destroy')) ->method(Method::Delete) ->danger() ->confirmTitle('Delete users?') ->confirmMessage('All selected users will be permanently removed.') ->confirmButton('Delete all'), ]); $tableRequest = $this->parser->parse($request); $rows = $this->tableFilters->apply($this->users->all(), $tableRequest->filters); if ($tableRequest->search) { $query = mb_strtolower($tableRequest->search); $rows = $rows->filter(fn($user) => str_contains(mb_strtolower($user->name), $query)); } if ($tableRequest->sort) { $rows = $tableRequest->sort->direction->value === 'desc' ? $rows->sortByDesc(fn($user) => $user->{$tableRequest->sort->column}) : $rows->sortBy(fn($user) => $user->{$tableRequest->sort->column}); } $bulkTable = $table; $bulkPaginator = ArrayPaginator::fromCollection($rows->values(), 10, $tableRequest->page); return ['bulkTable' => $bulkTable, 'bulkPaginator' => $bulkPaginator]; } }
<x-pajak-table::table :table="$bulkTable" :paginator="$bulkPaginator" />
Empty state
When the paginator returns zero items the table automatically renders a built-in empty state with a configurable title and subtitle via the translation file pajak::table. Pass an empty collection to ArrayPaginator to trigger it.
| Status | ||
|---|---|---|
|
Nothing here yet No items have been added. |
||
Number
<?php use App\Tables\TableEmptyBuilder; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; use Pajak\Ui\Table\Services\TableFilters; use Pajak\Ui\Table\Services\TableRequestParser; class InvoiceController extends Controller { public function __construct(private readonly TableEmptyBuilder $tableEmpty) { } public function index(Request $request): View { return view('invoices.index', $this->tableEmpty->build($request)); } } // TableEmptyBuilder — app/Tables/TableEmptyBuilder.php class TableEmptyBuilder { public function __construct( private readonly TableRequestParser $parser, private readonly TableFilters $tableFilters, ) {} public function build(Request $request): array { $table = Table::make('invoices') ->dataUrl(route('invoices.table')) ->columns([ TextColumn::make('number')->label('Number')->sortable(), TextColumn::make('client')->label('Client')->sortable(), StatusColumn::make('status')->label('Status')->colors([ 'paid' => 'green', 'pending' => 'yellow', 'draft' => 'gray', ]), ]) ->filters([ TextFilter::make('number')->label('Number'), ]); $tableRequest = $this->parser->parse($request); $rows = $this->tableFilters->apply(new Collection([]), $tableRequest->filters); $emptyTable = $table; $emptyPaginator = ArrayPaginator::fromCollection($rows, 10, $tableRequest->page); return ['emptyTable' => $emptyTable, 'emptyPaginator' => $emptyPaginator]; } }
<x-pajak-table::table :table="$emptyTable" :paginator="$emptyPaginator" />