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 via axios | 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 | Type | Required | 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) |
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 |
fields | TableField[] | Yes | - | Table field definitions (see TableField interface) |
sortBy | BTableSortBy[] | No | [] | Sort configuration (v-model support) |
filters | Record<string, string> | No | {} | Filter values (v-model support) |
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 |
perPageOptions | number[] | No | [10, 25, 50, 100] | Per-page options for selector |
currentPage | number | No | 1 | Current page (for provider mode) |
perPage | number | No | 10 | Items per page (v-model support) |
striped | boolean | No | true | Striped rows |
hover | boolean | No | true | Hover effect on rows |
responsive | boolean | No | true | Responsive table |
fluid | boolean | No | false | Fluid container |
containerClass | string | No | py-5 | Container CSS class |
columnSize | string | number | No | 12 | Column size (Bootstrap grid) |
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 |
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") |
Events
| Name | 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
| Name | Description | Scoped Props |
|---|---|---|
header | Custom header content | - |
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 with axios
- 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'- Dropdown with options (automatically adds “All” option)'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
Manual Refresh
Access the refresh() method for manual data reloading:
<script setup>
import { ref } from 'vue';
const tableRef = ref(null);
const refreshData = () => {
tableRef.value?.refresh();
};
</script>
<template>
<DButton @click="refreshData">Refresh</DButton>
<DXTable
ref="tableRef"
:provider="fetchProducts"
:fields="fields"
/>
</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 dropdown'number'- Exact numeric match'date'- Exact date match
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"
/>
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' | 'number' | 'date' | false
filterOptions?: FilterOption[]; // Options for select filters
filterPlaceholder?: string; // Placeholder for filter input
formatter?: (value: any, key: string, item: any) => string; // Custom formatter function
[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.