TablePayload in packages/core/src/types.ts is the client contract. The PHP serializer has matching integration tests. Breaking schema changes must increment version and be documented.
Version 3 adds PageSize = number | 'all' for state.perPage and defaults.perPage, and adds 'none' to pagination.mode. Upgrade the Laravel package and both frontend packages together. The controller rejects version 1/2 props rather than misinterpreting these values. Custom drivers must handle 'all' without converting it to a number and disable page navigation/scroll loading for 'none'.
Optional config.paginationPageOptions contains numeric choices and an explicitly enabled 'all'; an empty array hides the selector. config.paginated: false hides page navigation and the size selector regardless of state. config.extremePaginationLinks enables First/Last buttons for ordinary page navigation; pagination.firstPage and lastPage are numbers for length-aware pagination and null otherwise. Page-size changes reset Inertia data as other query changes do. Numeric sizes remain bounded by config.maxPerPage; 'all' requires server opt-in.
A table prop contains:
| Key | Contents |
|---|---|
version, name |
Protocol version and stable table ID |
data |
{id, cells, url, classes, selectable, group, actions} rows |
columns, filters, groups |
Renderer definitions |
state, defaults |
Validated current state and reset state |
toolbarActions, emptyStateActions, bulkActions |
Table-level action descriptors (row actions are on each row) |
views |
Presets and scoped private views |
summaries, pageSummaries |
Full filtered and current-page aggregates by column and label; group summaries are attached to the first row of each loaded group |
pagination |
Native scroll metadata, mode and optional total |
config |
URL state key, optional mutation endpoint and presentation settings (infinite, pagination, layouts, …) |
Each cell is {value, formatted, url, tooltip, color, editable} with optional html containing server-sanitized rich content. Code cells may also contain codeLanguage and code: [{text, classes: string[]}]. Token classes are allowlisted hljs-* names; tokens contain no markup. Raw values, formatted strings, and code token text must be escaped. Only data is merged by Inertia; schema, state and metadata are replaced. Inertia metadata has mergeProps: ['projects.data'], matchPropsOn: ['projects.data.id'], and scrollProps.projects. Prepend intent and reset headers are handled by the Inertia Laravel adapter.
Text formatting metadata supports format: "number" | "money" | "date" | "datetime" | "time" | "relative", optional locale, timezone, decimals, currency and divideBy. Null currency precision uses the currency’s standard fraction digits. Custom PHP/Carbon date formats are evaluated server-side into formatted; clients do not parse PHP tokens. formatCell() handles scalar/list display, locale overrides, UTC SQL timestamps and time-zone conversion, while date-only values retain their original calendar date. Date tooltips use the ordinary tooltip field. These are additive protocol 3 metadata options.
Text presentation metadata includes description, descriptionPosition, prefix, suffix, icon, iconPosition, iconColor, textSize, fontWeight, fontFamily, lineClamp, limit, limitEnd, words, and wordsEnd. Per-record callbacks emit optional string/null cell.description, cell.prefix, cell.suffix, cell.icon, or cell.iconColor, with the corresponding static metadata null. Render all of these as escaped text. formatCell() applies character/word limits before affixes; formatCodeTokens() preserves the same text and code classes. Descriptions do not form part of the main value. Limits leave the raw value and full tooltip available.
For rich cells, html (including each items[index].html) already includes server-side character/word limits after sanitization. Limits count decoded text, retain balanced markup and append endings as text, never raw HTML. Drivers must not truncate this HTML string again. value and formatted retain their complete sources; prefixes/suffixes remain separate escaped presentation values. A limit change requires a new table prop to recompute rich HTML. No additional protocol field is needed.
Text list metadata adds separator: string | null, bulleted: boolean, listLimit: number | null, and expandableList: boolean, alongside list (line breaks) and badge. Full values stay in the prop. getCellList() formats visible items and returns their total/remaining counts and expansion availability. Expansion is a renderer concern and never enters table state or requests. Per-item badges use the cell’s existing color; all list item strings remain escaped.
Rich/code lists add optional cell.items: {html?: string, code?: CodeToken[] | null}[], aligned with the effective array (formatted when it is an array, otherwise value) or the split/trimmed nonempty fields of an unformatted delimited string. This is presentation data only; raw values remain in value, and list limits do not remove entries. Scalar cells retain their existing html/code fields. HTML is sanitized independently per item; null/missing code falls back to escaped text. getCellContentList() returns visible {text, html?, code?} items with the same list metadata, applying code limits and affixes while retaining token classes. HTML affixes must be rendered separately as escaped text. Empty/null unformatted list values use the normal placeholder. This is an additive protocol 3 field.
Clipboard metadata includes copyable, optional copyableState, copyMessage, and copyMessageDuration (milliseconds). Callback results use optional cell.copyableState and cell.copyMessage fields, taking precedence over static metadata. Null falls back to the raw copy value or localized success message; an empty string is an explicit override. Copying writes plain text only. Clipboard pending/result state and timers remain local to the renderer and do not enter Inertia requests or saved views.
URL state
<table>_state is JSON. Cursor and page parameters are <table>_cursor and <table>_page. Drivers must clear their own cursor/page when state changes, retain unrelated parameters, and send Inertia’s reset: [prop]. Invalid state results in normal Laravel validation responses. Unknown allowlisted resources are rejected; they are not interpolated into SQL.
Optional filter options
toProps() adds <name>Options using Inertia::optional(). A partial GET with only: [config.optionsProp] accepts <name>_options[filter], [search] and [selected][], and returns {filter, search, options: {id: label}, order: [ids], selected: {id: label}}. order (and a select filter’s meta.optionOrder) keeps the server’s option order, which object keys cannot for numeric IDs.
toProps() also adds <name>Group for group pagination (config.groupPagination). A partial GET with only: ['<name>Group'] and <name>_group={"path": [...raw values], "page": n} (JSON) returns {key, path, depth, pagination: {page, perPage, total, lastPage}} plus either groups (the next level’s entries, shaped like groupRows: {name, key, label, count, summaries, path, parent, depth}) or rows (records, at the deepest level). The prebuilt client merges the page into the table prop with router.replaceProp: groupRows gains the entries, groupPages[key] the pagination and data the records, replacing what an earlier page of that group loaded.
config.search is {fuzzy, typos} (Table::fuzzySearch()). Clients that search or highlight locally should match the same way: words in any order when fuzzy, one typo in words of five or more letters without digits when typos (the core exports searchTerms(), fuzzyIncludes() and termMatches()).
Grouped state: group and groupDirection hold the outermost level and subgroups ([{name, direction}], at most three) the nested ones. Rows carry group (outermost) and groups (every level, with path-unique keys); group summaries arrive on the first row of each group in groups[level].summaries. Drivers use an asynchronous visit with preserved URL/state/scroll. Table data is omitted from this response. Limits: search 200 characters, 100 selected IDs, 100 search results at most.
config.columnLayout is a tree of {kind: 'column', name} and {kind: 'layout', type, meta, children} nodes. Column leaves refer to the flat column definitions. Server-hidden columns and their empty containers are removed before serialization.
POST operations
Operations POST to the page that renders the table (its current URL without the table’s page/cursor parameter) with header X-Spindle-Table: <name>. RouteTableMutations hands the POST to the page’s GET route; the matching table claims and executes it (after CSRF verification) while building its prop, and the page responds with fresh props. Clients send preserveUrl so the address bar is unchanged. If config.endpoint is set, operations POST there instead (without X-Spindle-Table) and it answers with a 303 redirect. The current state is included under the table’s state key.
In-place row refresh
A request carrying X-Spindle-Rows: <table>:<id>,<id> (at most 100 IDs) receives a patch instead of a page of rows: the table prop is an Inertia merge prop {data, summaries, runs, patch: {rows, missing}} with mergeProps: ['<prop>.data'] and matchPropsOn: ['<prop>.data.id'], and no scrollProps. Rows are read through the base query (tenant scopes apply, filters do not). Clients request only: ['<prop>', '<prop>.data'] without reset, so Inertia replaces matching rows in place and deep-merges the other keys; IDs in patch.missing are removed with router.replaceProp(). The prebuilt controller uses this for inline edits (with an Inertia optimistic update), record actions, cell actions and bulk actions on explicit selections. Actions with meta.refresh: 'table' (refreshesTable(), e.g. replicate) and toolbar actions reload the table.
operation |
Fields |
|---|---|
action |
action, optional record, data |
bulk |
action, selection: {all, ids, except}, data |
edit |
record, column, value |
reorder |
ids in requested order |
view.create |
name, state, optional favorite |
view.update |
id, name, state, optional favorite |
view.delete |
id |
view.favorite |
id, favorite |
view.default |
id, or null to clear |
view.reorder |
ids |
Record/view IDs are strings in the client contract, including numeric model keys. A bulk action re-resolves IDs through the scoped, filtered query. Mutation success returns a 303 redirect; validation uses Inertia’s regular error bag.
Action descriptors
Actions contain name, label, handler, url, confirmation, readOnly, requiresInput, data and meta. They contain no fields, form schema, validation rules or callbacks. data is an explicitly selected JSON object. Frontend handlers decide how to collect inputs or display details. The server validates posted data independently.
Future drivers
An adapter needs reactive subscription, controller.update() on prop replacement, teardown, a transport bridge, native Inertia scroll integration and rendering of descriptors. Preserve native Inertia headers and cookies; do not introduce an unrelated JSON API transport. Use TablePayload and the existing frontend fixtures for shared behavior tests. Client checks are UI conveniences; Laravel remains authoritative.
Version 2 replaces the draft action fields descriptors with frontend handlers, explicit data, and requiresInput. Rebuild the Laravel and frontend packages together; the core rejects mismatched payload versions. The package no longer contains an action form/schema builder. Table-column layouts and custom column/filter renderers are unchanged.
When config.groupsOnly is true, data contains group report rows. Each has a stable opaque ID, empty cells, selectable: false, no row actions, and group: {key, label, count, summaries}. Pagination counts groups. pageSummaries covers the records belonging to the current page of groups, while summaries covers the full filtered query. Group keys are opaque identifiers; use label for display. See aggregate reports.
Import actions can expose meta.import: {userMapping, delimiter, columns: [{name, label, required}]}. A mapping-enabled upload submits data.file and optional data.mapping, mapping destination attributes to uploaded header names. Destinations remain server-allowlisted. Row validation uses error keys rows.<csvRecord>.<attribute>. No validation rules, model classes, or UI form descriptors are included.
config.filterControls contains {layout, defer, columns, width, maxHeight}. config.columnManager contains {enabled, toggleable, layout, defer, reorderable, columns, width, maxHeight, resetPosition}. Width/height are bounded pixel values; column counts are 1–6. These are presentation settings. Draft preferences stay local until applied using the existing state keys and Inertia visits; there is no additional HTTP protocol or persisted draft state.
config.searchable controls the global search input and reflects table-level search callbacks, Scout, and explicit searchable(false). Custom drivers omitting this optional flag may fall back to whether any column is searchable. Individual column-search capabilities remain in the column definitions.
Background operations
Tables configured with background() include runs and config.runsProp. toProps() adds the native optional <name>Runs prop. Each run contains {id, kind, label, status, total, processed, succeeded, failed, skipped, message, unread, createdAt, finishedAt}. It contains no input, definition class, owner identity, or connection information. Partial GETs for this prop omit table data. Queued bulk action descriptors set meta.background: true; their normal bulk submission returns a 303 after dispatch.
POST operations run.cancel and run.read accept an owned run UUID in id. Ownership includes table and server context. Use the progress prop for response updates; it is replaced, never merged as scroll data. See background jobs for state transitions and worker semantics.
Completed export runs may include download: {url, fileName, format: "csv" | "xlsx", expiresAt} or null. The URL is an application-authenticated ordinary GET download, not an Inertia visit or public storage URL. Render it as an ordinary anchor after URL validation (safeUrl() in the core). Expired or unauthorized links disappear when progress refreshes; the endpoint checks authorization and expiry independently. Export action meta.formats declares allowed formats; the handler may submit data.format. See queued exports.
Queued import actions add meta.background: true and reuse the synchronous import mapping contract. Submit files through the normal Inertia FormData path. A successful redirect acknowledges queueing, not imported records. Terminal import runs with failed/skipped rows may expose a CSV download link to the authenticated report endpoint; input rows and detailed row errors stay out of progress props. See queued imports.
Cells may include an optional action: Action | null. Its column identifies the originating column. Submitting it uses operation: "action", column, action, record, and data; the server resolves the column definition and checks the action name before executing the scoped, authorized action. Headless createAction() preserves this target automatically. New-tab preferences are column.meta.newTab, action.meta.newTab, and config.recordUrlNewTab; renderers must retain URL validation and safe link relations.
toolbarActions?: Action[] is independent of selection-bar bulkActions. Toolbar descriptors include scope: "toolbar" and bulk: boolean. Submit scope: "toolbar" with operation: "action" or "bulk" as appropriate; do not include a record or column target. The controller handles this automatically for createAction(). Optional config.recordActionsPosition is "after-columns" (default), "before-columns", or "before-cells". These fields were introduced in version 2 and remain available in version 3.
config.selection?: { enabled: boolean, max: number | null, currentPageOnly: boolean, groupsOnly: boolean } describes selection controls. Missing configuration preserves automatic selection for visible bulk actions. Restricted tables accept explicit selection.ids and reject selection.all: true; group restrictions compare the selected records’ actual group keys on the server. These optional fields remain available in version 3. The frontend retains only loaded IDs after a page replacement when current-page or group-only mode is enabled.
The scope request field accepts "toolbar" or "empty"; operation: "bulk" requires a bulk action in toolbar scope and is rejected in empty scope. Scoped actions reject record and column targets, and an unscoped operation: "action" must target a record. An unscoped bulk request continues to address bulkActions.
Optional emptyStateActions: Action[] contains independently scoped descriptors with scope: "empty". Render them when the loaded data array is empty. controller.createAction(action) preserves the scope and captured table state automatically. Permission checks run again when submitted. Empty-state presentation is not an authorization condition. config.emptyIcon?: string | null is an optional escaped text glyph; custom renderers can replace it. These fields remain available in version 3.
Action.accessSelectedRecords?: boolean opts an ordinary action into selected-record context. Submit its normal action/record/column target plus selection; the controller captures both selection and table state when creating the invocation. Laravel re-resolves the selected models and authorizes the entire collection before the callback. An explicit empty ids list is valid for context actions. Non-opted-in actions ignore extra selection input. These fields remain available in version 3.
config.reorder describes record ordering: column, direction: "asc" | "desc", and active (whether current sort/group state permits reordering). Optional mode indicates whether the temporary reorder UI is open; paginated indicates whether normal pagination continues during that mode. Optional trigger: { label, meta } | null configures the local toggle’s label/color, with null hiding it. config.reorderable remains the authorization/availability flag.
Optional state.reordering defaults to false. Changing it resets the loaded Inertia data. Entering requires table-level reorder authorization and normally switches pagination to none; it never bypasses filters or tenant constraints. Drivers should clear incompatible sort/group state on entry. The server strips the flag before persisting session preferences or saved views; drivers explicitly clear it on reset/view selection.
The reorder operation accepts up to 1,000 distinct IDs in their desired visual order. Laravel assigns the submitted records’ existing position values in configured direction, preserving other records. A direct authorized reorder operation does not require the temporary UI mode. These fields remain available in version 3.