@givanov95/vue-data-table

Vue 3 + Inertia data-table components and a TanStack Table composable for givanov95/laravel-data-table.
812
Install
npm install @givanov95/vue-data-table
Latest Version:3.5.1
Vue:3
License:MIT
Last Updated:Oct 27, 2026
Links: GitHub  ·  npm
Maintainer: givanov95

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=-1 is not "all rows" — it gets the default page size. Export all rows server-side instead.
  • A max_per_page below 1 is treated as a misconfiguration and falls back to 50; it does not turn the bound off.
  • If your frontend offers perPageOptions larger than 100, raise max_per_page accordingly. An option above max_per_page cannot be honoured: the server answers with max_per_page rows, and the per-page select shows the page size actually applied (as Default (100), the label of the "Default" entry carrying the number) instead of the option that was chosen.
  • A DataTableParams you build and pass to process() 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): self
  • setRelationColumn(RelationColumn $column): self
  • setTranslatableColumn(TranslatableColumn $column): self
  • setEnumColumn(string $key, class-string<\BackedEnum> $enumClass): self
  • setPriceColumn(string $key): self
  • setDateColumn(string $key, string $format, string $dateDelimiter = '.', string $timeDelimiter = ':'): self
  • setRelation(string $relationString, ?array $columnsToSelect = null): self
  • setOrdering(Ordering $ordering): self
  • setRawOrdering(?RawOrdering $rawOrdering): self
  • process(?DataTableParams $params = null, ?callable $callbackBeforePaginate = null): self
  • advancedSearch(callable $callback): self
  • getData(): Collection
  • getPaginator(): Paginator
  • getBuilder(): Builder
  • getColumnByKey(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

@forjedio/inertia-table-vue

Vue 3 components for Forjed Inertia Table - backend-driven dynamic tables for La...

422 31
vue3-laravel-datatable

yajra datatables for laravel with novinvision/laravel-datatable package

0 0
@starter-solutions/inertia-data-table-vue

Vue 3 + Inertia.js data table utilities with Laravel backend integration.

3,742 2
laravel-precognition-vue-inertia

Laravel Precognition (Vue w/ Inertia).

342,609 175
momentum-layout

A Vue 3 compiler macro to define persistent layouts in Inertia.js apps

9,916 33

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