@givanov95/vue-data-table
Laravel DataTable
A server-side DataTable builder for Laravel that ships with a matching Vue 3 + Inertia frontend. Backend and frontend live in the same repository and are published as two separate packages:
| Package | Installer |
|---|---|
givanov95/laravel-data-table |
composer require givanov95/laravel-data-table |
@givanov95/vue-data-table |
npm install @givanov95/vue-data-table |
The two are designed to talk to each other through a single JSON payload, so you write your table definition once in PHP and the Vue component renders it.
Installation
Backend (Laravel 12–13 / PHP 8.3+)
composer require givanov95/laravel-data-table
The service provider is auto-discovered. Publish the config if you want to customise the request parameter keys:
php artisan vendor:publish --tag=data-table-config
This creates config/data-table.php.
Frontend (Vue 3 + Inertia)
npm install @givanov95/vue-data-table
Register the plugin once (e.g. in app.ts / app.js). Both options are
optional — without them the components fall back to identity translations and
a global route() helper if one exists.
import { createApp } from "vue";
import { DataTablePlugin } from "@givanov95/vue-data-table";
createApp(App)
.use(DataTablePlugin, {
// Hook up your i18n helper:
translator: (key) => window.__(key),
// Hook up ziggy or whatever produces URLs:
route: window.route,
// Optional: debounce delay for filter/search reloads (ms)
reloadDebounceMs: 1200,
})
.mount("#app");
Configuration
config/data-table.php:
return [
'translatable_table' => 'translations',
'translatable_column' => 'key',
'global_filter' => 'filter.global',
'per_page' => 'perPage',
'trashed' => 'filter.trashed',
'restore_id' => 'restore_id',
'ordering' => 'ordering',
'default_per_page' => 15,
'max_per_page' => 100,
];
These keys map directly to the HTTP query parameters the frontend sends, and
both DataTable and QueryBuilderTable read them (restore_id is only used
by DataTable). ordering is the parameter that carries ordering[key] and
ordering[direction]. The Vue components bundled with the package send the
defaults above, so changing a key means pairing it with your own frontend.
The page size read from the request is bounded: anything above max_per_page
is cut to it (so ?perPage=100000 cannot load the whole table), and anything
that is not a number or is below 1 (empty, text, an array, zero, negative)
falls back to default_per_page. default_per_page is capped by
max_per_page too. This applies to both DataTable and QueryBuilderTable.
?perPage=-1is not "all rows" — it gets the default page size. Export all rows server-side instead.- A
max_per_pagebelow 1 is treated as a misconfiguration and falls back to 50; it does not turn the bound off. - If your frontend offers
perPageOptionslarger than 100, raisemax_per_pageaccordingly. An option abovemax_per_pagecannot be honoured: the server answers withmax_per_pagerows, and the per-page select shows the page size actually applied (asDefault (100), the label of the "Default" entry carrying the number) instead of the option that was chosen. - A
DataTableParamsyou build and pass toprocess()yourself is used as-is.
Backend usage
Basic example
use Givanov95\DataTable\DataTable;
use App\Models\User;
public function index()
{
$table = (new DataTable(User::query()))
->setColumn('id', '#', searchable: true, orderable: true)
->setColumn('name', __('Name'), searchable: true, orderable: true)
->setColumn('email', __('Email'), searchable: true, orderable: true)
->setColumn('action', __('Action'))
->process();
return Inertia::render('Users/Index', [
'dataTable' => fn () => $table,
]);
}
Sorting requested by the client (ordering[key], ordering[direction]) is only
applied to columns registered with orderable: true (for a RelationColumn, its
relation.column path). Any other key is ignored and the default ordering
(id DESC) is used. An ordering you set yourself with
setOrdering(new Ordering(...)) is applied as-is.
setColumn accepts both shorthand positional arguments and a fully
constructed Column object — pick whichever reads better:
use Givanov95\DataTable\Columns\Column;
$table
->setColumn(new Column('id', '#', searchable: true, orderable: true))
->setColumn('action', __('Action'));
Lightweight spatie table (QueryBuilderTable)
For lean listings that don't need the column-type system, QueryBuilderTable
wraps spatie/laravel-query-builder
and emits the same { data, meta, columns, state } contract, so the same
frontend consumes it. It handles global search, sorting, the trashed toggle,
a perPage cap, and an optional transform() for whitelisting output (keep PII
out of the listing).
use App\Models\User;
use Givanov95\DataTable\QueryBuilderTable;
$payload = QueryBuilderTable::for(User::query()->with('roles:id,name'), request())
->columns([
['key' => 'id', 'label' => __('ID'), 'sortable' => true],
['key' => 'first_name', 'label' => __('First name'), 'sortable' => true, 'searchable' => true],
['key' => 'email', 'label' => __('Email'), 'sortable' => true, 'searchable' => true],
])
->allowTrashed()
->defaultSort('id', 'desc')
->transform(fn (User $user) => [
'id' => $user->id,
'first_name' => $user->first_name,
'email' => $user->email,
'roles' => $user->roles->map(fn ($r) => ['id' => $r->id, 'name' => $r->name])->all(),
'deleted_at' => $user->deleted_at?->toDateTimeString(),
])
->toArray();
The request keys (search, trashed, ordering and per-page) come from
config/data-table.php, like in DataTable. Spatie rejects a filter[...] it
does not know with InvalidFilterQuery, so QueryBuilderTable accepts the
filters named by the configured keys when they sit under Spatie's filter
parameter ('global_filter' => 'filter.search' accepts filter[search]), and
keeps tolerating filter[global], filter[timeZone] and (with allowTrashed())
filter[trashed]. A search key outside that parameter ('global_filter' => 'q')
works too.
The page size is bounded by data-table.max_per_page (see
Configuration); call ->maxPerPage(50) to override it for a
single table (it throws InvalidArgumentException below 1), and
->defaultPerPage(25) to change the fallback.
Use DataTable for column-rich tables; use QueryBuilderTable for lean listings
that want spatie's query power behind a small, reusable facade.
Relation columns
use Givanov95\DataTable\Columns\RelationColumn;
$table->setRelationColumn(
new RelationColumn('user.name', __('User'), searchable: true, orderable: true)
);
Translatable columns
Designed to plug into the common translations morphMany pattern
(translatable_id, translatable_type, locale, key, text):
use Givanov95\DataTable\Columns\TranslatableColumn;
$table->setTranslatableColumn(
new TranslatableColumn(
locale: app()->getLocale(),
translationKey: 'title',
label: __('Title'),
searchable: true,
orderable: true,
)
);
Special column types
// Enum filtering by case name
$table->setEnumColumn('status', App\Enums\OrderStatus::class);
// Numeric-only filtering (currency / numeric input)
$table->setPriceColumn('price');
// Date filtering with timezone-aware parsing
$table->setDateColumn('created_at', 'd.m.Y H:i:s');
Supported databases
Date filtering formats the column in SQL, so it needs a driver-specific expression. It is supported on:
| Database | Driver | SQL used |
|---|---|---|
| MySQL / MariaDB | mysql, mariadb |
DATE_FORMAT(column, …) |
| PostgreSQL | pgsql |
TO_CHAR(column, …) |
| SQLite | sqlite |
strftime(…, column) |
On any other driver (e.g. SQL Server) filtering a date column throws
Givanov95\DataTable\Exceptions\UnsupportedDriverException. The other filters
(text, enum, price) are plain Eloquent where clauses and do not depend on it.
Eager-loading relations
$table->setRelation('translations');
$table->setRelation('user', ['id', 'name']);
Custom query hooks
$table->process(null, function ($query) {
$query->where('owner_id', auth()->id());
});
// Or for free-form mutations:
$table->advancedSearch(fn ($q) => $q->whereJsonContains('tags', 'featured'));
Frontend usage
<script setup lang="ts">
import { DataTable } from "@givanov95/vue-data-table";
import type { DataTableType } from "@givanov95/vue-data-table";
defineProps<{
dataTable: DataTableType<{ id: number; name: string; email: string }>;
}>();
</script>
<template>
<DataTable
:data-table="dataTable"
:global-search="true"
:per-page-options="[15, 30, 50]"
>
<template #cell(action)="{ item }">
<a :href="https://github.com/givanov95/laravel-data-table/blob/HEAD/`/users/${item.id}/edit`">Edit</a>
</template>
</DataTable>
</template>
Component props
| Prop | Type | Description |
|---|---|---|
dataTable |
DataTableType<T> |
The payload returned by (new DataTable(...))->process() |
propName |
string (default dataTable) |
Inertia prop key for partial reloads |
globalSearch |
boolean |
Show the global search input |
showTrashed |
boolean |
Show the "trashed" toggle |
advancedFilters |
boolean |
Reserve space for the advanced-filters slot |
selectedRowIndexes |
(string | number)[] |
Highlight matching rows |
selectedRowColumn |
string |
Column to match against selectedRowIndexes |
rowClickLink |
string |
URL template (use ?id placeholder) for row clicks |
perPageOptions |
number[] |
Render a per-page dropdown |
Slots
#additionalContent— content inside the toolbar (e.g. "Create" buttons)#advancedFilters— content inside the advanced-filters toolbar slot#cell(<column-key>)— custom renderer for a column; receives{ value, item }#cell(<relation.column>)— custom renderer for relation columns
TanStack Table + shadcn-vue
For apps built on shadcn-vue (Laravel's Vue starter kit and the ones derived
from it), the package has a second frontend: a composable that drives the
payload of ->process()->toArray() with TanStack Table
in manual mode, and a shadcn-vue UI for it.
The two parts are delivered the way each one has to be:
| Part | What | How |
|---|---|---|
| Logic | useLaravelDataTable() and the payload types |
npm: @givanov95/vue-data-table/tanstack — updated with npm update |
| UI | ServerDataTable, DataTable, DataTableColumnHeader, features.ts |
published into the app — it imports the app's own @/components/ui/* |
Install
composer require givanov95/laravel-data-table
npm install @givanov95/vue-data-table @tanstack/vue-table
php artisan vendor:publish --tag=data-table-shadcn
The UI lands in resources/js/components/ui/data-table/. It needs these
shadcn-vue components in the app: button, input, label, select,
switch, table and dropdown-menu, and the @lucide/vue icons.
Update both together, so the UI and the composable stay on one version:
composer update givanov95/laravel-data-table
npm install @givanov95/vue-data-table@^3.4
php artisan vendor:publish --tag=data-table-shadcn --force
The published files are the package's: --force overwrites them. Change them
here (props, slots) rather than in the app.
Texts
Every text of the UI goes through the package's translator, with the English text as the key. Hook up the app's translator once, after its own i18n plugin:
import { DataTablePlugin } from "@givanov95/vue-data-table/tanstack";
app.use(TranslationPlugin, translations) // the app's own __()
.use(DataTablePlugin, { translator: (key, replacements) => app.config.globalProperties.__(key, replacements) });
Without a translator the English keys are shown, with :placeholders filled
in. The keys, for the app's lang/*.json:
| Key | bg | nl |
|---|---|---|
Search… |
Търсене… | Zoeken… |
No results. |
Няма резултати. | Geen resultaten. |
Show archived |
Покажи архивираните | Toon gearchiveerde |
Showing :from–:to of :total |
Показани :from–:to от :total | :from–:to van :total |
Rows per page |
Редове на страница | Rijen per pagina |
First page |
Първа страница | Eerste pagina |
Previous page |
Предишна страница | Vorige pagina |
Next page |
Следваща страница | Volgende pagina |
Last page |
Последна страница | Laatste pagina |
Columns |
Колони | Kolommen |
The translator receives :from, :to and :total as replacements; it must
replace the longest names first (Laravel does), or :to eats the start of
:total. fillPlaceholders() of the package does it right.
Usage
<script setup lang="ts">
import { h } from "vue";
import { useLaravelDataTable } from "@givanov95/vue-data-table/tanstack";
import type { LaravelDataTablePayload } from "@givanov95/vue-data-table/tanstack";
import { DataTableColumnHeader, ServerDataTable } from "@/components/ui/data-table";
import type { DataTableColumnDef } from "@/components/ui/data-table";
type Row = { id: number; name: string };
const props = defineProps<{ dataTable: LaravelDataTablePayload<Row> }>();
const table = useLaravelDataTable<Row>(() => props.dataTable);
const columns: DataTableColumnDef<Row>[] = [
{
accessorKey: "name",
header: ({ column }) => h(DataTableColumnHeader, { column, title: "Name" }),
},
];
</script>
<template>
<ServerDataTable :columns="columns" v-bind="table">
<template #toolbar><!-- the page's own filters --></template>
</ServerDataTable>
</template>
useLaravelDataTable(payload, options) options: propName (default
dataTable), alsoReload (other props to refresh with the table, e.g. the
page's own filters), searchDebounceMs (1200) and perPageOptions
([10, 20, 50, 100]; the applied size is always offered). The table follows
the server's applied state (state.sort, state.search, state.trashed), so
back/forward and shared links stay right; a new sort, page size, search or
archive goes back to page 1. clearSearch(data) empties the search box and
reloads once, together with the page's own filter changes. The request keys
come from the payload's params (config/data-table.php), so changing a key
there needs nothing on the frontend.
A RawOrdering hides the sort from state.sort (the rows are not in the order
of a column then); pass what it amounts to when there is one:
new RawOrdering('created_at DESC, id DESC', new Ordering('created_at', 'DESC')).
ServerDataTable props beyond the composable's: showTrashed,
searchPlaceholder, emptyText, trashedLabel and cellClass (classes on
every body cell, e.g. align-top for tall rows).
DataTable is the client-side counterpart for rows already in the page (no
server round trip): search, sorting, column toggle and the same footer as
ServerDataTable ("Showing 1–10 of 25", page numbers, per-page select). Props:
columns, data, pageSize (10), pageSizeOptions ([10, 20, 50, 100], []
hides the select), enableGlobalFilter, enableColumnToggle,
searchPlaceholder, emptyText, columnsLabel. Prefer ServerDataTable for
anything that can grow: DataTable loads every row.
Backend API reference
DataTable
__construct(Builder $builder, ?Request $request = null)setColumn(string|Column $keyOrColumn, ?string $label = null, bool $searchable = false, bool $orderable = false, bool $exactMatch = false): selfsetRelationColumn(RelationColumn $column): selfsetTranslatableColumn(TranslatableColumn $column): selfsetEnumColumn(string $key, class-string<\BackedEnum> $enumClass): selfsetPriceColumn(string $key): selfsetDateColumn(string $key, string $format, string $dateDelimiter = '.', string $timeDelimiter = ':'): selfsetRelation(string $relationString, ?array $columnsToSelect = null): selfsetOrdering(Ordering $ordering): selfsetRawOrdering(?RawOrdering $rawOrdering): selfprocess(?DataTableParams $params = null, ?callable $callbackBeforePaginate = null): selfadvancedSearch(callable $callback): selfgetData(): CollectiongetPaginator(): PaginatorgetBuilder(): BuildergetColumnByKey(string $key): ?Column
Column classes
| Class | Purpose |
|---|---|
Column |
Plain column (key, label, searchable, orderable…) |
RelationColumn |
Dot-notated relation column ('user.name') |
TranslatableColumn |
Pulls value from the configured translations table |
EnumColumn |
Internal — registered via setEnumColumn |
PriceColumn |
Internal — registered via setPriceColumn |
DateColumn |
Internal — registered via setDateColumn |
Repository layout
laravel-data-table/
├── composer.json # PHP package manifest
├── package.json # NPM package manifest
├── tsconfig.json
├── src/ # PHP source
│ ├── DataTable.php
│ ├── DataTableConfig.php
│ ├── DataTableParams.php
│ ├── DataTableServiceProvider.php
│ ├── ColumnFilter.php
│ ├── Columns/
│ ├── Exceptions/
│ ├── Support/
│ └── config/data-table.php
├── stubs/shadcn/ # shadcn-vue UI, published with --tag=data-table-shadcn
└── resources/
└── js/ # Vue / TypeScript source
├── index.ts
├── install.ts
├── config.ts
├── Table.vue
├── tanstack/ # useLaravelDataTable (npm: @givanov95/vue-data-table/tanstack)
├── components/
├── icons/
├── types/
└── utils/
License
MIT
Related Packages
Vue 3 components for Forjed Inertia Table - backend-driven dynamic tables for La...
Vue 3 + Inertia.js data table utilities with Laravel backend integration.
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 3.5.1 | ^3.3.0 | MIT | |
| 3.5.0 | ^3.3.0 | MIT | |
| 3.4.0 | ^3.3.0 | MIT | |
| 3.3.0 | ^3.2.0 | MIT | |
| 3.0.0 | ^3.2.0 | MIT | |
| 2.0.0 | ^3.2.0 | MIT | |
| 1.0.0 | ^3.2.0 | MIT | |
| 0.2.0 | ^3.2.0 | MIT |