SIGNALS Documentation
API Reference

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.

Related: Component library Manifest reference Manifest validator Slots & pages Component: App\Livewire\Components\DataTable

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.

No countries found.
Country ISO Currency Active
No countries found.
Showing 4 columns 0 filters active Sorted by name asc 12 per page
Show: of 0
– of 0
selected
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.

PropTypeDefaultPurpose
columnsarray[]Column definitions (see the schema below). Required.
modelstring—Eloquent model FQCN. Required; a non-model class throws at mount.
searchablelist<string>[]Columns the global search box matches (wildcards escaped, input capped at 200 chars).
perPageint12Initial page size; the user can switch between 12 / 24 / 48.
emptyMessagestringNo records found.Empty-state copy.
defaultSortstring''Column key; a leading - means descending (-created_at).
defaultDirectionstringascExplicit direction when defaultSort carries no - prefix.
withlist<string>[]Eager-loaded relations — the N+1 guard for relation-path columns.
withCountslist<string>[]Relation counts exposed as {relation}_count columns.
scopesarray[]Query scopes applied on load: ['active' => true] calls scopeActive(). Validated against the model.
refreshEventslist<string>[]Livewire events that force a re-query.
entityType?stringnullOpts the table into saved live filters and the column registry for that entity.
actionsViewstring''Blade path rendered in each row's action cell.
bulkActionsViewstring''Blade path rendered in the selection bar.
toolbarViewstring''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.

KeyTypeExamplePurpose
keystring'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).
labelstring'Country'Header text. Defaults to the title-cased key.
width?int120Fixed column width in pixels.
sortablebooltrueEnables the header sort toggle.
filterablebooltrueEnables the per-column control in the filter row.
typestring'enum'Value type: string, enum, date, datetime, …
filter_typestring'select'Filter control: text or select.
filter_optionsarray['1' => 'Active']Value => label options for select filters.
filter_handlerclass-stringFavouritedByFilter::classColumn-owned filter applicator implementing ColumnFilterHandler.
cost_gatedbooltrueHides the column from users without costs.view.
listableboolfalseFilter-only virtual column: excluded from the column picker.
rendererstring'status-dot'Shared cell renderer: primary, status-dot, type-icon, date, datetime.
primarystring'name'Primary-cell main label path.
secondarystring'code3'Primary-cell secondary reference line.
imagebool|string'thumbnail_url'Leading thumbnail: true for a placeholder, or an attribute path.
image_signbooltrueTreat image as an S3 path signed centrally at render time.
hrefstring'catalogue-items.show'Named route for the primary-cell link (receives the row model).
href_paramsarray['tab' => 'items']Extra route params merged into the link.
status_colorsarray['1' => 'var(--green)']Value => CSS colour token for the status-dot renderer.
iconsarray['1' => 'user']Value => icon key for the type-icon renderer.
labelsarray['1' => 'Active']Display-label overrides for status-dot / type-icon values.
exportstring'account.name'Dot path used by CSV export instead of the raw key.
export_labelsarray['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.