DataTable — plugin SDK
One listing component for the whole framework. Plugins declare a table in signals.yaml,
register it on the registrar, and drop it into a page section or a UI slot — no plugin-shipped views.
Live demo
Everything below this heading is the real components.data-table component mounted against the
core Country reference model with the four-column config shown underneath. Search it, sort it,
filter it, change the page size — it behaves exactly as it does on every listing page in the product,
because it is the same component.
| Country | ISO | Currency | Active |
|---|---|---|---|
|
No countries found.
|
|||
array (
0 =>
array (
'key' => 'name',
'label' => 'Country',
'sortable' => true,
'filterable' => true,
'renderer' => 'primary',
'primary' => 'name',
'secondary' => 'code3',
),
1 =>
array (
'key' => 'code',
'label' => 'ISO',
'sortable' => true,
'filterable' => true,
),
2 =>
array (
'key' => 'currency_code',
'label' => 'Currency',
'filterable' => true,
),
3 =>
array (
'key' => 'is_active',
'label' => 'Active',
'renderer' => 'status-dot',
'type' => 'enum',
'status_colors' =>
array (
1 => 'var(--green)',
'' => 'var(--text-muted)',
),
'labels' =>
array (
1 => 'Active',
'' => 'Inactive',
),
),
)
Mount props
The component's public API. Plugin definitions map onto exactly this set —
PluginDataTableDefinition::toLivewireParams() emits these keys and nothing else.
| Prop | Type | Default | Purpose |
|---|---|---|---|
columns | array | [] | Column definitions (see the schema below). Required. |
model | string | — | Eloquent model FQCN. Required; a non-model class throws at mount. |
searchable | list<string> | [] | Columns the global search box matches (wildcards escaped, input capped at 200 chars). |
perPage | int | 12 | Initial page size; the user can switch between 12 / 24 / 48. |
emptyMessage | string | No records found. | Empty-state copy. |
defaultSort | string | '' | Column key; a leading - means descending (-created_at). |
defaultDirection | string | asc | Explicit direction when defaultSort carries no - prefix. |
with | list<string> | [] | Eager-loaded relations — the N+1 guard for relation-path columns. |
withCounts | list<string> | [] | Relation counts exposed as {relation}_count columns. |
scopes | array | [] | Query scopes applied on load: ['active' => true] calls scopeActive(). Validated against the model. |
refreshEvents | list<string> | [] | Livewire events that force a re-query. |
entityType | ?string | null | Opts the table into saved live filters and the column registry for that entity. |
actionsView | string | '' | Blade path rendered in each row's action cell. |
bulkActionsView | string | '' | Blade path rendered in the selection bar. |
toolbarView | string | '' | Blade path rendered beside the search box. |
The three *View props take Blade paths, so they are core-only: plugins ship no views and
therefore use page sections and slots for their own controls rather than these props.
Column config schema
Columns are plain arrays. App\Views\Column::fromConfig() is the contract: it accepts exactly the
keys below and throws InvalidArgumentException on anything else, so a typo fails at registration
rather than rendering a silently dead column. The fluent Column builder used by core registries
emits the same shape, and every core column definition round-trips through fromConfig() unchanged.
| Key | Type | Example | Purpose |
|---|---|---|---|
key | string | 'name' | Required. Column / attribute key; dot paths resolve relations (account.name). |
field | ?string | 'bookable' | Model attribute to read, filter and sort when it differs from the public key (a key kept stable for saved views). |
label | string | 'Country' | Header text. Defaults to the title-cased key. |
width | ?int | 120 | Fixed column width in pixels. |
sortable | bool | true | Enables the header sort toggle. |
filterable | bool | true | Enables the per-column control in the filter row. |
type | string | 'enum' | Value type: string, enum, date, datetime, … |
filter_type | string | 'select' | Filter control: text or select. |
filter_options | array | ['1' => 'Active'] | Value => label options for select filters. |
filter_handler | class-string | FavouritedByFilter::class | Column-owned filter applicator implementing ColumnFilterHandler. |
cost_gated | bool | true | Hides the column from users without costs.view. |
listable | bool | false | Filter-only virtual column: excluded from the column picker. |
renderer | string | 'status-dot' | Shared cell renderer: primary, status-dot, type-icon, date, datetime. |
primary | string | 'name' | Primary-cell main label path. |
secondary | string | 'code3' | Primary-cell secondary reference line. |
image | bool|string | 'thumbnail_url' | Leading thumbnail: true for a placeholder, or an attribute path. |
image_sign | bool | true | Treat image as an S3 path signed centrally at render time. |
href | string | 'catalogue-items.show' | Named route for the primary-cell link (receives the row model). |
href_params | array | ['tab' => 'items'] | Extra route params merged into the link. |
status_colors | array | ['1' => 'var(--green)'] | Value => CSS colour token for the status-dot renderer. |
icons | array | ['1' => 'user'] | Value => icon key for the type-icon renderer. |
labels | array | ['1' => 'Active'] | Display-label overrides for status-dot / type-icon values. |
export | string | 'account.name' | Dot path used by CSV export instead of the raw key. |
export_labels | array | ['1' => 'Yes'] | Raw value => label mapping applied during CSV export. |
Registrar usage
1. Declare it in signals.yaml
Wiring ⊆ declaration, as everywhere else in the SDK. Keys are global, so they carry the vendor prefix
(rule datatables.malformed) and must be unique (datatables.key_unique).
A declared permission is binding: the registered definition must gate on exactly that key.
datatables:
- key: acme.fieldops.jobs
permission: acme.fieldops.view
description: Open field jobs.
pages:
- key: jobs
title: Jobs
sections:
- type: stat-grid
- type: datatable
datatable: acme.fieldops.jobs
slots:
- slot: dashboard.widgets
component: components.data-table
priority: 80
permission: acme.fieldops.view
2. Register the definition
A permission is mandatory — plugin tables are never ungated, whether they target a
plugin-owned model or a core App\Models\* one. The model class, every column, and every scope
are validated as the plugin boots.
public function register(PluginRegistrar $registrar): void
{
$registrar->datatable('acme.fieldops.jobs', [
'model' => Job::class,
'permission' => 'acme.fieldops.view',
'searchable' => ['reference', 'title'],
'default_sort' => '-scheduled_at',
'per_page' => 24,
'empty_message' => 'No jobs scheduled.',
'with' => ['account'],
'with_counts' => ['tasks'],
'scopes' => ['open' => true],
'columns' => [
['key' => 'reference', 'label' => 'Job', 'sortable' => true,
'renderer' => 'primary', 'primary' => 'reference', 'secondary' => 'account.name'],
['key' => 'scheduled_at', 'label' => 'Scheduled', 'sortable' => true, 'renderer' => 'date', 'type' => 'date'],
['key' => 'status', 'label' => 'Status', 'filterable' => true, 'filter_type' => 'select',
'filter_options' => ['open' => 'Open', 'done' => 'Done'],
'renderer' => 'status-dot', 'status_colors' => ['open' => 'var(--amber)', 'done' => 'var(--green)']],
],
]);
// Same definition, rendered as a dashboard widget.
$registrar->datatableSlot('dashboard.widgets', 'acme.fieldops.jobs', priority: 80);
}
3. Render it
A datatable page section needs no data provider — the definition is the content.
The section is omitted entirely for a viewer without the definition's permission, so sibling sections keep
rendering; an unknown key is logged and skipped rather than breaking the page.
datatableSlot() registers a SlotComponentType::Livewire slot mounting the same
component with the same params, and re-checks the definition's permission at render time even when the slot
itself is gated on a narrower key.
Customisation & extension
Renderers
Cell rendering is config, not markup. primary gives the two-line identity cell (with optional
thumbnail and route link), status-dot maps values to a coloured dot plus label,
type-icon maps values to icons, and date / datetime format through the
shared Formatter in the viewer's timezone. Setting primary, secondary,
image or href implies the primary renderer; setting
status_colors implies status-dot.
Filters
filterable plus filter_type: text gives a contains-match box;
select plus filter_options gives a dropdown. When a filter needs a query of its own
(a relation existence check, a pivot lookup), point filter_handler at a class implementing
App\Views\Filters\ColumnFilterHandler and it owns the clause.
Scopes, eager loading, and cost
scopes narrows the base query and is validated against real scope* methods on the
model at registration. Any column using a relation path needs that relation in with —
the table paginates server-side, so a missing eager load is a per-row query. Mark price and margin columns
cost_gated so they disappear for users without costs.view.
Row and toolbar controls
The actionsView, bulkActionsView and toolbarView props take Blade paths
and are therefore core-only. Plugins add controls around the table instead: an html page section
above it, or a header-action / index-action slot on the host page.
Compliance
There is exactly one DataTable implementation in the framework and no forks of it. Every core listing page
— accounts, rentals, catalogue_items, invoices, stock, scanning, imports — mounts
components.data-table with the props documented above, and plugin tables resolve to the very same
mount call. That is why a plugin table inherits search, sorting, per-column filters, column toggles, density,
saved live filters, row selection and CSV export without asking for any of them: there is nothing else to
inherit from. Improvements to the component reach core pages and plugin pages in the same commit.