DXTable

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 Modeclient-side + :itemsSmaller datasets (<1000 rows). Instant filtering without server requests.
Inertia Modeinertia-url="/" + :itemsApps already using Inertia.js with server-side page props.

Quick Comparison

Feature API Mode Client-Side Inertia Mode
Setup1 prop: api-url2 props: items, client-side3 props: items, pagination, inertia-url
Data SourceAuto-fetched (built-in fetch client)Passed as items propPassed as page props
Sorting/FilteringServer-side (AJAX)Client-side (instant)Server-side (page reload)
PaginationServer-sideClient-sideServer-side
Best ForLarge datasetsSmall datasets (<1000)Inertia apps

Live Example

Props

Name Click to sort ascendingType Required Click to sort ascendingDefault Description
titlestringNo-Table title
itemNamestringNoitemSingular item name (auto-pluralized for display)
itemsTItem[]No-Table data items (for Inertia or client-side mode)
footClonebooleanNofalseRender a <tfoot> mirroring the header, so #foot(<key>) slots can hold per-column totals
showEmptybooleanNotrueShow a message row when there are no rows (an empty body otherwise looks broken)
emptyTextstringNoNo {items} foundMessage shown when there are no rows. Becomes "No {items} match your filters" when a filter is active
expandedItemsTItem[]No[]Rows currently expanded (v-model). Pair with the row-expansion slot
rowClassstring | (item, index) => string | string[] | objectNo-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) => booleanNo-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
clientSidebooleanNofalseEnable client-side filtering, sorting, and pagination
providerBTableProvider<TItem>No-Provider function for API mode
apiUrlstringNo-API endpoint URL for auto-provider mode
apiAdapterDXTableApiAdapterNo-Translate the built-in provider's request params / response body to a different backend convention (see "Adapting to a different backend convention")
fieldsTableField[]Yes-Table field definitions (see TableField interface)
sortByBTableSortBy[]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)
filtersRecord<string, string>No{}Filter values. With v-model, controlled by you; without one, the INITIAL filters and the user can change them
filterValuesRecord<string, string[]>No-Dynamic filter options from server
inertiaUrlstringNo-Inertia route URL (enables auto-navigation)
busybooleanNofalseLoading/busy state (v-model support, API mode)
loadingbooleanNofalseLoading state (Inertia mode, deprecated - use busy)
loadingTextstringNoLoading...Loading text
errorstring | nullNonullError message
paginationPaginationDataNofunctionPagination data (Inertia mode)
showPaginationbooleanNotrueShow pagination controls
showPerPageSelectorbooleanNotrueShow per-page selector
showCountbooleanNotrueShow 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)
perPageOptionsnumber[]No[10, 20, 50, 100]Per-page options for selector
currentPagenumberNo1Current page (for provider mode)
perPagenumberNo10Page 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
stripedbooleanNofalseStriped (banded) rows. Off by default; opt in for dense tables
hoverbooleanNotrueHover effect on rows
responsivebooleanNotrueResponsive table
fixedLayoutbooleanNofalseApply 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
primaryKeystringNoundefinedField 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
fluidbooleanNofalseFluid container
containerClassstringNopy-5Container CSS class
columnSizestring | numberNo12Column size (Bootstrap grid)
cardbooleanNotrueWrap 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.
editFieldsFieldDefinition[]No-Form fields for edit modal (enables edit on row click)
editTabsEditTab[]No-Tab definitions for organizing edit modal content
editModalTitlestring | ((item: any) => string)No-Edit modal title (string or function)
editModalSizesm | md | lg | xlNolgEdit modal size
editLayoutvertical | horizontalNohorizontalField 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
editLabelColsLabelColsNo-Label column width for the edit modal horizontal layout, forwarded to DXForm labelCols. Only meaningful when editLayout is horizontal
editCardbooleanNofalseWrap 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
saveTextstringNoSave {item}Override the modal Save button label
createTextstringNoCreate {item}Override the modal Create button label
deleteTextstringNoDelete {item}Override the modal Delete button label
editUrlstringNo-API endpoint pattern for updates (e.g., "/api/products/:id")
deleteUrlstringNo-API endpoint pattern for deletions (e.g., "/api/products/:id")
showUrlstringNo-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 | nullNo-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
createUrlstringNo-API endpoint for creating new items (e.g. "/api/products"). Enables the create modal and the built-in "New {item}" button
showCreateButtonbooleanNotrueRender 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 ascendingParameters Description
pageChangepage: numberEmitted when the page changes
sortChangesort: { key: string, order: "asc" | "desc" }Emitted when sort changes
filterChangefilters: Record<string, string>Emitted when filters change
perPageChangeperPage: numberEmitted when per-page value changes
rowClickeditem: T, index: number, event: MouseEventEmitted when a row is clicked
rowUpdateditem: T, response: anyEmitted when a row is successfully updated
editErroritem: T, error: anyEmitted when row update fails
rowDeleteditem: T, response: anyEmitted when a row is successfully deleted
deleteErroritem: T, error: anyEmitted when row deletion fails
update:sortBysortBy: BTableSortBy[]v-model update for sortBy
update:filtersfilters: Record<string, string>v-model update for filters
update:perPageperPage: numberv-model update for perPage
update:busybusy: booleanv-model update for busy state

Slots

DXTable’s own slots:

Name Click to sort ascendingDescription Scoped Props
headerCustom 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 ascendingDescription 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-footA fully custom <tfoot> (instead of footClone)-
emptyReplaces the no-rows message entirely-
row-expansionDetail content rendered under an expanded row (pair with v-model:expanded-items)-
top-row / bottom-rowAn extra row pinned above / below the data rows-
thead-subA second header row beneath the column headers-
table-caption / table-colgroup / table-busyCaption, 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:

  • select vs select-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. Pick select-native when native feel matters more than type-to-filter. filterMultiple is ignored for select-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 on customer_id, instead of the column having to be named after the server’s param with the human-facing value pushed into a #cell slot.
  • filterNullText — adds a “has no value” option to a select filter (e.g. Unassigned on an assignee column). Choosing it sends filterNullValue (default "null"); client-side it matches rows whose value is null, undefined or empty.
  • filterMultiple — lets a select filter 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); filterNullText remains 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:

  1. filterOptions on the field — an explicit { value, text }[].
  2. Server-supplied values — the filter-values prop, or the filterValues block a dfl API response returns for the columns that asked for it.
  3. The loaded rowsclient-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 2 precedes Lane 10).
  • null, undefined and '' are skipped: “has no value” is expressed by filterNullText, which gives that case a real label.
  • A column formatter labels 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 via filterOptions.

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-model they 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-model they 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 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 filtering
  • false or 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 hint property 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 default New {item} button). Lets the create action live outside the table card — a page header, the dashboard navbar actions slot, etc. No-op unless editFields are 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: true matches 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 PaginatedResource for 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 :items and :pagination props (from Inertia page props)
  • Use inertia-url instead of api-url
  • URL updates when sorting/filtering/paginating
  • Uses :loading prop instead of v-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 (:id replaced 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.