DataTable component: keyword search, toolbar filters, sortable columns, pagination, row-action menus, and bulk actions. A Lunar\Panel\Tables\TableExtension registered against a table’s id adds to any of these without touching the page that owns the table.
Finding a table’s id
A table id is a plain string, chosen by whichever controller built the table — there is no central registry, so a typo produces no error, just an extension that never appears. The reliable way to find one is to search the panel’s controllers forresolveTable(, the trait method every table-backed index page calls:
customers.index — is the table id. It happens to match the page’s zone/page-action id (the route name minus panel.), but that’s a naming convention, not a guarantee; always confirm against the controller.
Registering a table extension
ATableExtension bundles columns, filters, and actions; register it against a table id from a Section’s tableExtensions() hook:
TableExtension exposes four hooks, each defaulting to an empty array (or a no-op):
Outside a
Section, call Panel::extendTable($tableId, ExampleTableExtension::class) directly.
Multiple extensions (from different add-ons, or first-party and add-on together) can target the same table id; their columns, filters, and actions all merge into one ordered set.
Adding a column
A column extendsLunar\Panel\Tables\TableColumn:
key()— the column’s identifier. This doubles as the record attribute read for the cell’s raw value (see below), so it should be a real attribute on the model, unlesscomponent()renders the cell itself.header()— the column heading.type()— an optionalLunar\Panel\Tables\Support\ColumnTypefor a generic renderer:ColumnType::badge(),::date(?string $format),::boolean(),::currency(?string $code),::image(). Leavenullfor plain text.component()— an optional namespaced Vue component name (registered viawindow.LunarPanel.registerComponents()) for a fully custom cell. It receivesrowandvalueprops. Takes precedence overtype().position()— aLunar\Panel\Support\Position; defaults toPosition::last(). See Ordering with Position.permission()— a manifest permission handle; a staff member lacking it never receives the column.query(Builder $query)— a hook to modify the table’s Eloquent query before pagination, for a column whose value isn’t a plain attribute (awithCount(), anaddSelect()subquery, an eager-loaded relation).
How a column’s value is resolved
Lunar\Panel\Tables\Resolvers\TableExtensionResolver::applyColumnQueries() calls every visible column’s query() against the table’s builder before pagination runs. Once the records are loaded, the controller reads each add-on column’s raw cell value as $record->getAttribute($column->key()) — so key() must resolve to something the record actually exposes by that point: either a native column, or something the column’s own query() hook added (a withCount() alias, a subquery addSelect(), a relation loaded and flattened onto the model). A column with no query() override simply reads whatever native attribute matches its key().
Merging with first-party columns
TableExtensionResolver::mergeAndOrderColumns() assigns the page’s first-party columns ascending priorities (10, 20, 30, …) in their declared order, then merges in every visible add-on column by its own position(), resolving the combined set with the shared Lunar\Panel\Support\OrderResolver. This is what lets an add-on column anchor Position::before('company_name') or similar against a first-party column’s key, not just another add-on’s.
Rendering a cell
A column with notype() and no component() renders its raw value as plain text. type() hands the value to a built-in renderer (DataTableCell.vue) for a badge, formatted date, boolean icon, currency amount, or image thumbnail. component() overrides both for a fully custom cell — the same registerComponents() mechanism a slot or dashboard widget component uses, receiving row (the whole row payload) and value (this column’s raw value) as props.
Filters
A filter extendsLunar\Panel\Tables\TableFilter:
key()/query(Builder $query, mixed $value)are required;query()only runs when the filter has a submitted, non-empty value.label()defaults to the title-cased key if not overridden.options()—[submitted value => label]. The generic toolbar dropdown only renders a filter that has options (reserved for a fully custom filter viacomponent(), which is otherwise unused here).- The panel submits the selection as a nested
filter[{key}]query parameter and renders the dropdown next to the first-party filters, with an automatic “All” default.
Row actions
A row action extendsLunar\Panel\Tables\TableAction and appears in every row’s ellipsis menu:
url($record) builds the action’s per-row URL from the record; returning null omits the action from that row entirely — this is how the first-party Delete action hides itself on a protected record. First-party Edit/Delete are ordinary TableActions in the same ordered set, so Position::after('edit') anchors right after the built-in Edit entry. primary(): true renders the action as an inline button instead of collapsing into the ellipsis — reserved by convention for a page’s main verb; add-ons should normally stay in the ellipsis. confirmationMessage() adds a confirm dialog before dispatch, and permission() hides the action from unauthorized staff.
Bulk actions
A bulk action extendsLunar\Panel\Tables\TableBulkAction. Registering any bulk action against a table is what makes its row-selection checkboxes appear at all:
ids) to url() — unlike a row action, url() here takes no record, since it targets the whole selection at once. confirmationMessage(), permission(), and position() work exactly as they do on row actions.
Extending search
OverridesearchQuery(Builder $query, string $term) on the TableExtension (not on an individual column) to extend the page’s own keyword search:
where group (TableExtensionResolver::applySearchQueries()), so add orWhere clauses — a plain where would narrow every other search term instead of extending it.
See also
- Slots — injecting a Vue component into a page’s body instead of extending its table.
- Page Actions — the header-scoped sibling of a row action.
- Ordering with Position — anchoring columns and actions relative to first-party or add-on entries.