DXTable
A comprehensive data table component with built-in pagination, sorting, filtering, and CRUD operations for Laravel dashboards.
Data Fetching Modes
DXTable supports three modes for loading data. API mode is recommended for server-side operations. Client-side mode is great for smaller datasets that can be filtered/sorted locally.
| Mode | Prop | Best For |
|---|---|---|
| API Mode (Recommended) | api-url="/api/products" | Server-side pagination. Clean separation, reusable endpoints. |
| Client-Side Mode | client-side + :items | Smaller datasets (<1000 rows). Instant filtering without server requests. |
| Inertia Mode | inertia-url="/" + :items | Apps already using Inertia.js with server-side page props. |
Quick Comparison
| Feature | API Mode | Client-Side | Inertia Mode |
|---|---|---|---|
| Setup | 1 prop: api-url | 2 props: items, client-side | 3 props: items, pagination, inertia-url |
| Data Source | Auto-fetched (built-in fetch client) | Passed as items prop | Passed as page props |
| Sorting/Filtering | Server-side (AJAX) | Client-side (instant) | Server-side (page reload) |
| Pagination | Server-side | Client-side | Server-side |
| Best For | Large datasets | Small datasets (<1000) | Inertia apps |
Live Example
Props
| Name Click to sort ascending | Type | Required Click to sort ascending | Default | Description |
|---|---|---|---|---|
title | string | No | - | Table title |
itemName | string | No | item | Singular item name (auto-pluralized for display) |
items | TItem[] | No | - | Table data items (for Inertia or client-side mode) |
footClone | boolean | No | false | Render a <tfoot> mirroring the header, so #foot(<key>) slots can hold per-column totals |
showEmpty | boolean | No | true | Show a message row when there are no rows (an empty body otherwise looks broken) |
emptyText | string | No | No {items} found | Message shown when there are no rows. Becomes "No {items} match your filters" when a filter is active |
expandedItems | TItem[] | No | [] | Rows currently expanded (v-model). Pair with the row-expansion slot |
rowClass | string | (item, index) => string | string[] | object | No | - | Class(es) applied to each row's <tr>. Use for conditional row styling instead of reaching into the table's DOM from global CSS |
rowClickable | (item, index) => boolean | No | - | Whether a row is actionable. A row this returns false for gets no pointer cursor, no hover highlight, and does not fire row-clicked or open the edit modal — so a row that is not clickable does not look clickable |
clientSide | boolean | No | false | Enable client-side filtering, sorting, and pagination |
provider | BTableProvider<TItem> | No | - | Provider function for API mode |
apiUrl | string | No | - | API endpoint URL for auto-provider mode |
apiAdapter | DXTableApiAdapter | No | - | Translate the built-in provider's request params / response body to a different backend convention (see "Adapting to a different backend convention") |
fields | TableField[] | Yes | - | Table field definitions (see TableField interface) |
sortBy | BTableSortBy[] | No | [] | Sort state. With v-model, controlled by you; without one, the table's INITIAL sort and the user can change it. When nothing is sorted, no sort params are sent (see Sorting) |
filters | Record<string, string> | No | {} | Filter values. With v-model, controlled by you; without one, the INITIAL filters and the user can change them |
filterValues | Record<string, string[]> | No | - | Dynamic filter options from server |
inertiaUrl | string | No | - | Inertia route URL (enables auto-navigation) |
busy | boolean | No | false | Loading/busy state (v-model support, API mode) |
loading | boolean | No | false | Loading state (Inertia mode, deprecated - use busy) |
loadingText | string | No | Loading... | Loading text |
error | string | null | No | null | Error message |
pagination | PaginationData | No | function | Pagination data (Inertia mode) |
showPagination | boolean | No | true | Show pagination controls |
showPerPageSelector | boolean | No | true | Show per-page selector |
showCount | boolean | No | true | Show the footer item-count caption ("N items."). Set false to suppress just the caption, independent of the pager (e.g. a page ported from a plain <b-table> that hides the pager and never had a caption) |
perPageOptions | number[] | No | [10, 20, 50, 100] | Per-page options for selector |
currentPage | number | No | 1 | Current page (for provider mode) |
perPage | number | No | 10 | Page size. With v-model, controlled by you; without one, the INITIAL page size and the per-page selector still works. An explicitly-passed perPage takes precedence over any per-page preference persisted in localStorage (URL-keyed tables only) — :per-page="20" always starts at 20 |
striped | boolean | No | false | Striped (banded) rows. Off by default; opt in for dense tables |
hover | boolean | No | true | Hover effect on rows |
responsive | boolean | No | true | Responsive table |
fixedLayout | boolean | No | false | Apply table-layout: fixed so column widths stop depending on cell content and no longer reshuffle when a filter narrows the rows. Off by default. Combine with per-field width/minWidth to control proportions; undeclared columns share the rest equally |
primaryKey | string | No | undefined | Field whose value uniquely identifies each row, forwarded to the inner table so bvn keys rows by value instead of by index (all three data modes). Without it a stateful cell component (a debounced input, an inline editor) can re-associate to the wrong record when a row is inserted/removed mid-interaction — e.g. a concurrent delete of a row above. Set it (e.g. primaryKey="id") to keep each row bound to its record across data mutations. Non-breaking: omit it and rows key by index as before |
fluid | boolean | No | false | Fluid container |
containerClass | string | No | py-5 | Container CSS class |
columnSize | string | number | No | 12 | Column size (Bootstrap grid) |
card | boolean | No | true | Wrap the table in a card. The table renders flush to the card border (rows and stripes reach the edge) while the header and pagination stay padded; only the table region is rounded to follow the card corners, so the card itself does not clip and a position:absolute popover in the header slot can overhang it. Content in a cell slot is still bounded by the .table-responsive scroll container — teleport it (DDropdown and the filter menus already do) or pass responsive={false}. Set false for a plain, borderless variant on the page background. |
editFields | FieldDefinition[] | No | - | Form fields for edit modal (enables edit on row click) |
editTabs | EditTab[] | No | - | Tab definitions for organizing edit modal content |
editModalTitle | string | ((item: any) => string) | No | - | Edit modal title (string or function) |
editModalSize | sm | md | lg | xl | No | lg | Edit modal size |
editLayout | vertical | horizontal | No | horizontal | Field layout for the edit/create modal form, forwarded to its DXForm. Defaults to horizontal (label-left) to match the Omni Tend form convention; pass "vertical" to opt out |
editLabelCols | LabelCols | No | - | Label column width for the edit modal horizontal layout, forwarded to DXForm labelCols. Only meaningful when editLayout is horizontal |
editCard | boolean | No | false | Wrap the edit/create modal form in a bordered card. Off by default (the modal body already provides a boundary); opt in for a more contained, panelled look |
saveText | string | No | Save {item} | Override the modal Save button label |
createText | string | No | Create {item} | Override the modal Create button label |
deleteText | string | No | Delete {item} | Override the modal Delete button label |
editUrl | string | No | - | API endpoint pattern for updates (e.g., "/api/products/:id") |
deleteUrl | string | No | - | API endpoint pattern for deletions (e.g., "/api/products/:id") |
showUrl | string | No | - | API endpoint pattern to fetch the FULL record for the edit modal (e.g., "/api/products/:id"). Seeds the form from this fetch (with a loading state) instead of the thin list row. Unwraps data.data or uses the response as-is |
deleteGuard | (item) => string | null | No | - | Run when Delete is clicked, before the confirm and request. Return a message to block the delete and show it immediately (skips confirm + request); return null to proceed normally |
createUrl | string | No | - | API endpoint for creating new items (e.g. "/api/products"). Enables the create modal and the built-in "New {item}" button |
showCreateButton | boolean | No | true | Render the built-in "New {item}" button in the card header (only relevant when createUrl is set). Set false to drive the create modal from your own trigger elsewhere via the exposed openCreate(). With no title and no header slot, the card header is then dropped entirely rather than left empty |
Events
| Name Click to sort ascending | Parameters | Description |
|---|---|---|
pageChange | page: number | Emitted when the page changes |
sortChange | sort: { key: string, order: "asc" | "desc" } | Emitted when sort changes |
filterChange | filters: Record<string, string> | Emitted when filters change |
perPageChange | perPage: number | Emitted when per-page value changes |
rowClicked | item: T, index: number, event: MouseEvent | Emitted when a row is clicked |
rowUpdated | item: T, response: any | Emitted when a row is successfully updated |
editError | item: T, error: any | Emitted when row update fails |
rowDeleted | item: T, response: any | Emitted when a row is successfully deleted |
deleteError | item: T, error: any | Emitted when row deletion fails |
update:sortBy | sortBy: BTableSortBy[] | v-model update for sortBy |
update:filters | filters: Record<string, string> | v-model update for filters |
update:perPage | perPage: number | v-model update for perPage |
update:busy | busy: boolean | v-model update for busy state |
Slots
DXTable’s own slots:
| Name Click to sort ascending | Description | Scoped Props |
|---|---|---|
header | Custom card header content (overrides the title and the built-in New button) | - |
head-end(<key>) | Additive content at the end of a column's header — a period total, a badge — keeping the sort indicator and hint | - |
edit-value(<key>) | Replace a field's control in the edit modal | - |
edit-span(<key>) | Full-width custom block for an edit-modal field marked span: true | - |
tab-content(<key>) / tab-before(<key>) / tab-after(<key>) | Edit-modal tab content slots (see DXForm) | - |
Every slot the underlying table supports is forwarded with its scope:
| Name Click to sort ascending | Description | Scoped Props |
|---|---|---|
cell(<key>) | Custom rendering for a column's cell | - |
foot(<key>) | A footer cell, under its own column — use for totals. Requires footClone | - |
custom-foot | A fully custom <tfoot> (instead of footClone) | - |
empty | Replaces the no-rows message entirely | - |
row-expansion | Detail content rendered under an expanded row (pair with v-model:expanded-items) | - |
top-row / bottom-row | An extra row pinned above / below the data rows | - |
thead-sub | A second header row beneath the column headers | - |
table-caption / table-colgroup / table-busy | Caption, colgroup and busy-state slots | - |
thead-top is composed, not forwarded: DXTable renders its filter row there,
and a consumer’s thead-top content is placed above it — so a grouped
column-header banner (a <th colspan> spanning several columns) or a pinned
totals row sits above the headers where it belongs, with the filter row beneath.
Give it <tr>s.
One table slot is not available at all, because DXTable renders it itself:
head(<key>) — it draws the column headers, with sort indicators and field
hints, and a forwarded one would silently drop those.
A value in a column’s header (head-end)
To put a single value at the top-right of a column — a period total above the
amounts, a count, a small badge — use #head-end(<key>). It renders inside
DXTable’s own header cell, after the label and before the sort arrows, so the
sort indicator and the field hint survive:
<DXTable :items="lines" :fields="fields">
<template #head-end(amount)>
<strong>{{ periodTotal }}</strong>
</template>
</DXTable>
The slot scope gives you field (the column definition) and label (the
resolved header label). The wrapper element carries the class dx-head-end if
you need to style it.
This is deliberately additive rather than a head(<key>) override: an override
would replace the whole header cell and take the sort indicator and hint with it.
For a per-column total under the data instead, use foot(<key>) below —
the two compose, and a column can have both.
Totals row
Set foot-clone to render a <tfoot> mirroring the header, then fill the
columns you want with #foot(<key>). The numbers line up under their data,
which is the whole point of a footer over a summary bar above the table:
<DXTable :items="lines" :fields="fields" foot-clone>
<template #foot(amount)>
<strong>{{ total }}</strong>
</template>
<template #foot(name)>Total</template>
</DXTable>
For a footer that isn’t a per-column mirror, use #custom-foot instead.
Empty state
When there are no rows, DXTable renders a message rather than a bare header —
an empty body is otherwise indistinguishable from a broken one. It reads
No {items} found, or No {items} match your filters when a column filter is
active, pluralised from item-name.
Override the wording with empty-text, replace the row entirely with the
empty slot, or turn it off with :show-empty="false".
Expandable rows
Bind v-model:expanded-items and provide a row-expansion slot to open detail
content under a row, instead of pushing every per-row detail into a modal:
<DXTable v-model:expanded-items="expanded" :items="entries" :fields="fields">
<template #cell(actions)="{ item }">
<DButton size="sm" @click="toggle(item)">Details</DButton>
</template>
<template #row-expansion="{ item }">
<pre>{{ item.payload }}</pre>
</template>
</DXTable>
Column filters
Set filter on a field ('text' | 'select' | 'select-native' | 'number' | 'date')
to give the column an inline filter. Beyond the type, these options matter:
selectvsselect-native— both are single-select dropdowns sharing the same options and value semantics; they differ only in the control.'select'is a typeahead (browse-on-focus, type-to-narrow);'select-native'renders a plain native<select>(DFormSelect) for consumers who prefer OS-native menu behaviour — a full-height menu and native keyboard/scroll. Pickselect-nativewhen native feel matters more than type-to-filter.filterMultipleis ignored forselect-native(it is always single-select).filterKey— the key the filter is sent under, when it differs from the column’s own key. A “Customer” column can render a name and filter oncustomer_id, instead of the column having to be named after the server’s param with the human-facing value pushed into a#cellslot.filterNullText— adds a “has no value” option to aselectfilter (e.g.Unassignedon an assignee column). Choosing it sendsfilterNullValue(default"null"); client-side it matches rows whose value is null, undefined or empty.filterMultiple— lets aselectfilter hold several values at once (status in active, pending). The filters map entry becomes an array, sent as Laravel bracket notation (filters[status][]=active&filters[status][]=pending) in provider and Inertia modes; client-side, a row matches when its value equals any chosen value. An emptied selection means “no filter”. The “All …” reset row is omitted (remove the chips or ✕ instead);filterNullTextremains choosable alongside real values.
const fields = [
{ key: 'customer_name', label: 'Customer', filter: 'text', filterKey: 'customer_id' },
{ key: 'assignee', label: 'Assignee', filter: 'select', filterNullText: 'Unassigned' },
{ key: 'status', label: 'Status', filter: 'select', filterMultiple: true },
];
A field with label: '' renders an empty header (for an actions column, say)
— only a field with no label at all falls back to its key.
Where a select filter’s options come from
A select (and select-native) filter resolves its option list in this order:
filterOptionson the field — an explicit{ value, text }[].- Server-supplied values — the
filter-valuesprop, or thefilterValuesblock a dfl API response returns for the columns that asked for it. - The loaded rows — client-side mode only: DXTable derives the options from the distinct values present in the data.
Step 3 means a client-side table needs no option list at all:
// clientSide: rows are all loaded, so the Status filter fills itself in
const fields = [
{ key: 'reference', label: 'Reference' },
{ key: 'status', label: 'Status', filter: 'select' },
];
Details worth knowing:
- Options come from the full loaded row set, never the filtered rows or the current page — otherwise picking a value would collapse the list to that one value with no way back.
- Values are de-duplicated and sorted naturally (numbers numerically, strings by
locale, so
Lane 2precedesLane 10). null,undefinedand''are skipped: “has no value” is expressed byfilterNullText, which gives that case a real label.- A column
formatterlabels the option (19.99→$19.99); the option’s underlying value stays raw, so the filter still matches. - Dot-path keys (
paid_by.card) work. - Opt a column out with
deriveFilterOptions: false— useful when the raw values are opaque ids you would rather label yourself viafilterOptions.
Provider/API and Inertia modes never derive: they hold one page of rows, so the
options would silently change as you page. Give those columns filterOptions,
or return filterValues from the endpoint.
Nested values (dot-path keys)
A field key may be a dot path into the row, so a relation can be rendered
straight from the payload Laravel already serialises — no flattening step:
// items: [{ id: 1, reference: 'INV-1', paid_by: { card: 'Visa' } }]
const fields = [
{ key: 'reference', label: 'Reference' },
{ key: 'paid_by.card', label: 'Card', sortable: true, filter: 'text' },
];
The path is resolved for the cell, client-side sorting and client-side
filtering alike. A missing or null link along the path renders an empty cell
rather than throwing, so a row with no paid_by is fine.
Resolution takes the literal key first, then the path, so a flat row that
really does have a 'paid_by.card' key keeps working, and a nested one is
walked. In server modes the value still renders, but sorting and filtering are
the server’s job — a dotted key is sent as-is (sortBy=paid_by.card), so
whitelist or map it there.
To override the rendering, give the column its own #cell() slot as usual — it
takes precedence over the built-in path resolution:
<template #cell(paid_by.card)="{ item }">
<DBadge>{{ item.paid_by?.card ?? '—' }}</DBadge>
</template>
Adapting to a different backend convention (api-adapter)
If your backend doesn’t speak dfl’s provider contract — different param names
(spatie query-builder’s sort=-name, filter[key]=…), or an envelope without
{data, pagination} — the api-adapter prop translates in both directions
while keeping the built-in provider’s error handling and pager:
<DXTable
api-url="/api/accounts"
:fields="fields"
:api-adapter="{
// dfl params in → your backend's params out (this return IS the wire)
request: (params) => ({
paginate: 'true',
page: params.page,
perPage: params.perPage,
sort: (params.sortOrder === 'desc' ? '-' : '') + params.sortBy,
}),
// your envelope in → dfl shape out. `params` are the ORIGINAL dfl params,
// for synthesizing paginator metadata your envelope lacks.
response: (body, { params }) => ({
data: body.data,
pagination: {
current_page: params.page,
per_page: params.perPage,
total: body.total,
from: (params.page - 1) * params.perPage + 1,
to: (params.page - 1) * params.perPage + body.data.length,
},
}),
}"
/>
This replaces the axios-interceptor bridges some consumers used before v0.33 (DXTable no longer requests through axios). Without an adapter, a response that is a bare array of rows renders as rows with no pager, rather than a silently empty table.
Custom providers and pagination
Only the built-in api-url provider knows to read response.data.pagination.
A custom provider returns rows and nothing else, so it cannot report its own
page metadata: pass it via the pagination prop, or set
:show-pagination="false" if the pager is deliberately absent. DXTable warns
rather than quietly rendering a table with no pager.
Initial values vs controlled state
per-page, sort-by and filters mean different things depending on whether
you bind a v-model:
- Without a
v-modelthey are initial values. The table owns the state from then on, and the matching control keeps working.<DXTable :per-page="50" ... /> <!-- start at 50; the selector still works --> - With a
v-modelthey are controlled state. You are the source of truth, and the table renders whatever you hand it — including refusing a change.<DXTable v-model:per-page="perPage" ... />
Until 0.24.0 any passed value was treated as controlled, so :per-page="50"
rendered a per-page selector that responded to clicks and changed nothing.
Sorting
Clicking a sortable header cycles ascending → descending → unsorted.
In the unsorted state — and on an initial load with no sortBy — DXTable sends
no sort params at all. It does not fall back to a column of its own choosing,
so the server applies its own default ordering. Requesting a column the consumer
never declared is a hard 400 on a strict endpoint (e.g. spatie’s QueryBuilder),
which turned a third header click into a broken page.
Give the table an initial sort with sortBy:
<DXTable
api-url="/api/products"
:fields="fields"
:sort-by="[{ key: 'created_at', order: 'desc' }]"
/>
Handle the no-sort case on the server with a default, as the controller example
below does ($request->input('sortBy', 'created_at')).
The sortChange event (Inertia mode) can only describe an active sort, so it
is not emitted for the unsorted state. Listen to update:sortBy — which carries
the empty array — if you need to observe it.
Failed requests
A rejected request renders an error alert above the table (not instead of
it, so the sort or filter that caused it can still be undone). This covers your
own provider as well as the built-in api-url one: a provider that rejects
never renders as an empty table.
API Mode (Recommended)
API mode is the recommended approach for most applications. Just provide an api-url and DXTable handles everything: fetching data, pagination, sorting, and loading states.
Why API Mode?
- Simpler setup - One prop instead of three
- Better separation - Your API is independent of your UI framework
- Reusable endpoints - Same API works for mobile apps, other frontends, etc.
- Better caching - JSON responses cache more efficiently than full page responses
- No framework lock-in - Works whether you use Inertia, traditional Blade, or a pure SPA
Laravel Backend (API Endpoint)
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function apiIndex(Request $request)
{
$page = $request->input('page', 1);
$perPage = $request->input('perPage', 10);
$sortBy = $request->input('sortBy', 'created_at');
$sortOrder = $request->input('sortOrder', 'desc');
// Whitelist allowed sort columns for security
$allowedSortColumns = ['sku', 'name', 'price', 'stock', 'created_at'];
if (!in_array($sortBy, $allowedSortColumns)) {
$sortBy = 'created_at';
}
if (!in_array(strtolower($sortOrder), ['asc', 'desc'])) {
$sortOrder = 'desc';
}
$products = Product::orderBy($sortBy, $sortOrder)
->paginate($perPage, ['*'], 'page', $page);
return response()->json([
'data' => $products->items(),
'pagination' => [
'current_page' => $products->currentPage(),
'per_page' => $products->perPage(),
'total' => $products->total(),
'from' => $products->firstItem(),
'to' => $products->lastItem(),
'last_page' => $products->lastPage(),
],
]);
}
}
Route:
// routes/api.php
Route::get('/products', [ProductController::class, 'apiIndex']);
Vue Frontend (Simple API Mode)
<script setup lang="ts">
import { ref } from 'vue';
import { DXTable } from '@omnitend/dashboard-for-laravel';
const fields = [
{ key: 'sku', label: 'SKU', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'price', label: 'Price', sortable: true },
{ key: 'stock', label: 'Stock', sortable: true },
];
const busy = ref(false);
</script>
<template>
<DXTable
title="Products"
api-url="/api/products"
:fields="fields"
v-model:busy="busy"
:per-page="10"
/>
</template>
That’s it! Just pass api-url and DXTable handles:
- AJAX requests via the built-in fetch client (
api— no axios needed) - Sorting parameters
- Pagination parameters
- Data extraction from
response.data.data - Error handling
Advanced: Custom Provider Function
For custom API logic (auth headers, data transformation, etc.), provide your own provider:
<script setup>
const customProvider = async (context) => {
const response = await fetch('/api/products', {
headers: { 'Authorization': `Bearer ${token}` },
...
});
const json = await response.json();
return json.items; // Custom response structure
};
</script>
<template>
<DXTable :provider="customProvider" :fields="fields" />
</template>
Provider Function Details
The provider function receives a context object:
interface BTableProviderContext {
sortBy?: BTableSortBy[]; // Current sort state
filter?: string; // Filter string (if filtering enabled)
currentPage: number; // Current page number
perPage: number; // Items per page
}
Important:
- Return an array of items (or Promise that resolves to array)
- BTable handles pagination UI automatically
- No need to show “Showing X to Y” text (provider mode)
- Sorting and pagination trigger automatic provider calls
Adding Filters
Add inline filters by specifying filter in field definitions:
const fields = [
{ key: 'sku', label: 'SKU', sortable: true },
{ key: 'name', label: 'Name', sortable: true, filter: 'text' },
{ key: 'category', label: 'Category', sortable: true, filter: 'select', filterOptions: [
{ value: 'Electronics', text: 'Electronics' },
{ value: 'Clothing', text: 'Clothing' },
{ value: 'Books', text: 'Books' },
]},
{ key: 'price', label: 'Price', sortable: true, filter: 'number' },
{ key: 'stock', label: 'Stock', sortable: true, filter: 'number' },
];
Filter Types:
'text'- Text input with 300ms debounce for LIKE searches'select'- Typeahead dropdown with options (automatically adds “All” option)'select-native'- Plain native<select>(same options/values as'select', OS-native menu; single-select only)'number'- Number input for exact matches'date'- Date input for date filteringfalseor omit - No filter for this column
Filters appear inline beneath the table headers and trigger server requests automatically.
Field Hints
Add helpful hint text below column headers to guide users:
const fields = [
{ key: 'sku', label: 'SKU', sortable: true, hint: 'Product code' },
{ key: 'name', label: 'Name', sortable: true, filter: 'text', hint: 'Search by name' },
{ key: 'price', label: 'Price', sortable: true, hint: 'USD', formatter: (value) => `$${value}` },
{ key: 'stock', label: 'Stock', sortable: true, hint: 'Current inventory' },
];
Features:
- Hint text appears below the column label in a smaller, muted font
- Works with sortable columns (hint appears above sort indicators)
- Works with filters (hint appears in column header, above filter input)
- Useful for units (USD, kg), instructions (Search by name), or context (Current inventory)
- Optional - only shows when
hintproperty is provided
Backend Filter Handling
Update your controller to accept and apply filters:
public function apiIndex(Request $request)
{
$page = $request->input('page', 1);
$perPage = $request->input('perPage', 10);
$sortBy = $request->input('sortBy', 'created_at');
$sortOrder = $request->input('sortOrder', 'desc');
// Build query with filters
$query = Product::query();
$filters = $request->input('filters', []);
// Text filters (LIKE search)
if (!empty($filters['name'])) {
$query->where('name', 'LIKE', '%' . $filters['name'] . '%');
}
// Exact match filters
if (!empty($filters['category'])) {
$query->where('category', $filters['category']);
}
if (!empty($filters['price'])) {
$query->where('price', '=', $filters['price']);
}
// Whitelist sort columns
$allowedSortColumns = ['sku', 'name', 'price', 'stock', 'created_at'];
if (!in_array($sortBy, $allowedSortColumns)) {
$sortBy = 'created_at';
}
$products = $query->orderBy($sortBy, $sortOrder)
->paginate($perPage, ['*'], 'page', $page);
return response()->json([
'data' => $products->items(),
'pagination' => [
'current_page' => $products->currentPage(),
'per_page' => $products->perPage(),
'total' => $products->total(),
'from' => $products->firstItem(),
'to' => $products->lastItem(),
'last_page' => $products->lastPage(),
],
]);
}
Filter Behavior:
- Text filters automatically debounce (300ms) to reduce server load
- Filtering resets to page 1
- Empty filters are removed from request
- Filters preserve sort state
Exposed methods
Via a template ref, the table exposes:
refresh()— reload the table data.openCreate()— open the built-in create modal (same as the defaultNew {item}button). Lets the create action live outside the table card — a page header, the dashboard navbar actions slot, etc. No-op unlesseditFieldsare set.
Pair openCreate() with :show-create-button="false" to move the create action
somewhere else entirely: the table stops rendering its own New {item} button
and your trigger drives the same modal. When the table also has no title and
no header slot, the card header is dropped rather than left empty.
<script setup>
import { ref } from 'vue';
const tableRef = ref(null);
</script>
<template>
<DButton @click="tableRef?.openCreate()">New product</DButton>
<DButton @click="tableRef?.refresh()">Refresh</DButton>
<DXTable
ref="tableRef"
:provider="fetchProducts"
:fields="fields"
:edit-fields="editFields"
create-url="/api/products"
:show-create-button="false"
/>
</template>
Client-Side Mode
Client-side mode is perfect for smaller datasets where you want instant filtering and sorting without server requests. Data is loaded once (either statically or from an API), then all filtering, sorting, and pagination happens in the browser.
When to Use Client-Side Mode
- Dataset is small (< 1000 rows)
- You want instant filtering without network latency
- Data doesn’t change frequently
- You’re building a simple admin interface or demo
Basic Usage
<script setup lang="ts">
import { ref } from 'vue';
import { DXTable } from '@omnitend/dashboard-for-laravel';
const fields = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true, filter: 'text' },
{ key: 'email', label: 'Email', filter: 'text' },
{ key: 'status', label: 'Status', filter: 'select', filterOptions: [
{ value: 'Active', text: 'Active' },
{ value: 'Inactive', text: 'Inactive' },
]},
];
const items = ref([
{ id: 1, name: 'John Smith', email: 'john@example.com', status: 'Active' },
{ id: 2, name: 'Jane Doe', email: 'jane@example.com', status: 'Active' },
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', status: 'Inactive' },
// ... more items
]);
</script>
<template>
<DXTable
title="Users"
item-name="user"
:items="items"
:fields="fields"
:client-side="true"
/>
</template>
Filter Types
The same filter types work in client-side mode:
'text'- Case-insensitive contains search'select'- Exact match typeahead dropdown (filterMultiple: truematches ANY of the chosen values)'select-native'- Exact match via a native<select>(single-select; same derived options as'select')'number'- Exact numeric match'date'- Exact date match
A 'select' (or 'select-native') column needs no filterOptions here:
client-side, DXTable fills the dropdown from the distinct values in the loaded
rows. See
Where a select filter’s options come from.
Performance Considerations
- Client-side mode loads all data into memory
- For datasets > 1000 rows, consider API mode with server-side filtering
- Filtering and sorting are instant (no network requests)
- Pagination happens locally on the filtered/sorted data
Inertia Mode (Alternative)
If your application already uses Inertia.js and you prefer server-side page props, you can use Inertia mode instead of API mode.
When to Use Inertia Mode
- Your app is built entirely with Inertia.js
- You want the browser URL to update with sort/filter/page parameters
- You’re already using
PaginatedResourcefor other Inertia pages
Laravel Backend (Inertia)
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;
use OmniTend\LaravelDashboard\Http\Resources\PaginatedResource;
class ProductController extends Controller
{
public function index(Request $request)
{
$perPage = $request->input('perPage', 10);
$sortBy = $request->input('sortBy', 'created_at');
$sortOrder = $request->input('sortOrder', 'desc');
// Whitelist allowed sort columns
$allowedSortColumns = ['sku', 'name', 'price', 'stock', 'created_at'];
if (!in_array($sortBy, $allowedSortColumns)) {
$sortBy = 'created_at';
}
$products = Product::orderBy($sortBy, $sortOrder)->paginate($perPage);
return Inertia::render('Products/Index', [
'products' => new PaginatedResource($products),
]);
}
}
Vue Frontend (Inertia)
<script setup lang="ts">
import { DXTable } from '@omnitend/dashboard-for-laravel';
import type { PaginationData } from '@omnitend/dashboard-for-laravel';
interface Product {
id: number;
sku: string;
name: string;
price: string;
stock: number;
}
interface Props {
products: PaginationData & { data: Product[] };
}
defineProps<Props>();
const fields = [
{ key: 'sku', label: 'SKU', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'price', label: 'Price', sortable: true },
{ key: 'stock', label: 'Stock', sortable: true },
];
</script>
<template>
<DXTable
title="Products"
:items="products.data"
:fields="fields"
:pagination="products"
inertia-url="/"
/>
</template>
Key differences from API mode:
- Pass data via
:itemsand:paginationprops (from Inertia page props) - Use
inertia-urlinstead ofapi-url - URL updates when sorting/filtering/paginating
- Uses
:loadingprop instead ofv-model:busy
Custom Event Handlers (No Auto-Navigation)
If you need custom behavior, omit inertia-url and handle events manually:
<DXTable
:items="products.data"
:fields="fields"
:pagination="products"
@page-change="customPageHandler"
@sort-change="customSortHandler"
/>
Styling
Header titles render muted grey (still bold) by default, so the table’s content is the loud layer. Re-louden — or retheme — via the CSS token:
:root {
--dx-table-header-color: var(--bs-body-color); /* back to near-black */
}
TableField Interface
Fields support the following properties:
interface TableField {
key: string; // Required: Field key (matches data property)
label?: string; // Column header label
sortable?: boolean; // Enable sorting for this column
hint?: string; // Hint text below column header
filter?: FilterType; // Filter type: 'text' | 'select' | 'select-native' | 'number' | 'date' | false
filterMultiple?: boolean; // 'select' filters only: allow several values (array filter entry, ANY-match); ignored by 'select-native'
filterOptions?: FilterOption[]; // Options for select filters
deriveFilterOptions?: boolean; // Client-side 'select' filters: derive options from the loaded rows (default true)
filterPlaceholder?: string; // Placeholder for filter input
formatter?: (value: any, key: string, item: any) => string; // Custom formatter function
width?: string | number; // Fixed column width via <colgroup> (number → px). Pair with fixedLayout (#156)
minWidth?: string | number; // Minimum column width, same <col> (number → px)
[key: string]: any; // Any other Bootstrap Vue Next BTable field props
}
Edit Modals
Enable inline editing by providing editFields and editUrl:
<script setup>
const fields = [
{ key: 'name', label: 'Product Name', sortable: true },
{ key: 'price', label: 'Price', sortable: true },
];
const editFields = [
{ key: 'name', label: 'Product Name', type: 'text', required: true },
{ key: 'description', label: 'Description', type: 'textarea' },
{ key: 'price', label: 'Price', type: 'number', required: true },
{ key: 'stock', label: 'Stock', type: 'number', required: true },
];
</script>
<template>
<DXTable
:items="products.data"
:fields="fields"
:edit-fields="editFields"
edit-url="/api/products/:id"
edit-modal-title="Edit Product"
/>
</template>
Features:
- Click any row to open edit modal
- Form fields auto-populated from row data
- Save button submits PUT request to
editUrl(:idreplaced with item.id) - Success/error toasts shown automatically
- Table refreshes after successful save
- Validation errors displayed inline
Delete Functionality
Enable deletion with the deleteUrl prop:
<template>
<DXTable
:items="products.data"
:fields="fields"
:edit-fields="editFields"
edit-url="/api/products/:id"
delete-url="/api/products/:id"
@row-deleted="handleDeleted"
@delete-error="handleDeleteError"
/>
</template>
Features:
- Delete button appears in modal footer (red/danger variant)
- Confirmation dialog before deletion
- Success/error toasts with server messages
- Table auto-refreshes after successful deletion
- Displays server validation errors (e.g., “Cannot delete. This category has 42 products.”)
Backend Example:
public function destroy(Product $product)
{
// Optional: Add validation
if ($product->orders()->count() > 0) {
return response()->json([
'message' => "Cannot delete {$product->name}. This product has orders.",
], 422);
}
$product->delete();
return response()->json([
'success' => true,
'message' => 'Product deleted successfully',
]);
}
Extended Component
This is a custom component that extends beyond simple Bootstrap Vue Next wrappers, providing additional functionality specific to Laravel dashboards.