Search by

givanov95 / laravel-data-table

givanov95

A server-side DataTable builder for Laravel with a matching Vue 3 + Inertia frontend.

Package info

github.com/givanov95/laravel-data-table

pkg:composer/givanov95/laravel-data-table

Statistics

Installs: 1 243

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 1

v4.2.1 2026-10-28 13:28 UTC

README

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_relation'      => 'translations',
    'translatable_column'        => 'key',
    'translatable_locale_column' => 'locale',
    'translatable_text_column'   => 'text',

    'global_filter' => 'filter.global',
    'per_page'      => 'perPage',
    'trashed'       => 'filter.trashed',
    'ordering'      => 'ordering',
    'time_zone'     => null,   // null: next to global_filter (filter.timeZone)
    'column_filters' => 'filter', // filter[status]=paid

    '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 (trashed only when the table calls allowTrashed()). 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.

Filters by column take 'filter' in columns(): 'text' (contains), 'exact' (one or several values, never one the column cannot hold), 'select' (with 'options', as for setFilterOptions()) or 'date' (a range of calendar days, in the server's zone). They are applied by the table, not by Spatie, so filter[title]=Smith, John stays one value.

->columns([
    ['key' => 'status', 'label' => __('Status'), 'filter' => 'select', 'options' => ['active' => __('Active'), 'blocked' => __('Blocked')]],
    ['key' => 'created_at', 'label' => __('Created'), 'filter' => 'date'],
])

allowTrashed(ability: 'viewArchive') shows the soft-deleted rows only to users Gate::allows('viewArchive', User::class); for the rest, and for a model without SoftDeletes, the trashed toggle is accepted and ignored (no 400, no 500).

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). Without ?perPage the page size is data-table.default_per_page, like in DataTable; ->defaultPerPage(25) overrides it for a single table.

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)
);

Ordering joins each relation of the path, so an orderable relation column takes BelongsTo, HasOne and MorphOne relations (keys and morph class from the relation). Any other relation (HasMany, BelongsToMany, MorphTo, a …OfMany()) would repeat rows, so setRelationColumn() throws UnsupportedRelationException for an orderable column on it. A searchable-only column takes any relation (it is a whereHas).

Translatable columns

Designed to plug into the common translations morphMany pattern (translatable_id, translatable_type, locale, key, text). The relation and the key / locale / text columns are configurable (translatable_*); the table, the model's key and the morph class (a morph map alias, too) come from the relation. The translations the columns show are loaded with the page (only their keys and locales), unless the query already loads the relation (with('translations'), setRelation('translations')):

use Givanov95\DataTable\Columns\TranslatableColumn;

$table->setTranslatableColumn(
    new TranslatableColumn(
        locale: app()->getLocale(),
        translationKey: 'title',
        label: __('Title'),
        searchable: true,
        orderable: true,
    )
);

fallbackLocales lists languages tried in order when a row has no text (or an empty one) in locale, e.g. an admin list that also shows records not yet translated into the admin's language. Enums are taken as their value:

new TranslatableColumn(Locale::Bg, 'title', __('Title'), searchable: true, orderable: true,
    fallbackLocales: [Locale::En, Locale::De]);
  • The row shows the first language with a text that is not empty. A language the column does not list (or its enum cast does not know) is skipped, without an error.
  • Search, global and by column, looks at the text in every language of the column.
  • When the query loads the translations itself (with('translations')), that load must include the fallback languages, or a row cannot show them.
  • Ordering is by the shown text. Rows without one (or with an empty text) come last in both directions, the same on MySQL, PostgreSQL and SQLite; this holds for a column without fallbacks too.

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');

The search box takes a date column as d.m.Y, d.m, H:i, H:i:s, d.m.Y H:i or d.m.Y H:i:s (with the column's delimiters). The composable sends the browser's time zone with the search (time_zone key), and for a column whose format has a time (H), what the user types is read in that zone:

  • d.m.Y finds the rows of that day in the user's zone (a row at 23:30 UTC is found by the next day's date in Sofia), d.m.Y H:i the rows of that minute. These compare the stored value (>= / <), on every driver.
  • H:i / H:i:s are moved to the server's zone with today's offset.
  • d.m (no year) and the columns without a time in their format are compared as typed, in the server's zone.

The server's zone is app.timezone; the stored values are taken to be wall-clock times in it. A zone PHP does not know (a typo, or an old alias such as Europe/Kiev on some PHP builds) is ignored: the server's zone is used.

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)

An exactMatch column (and an exact price) never hands the database a value its type rejects: PostgreSQL fails the whole query on id = 'abc', where MySQL and SQLite find nothing. A term the column cannot hold matches nothing; any other is the usual typed comparison, so results and indexes are unchanged.

  • On PostgreSQL the column's real type decides, read from the schema once per table and process: integers by width, numbers, booleans (t, 1, yes…), uuids, and ISO dates and times (2026-10-28, 2026-10-28 10:00). A type not known here (jsonb, an array…) is compared as text.
  • Elsewhere a column numeric by its cast (int, decimal:2, float…, or an integer key) takes only numbers (an integer within 32 bits).

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'));

Filters by column

A column declared filterable takes its own filter from the request, next to the search: filter[<key>] (the parameter is column_filters). The filters AND with each other and with the search. The kind of filter follows the column:

$table
    // A select of the enum's cases: by value, labelled by the case's label() when it has one.
    ->setColumn('status', __('Status'), filterable: true)
    ->setEnumColumn('status', BookingStatus::class)

    // A select of the app's options: a list of ['value' => …, 'label' => …], a
    // value => label map, or a closure returning either (run once).
    ->setColumn('staff_id', __('Staff'), filterable: true)
    ->setFilterOptions('staff_id', fn () => $business->staff()->pluck('name', 'id')->all())

    // A range of days: filter[starts_at][from]=2026-10-01&filter[starts_at][to]=2026-10-31.
    ->setColumn('starts_at', __('Starts'), orderable: true, filterable: true)
    ->setDateColumn('starts_at', 'd.m.Y H:i')

    // Anything else is a text: contains, or equals with exactMatch.
    ->setRelationColumn(new RelationColumn('customer.name', __('Customer'), filterable: true));
  • Select: one value (filter[status]=paid) or several (filter[status][]=paid&filter[status][]=open). Only offered values are compared; none offered matches nothing.
  • Date range: ISO days, as a date input sends them. Either end may be left out, and to takes the whole day. For a column whose format has a time (H), the days are the user's, in the time zone the composable sends with the search. A date that does not exist is ignored.
  • Text: the same comparison as the search, for the column's type (price, exactMatch, relation, translation).
  • A key that is not a filterable column is ignored. A filterable column cannot be named like another key under the same parameter (global, trashed or timeZone with the defaults), and setColumn() throws if it is.

toArray() describes each column's filter (columns[].filterable, columns[].filter: {type: 'select', options: [{value, label}]}, {type: 'date'} or {type: 'text'}) and gives the applied ones in state.filters.

Archive and restore

The archive (soft-deleted rows) is off unless the table turns it on. Without allowTrashed(), filter[trashed]=true is ignored and the live rows are listed.

$table->allowTrashed();                          // anyone who sees the list
$table->allowTrashed(ability: 'viewArchive');    // Gate::allows('viewArchive', Post::class)

With an ability, users the gate refuses get the live rows (the parameter is ignored, not a 403). A model without SoftDeletes never shows an archive, so calling allowTrashed() on it is harmless. toArray()['state']['trashed'] (and $table->trashed in the default serialization) is what was applied, and state.trashed_allowed ($table->trashedAllowed) whether this user may see the archive at all: the bundled components show the archive toggle only then.

Restoring is a POST route of the app, never a query parameter of the list (a GET that changes data can be triggered from another site). Build the table the same way as for the list and call restore():

// routes/web.php
Route::post('posts/{id}/restore', [PostController::class, 'restore'])->name('posts.restore');

// PostController
public function restore(int $id)
{
    return $this->posts->table()->restore($id, fn ($q) => $q->where('team_id', auth()->user()->team_id));
}

restore($id, $scope) looks the record up through the table's query and $scope (pass the same narrowing as the callback of process()), among the archived rows only, so a record the table does not list is a 404. It then checks the restore policy (authorize('restore', $record)), restores the record and redirects back with a success flash message. It is a 404 when the table has no allowTrashed() or the model has no SoftDeletes, and a 403 when the ability of allowTrashed() refuses.

The app's own rules go in $before, which runs after all those checks, with the record, right before it is restored. It can refuse by throwing, and attributes it sets are saved by the restore in the same write. restoreRecord() does the same and returns the record, leaving the response (and its message) to the app:

$staff = $table->restoreRecord($id, before: function (Staff $staff) use ($business) {
    if (! $business->hasFreeStaffSeat()) {
        throw ValidationException::withMessages(['staff' => __('No free seat on the plan.')]);
    }
});

return back()->with('success', __('Staff member restored.'));

On the frontend, pass the route to the composable (restoreUrl) or to the DataTable component (restore-url); see below.

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="`/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 (needs allowTrashed())
restoreUrl string POST URL (use ?id) of the restore button on archived rows; no button without it
rowKey string Field that fills ?id (default: the model's key from the payload, else id)
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 require givanov95/laravel-data-table:^4.0
npm install @givanov95/vue-data-table@^4.0
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
Clear filters Изчисти филтрите Filters wissen
No options Няма опции Geen opties
:label from :label от :label vanaf
:label to :label до :label tot

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), perPageOptions ([10, 20, 50, 100]; the applied size is always offered) and restoreUrl ('/posts/?id/restore' or (id) => route('posts.restore', id)): onRestore(id, visitOptions?) POSTs there (with the page's own onSuccess / onError / onFinish, if given) and does nothing without it. restoringId holds the row being restored until its POST ends, so a button can show it; a second click meanwhile does nothing. trashedAllowed says whether the server allows this user the archive. 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')).

The filters by column come with the composable: filterColumns (the columns the server filters by, with their kind and options), filters (the values, as the server applied them), setColumnFilter(key, value) (a select or a date range is sent at once, a text waits like the search box; an empty value removes the filter), clearColumnFilters() and hasColumnFilters. Each change goes back to page 1. For TanStack, the same filters are state.columnFilters and onColumnFiltersChange.

ServerDataTable draws them in the toolbar, after the search box: a select is a button with the column's label that opens a list of checkboxes (and shows how many are ticked), a date range is two date fields with the column's label, and a text is a field. "Clear filters" shows while any filter is on. On a narrow screen the toolbar wraps and the date range takes its own line. :show-filters="false" leaves them out, to draw the page's own in #toolbar.

ServerDataTable props beyond the composable's: showTrashed, searchPlaceholder, emptyText, trashedLabel, cellClass (classes on every body cell, e.g. align-top for tall rows) and showFilters (true).

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, bool $filterable = false): self
  • setFilterOptions(string $columnKey, array|Closure $options): 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
  • allowTrashed(bool $allow = true, ?string $ability = null): self
  • restore(int|string $id, ?callable $scope = null): RedirectResponse
  • 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