inertify / table
Headless tables for Laravel + Vue + Inertia with pagination, sorting, and filtering.
Requires
- php: ^8.2
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- inertiajs/inertia-laravel: ^2.0
- spatie/laravel-query-builder: ^7.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0
README
Headless table tooling for Laravel + Inertia + Vue with:
- Pagination
- Sorting
- Filtering
- Multi-table support on one page (query keys are table-scoped)
- No UI opinions (you render your own markup)
Install
composer require inertify/table
If you publish config:
php artisan vendor:publish --tag=inertify/table-config
Optional Vue package build output:
npm install @inertify/table-vue
Publish to Packagist
- Make sure your package name in
composer.jsonis final (already set toinertify/table). - Commit and push
mainto a public Git repository. - Create a semantic version tag and push it:
git tag v1.0.0 git push origin v1.0.0
- Sign in to Packagist, click Submit, and paste your repository URL.
- In repository settings (GitHub/GitLab), add Packagist webhook so updates happen automatically.
- After indexing, install with Composer:
composer require inertify/table
Recommended release flow
- Merge changes to
main - Run tests (
composer test) - Tag release (
vX.Y.Z) - Push tag (
git push origin vX.Y.Z) - Verify package page on Packagist
Publish Vue package to npm (automated)
This repository includes GitHub Actions workflow at .github/workflows/publish-npm.yml.
It publishes @inertify/table-vue when you push a tag v*.
One-time setup
- In npm org
inertify, create a granular access token with publish permissions for@inertify/table-vueand 2FA bypass for automation. - In GitHub repo settings, add secret
NPM_TOKENwith that token value.
Release commands
npm version patch git push origin main --follow-tags
The workflow validates that tag version matches package.json version, builds package, and publishes to npm.
Laravel API
use Inertia\Inertia; use App\Models\User; use Inertify\Table\Column; use Inertify\Table\Filter; use Inertify\Table\Table; public function index() { $table = Table::make('users') ->columns([ Column::make('name'), Column::make('email'), Column::make('role'), Column::make('created_at')->type('date'), ]) ->sorts(['name', 'email', 'created_at']) ->filters(['name', 'email', 'created_at']) ->defaultSort('-created_at'); return Inertia::render('Users/Index', [ ...$table->payload( query: User::query(), rowsKey: 'users', metaKey: 'meta' ), ]); }
Filter inference from column type
When filters([...]) receives a string key, the package infers the filter type from Column::type(...):
number/int/float/decimal=>Filter::numberRange(...)date/datetime/timestamp=>Filter::dateRange(...)boolean/bool=>Filter::exact(...)- everything else =>
Filter::partial(...)
Use explicit Filter::... entries in filters([...]) when you need custom behavior (for example Filter::select(...) with options or callback filters).
Inertia macro shortcut
The package registers a Inertia::tablePayload(...) macro:
return Inertia::render('Users/Index', [ ...Inertia::tablePayload( name: 'users', query: User::query(), configure: fn ($table) => $table ->sorts(['name', 'email']) ->filters([Filter::partial('name')]), rowsKey: 'users', metaKey: 'meta', ), ]);
Vue Headless API
Composables-first (recommended)
import { useTable, useTableFilters, useTableSorting, useTablePagination, useTableSelection, } from "@inertify/table-vue"; const table = useTable(props.meta, { only: ["users", "meta"], }); const filters = useTableFilters(table); const sorting = useTableSorting(table); const pagination = useTablePagination(table); const selection = useTableSelection(table);
Each composable also supports inject fallback when used inside HeadlessTableProvider:
const filters = useTableFilters(); const sorting = useTableSorting(); const pagination = useTablePagination(); const selection = useTableSelection();
Provider/inject (optional)
<script setup lang="ts"> import { HeadlessTableProvider, HeadlessTableFilters, HeadlessTableSorting, HeadlessTablePagination, } from "@inertify/table-vue"; defineProps<{ meta: any }>(); </script> <template> <HeadlessTableProvider :meta="meta" :only="['users', 'meta']"> <HeadlessTableFilters v-slot="{ filters, getFilterValue, setFilterValue, applyFilters }" > <!-- Render your inputs/selects from filters metadata --> </HeadlessTableFilters> <HeadlessTableSorting v-slot="{ toggleSort, isSortedBy, activeDirection }"> <!-- Render sortable headers --> </HeadlessTableSorting> <HeadlessTablePagination v-slot="{ page, lastPage, previous, next, setPerPage, perPageOptions }" > <!-- Render pager controls --> </HeadlessTablePagination> </HeadlessTableProvider> </template>
Direct table API
import { useTable } from "@inertify/table-vue"; const table = useTable(props.meta, { only: ["users", "meta"], }); table.toggleSort("name"); table.setFilter("role", "admin"); table.visit(); table.toggleRowSelected(1); table.areAllRowsSelected([1, 2, 3]); table.clearSelection();
Row selection
Use HeadlessTableSelection for renderless row-selection state and helpers:
<HeadlessTableSelection v-slot="{ isRowSelected, toggleRowSelected, toggleAllRowsSelected, selectionCount, }" > <!-- Use these helpers to build checkbox/select-all UI --> </HeadlessTableSelection>
Selection state is client-side and automatically clears when table meta is refreshed.
Column-based head/cell rendering
HeadlessTableHeads and HeadlessTableCells support slot overrides by:
- Column name:
column-{key}(example:column-created_at) - Column type:
type-{type}(example:type-date,type-number)
Precedence is: column-name slot → type slot → default slot.
Column type is resolved from column.meta.type first, then inferred from filter input (date-range => date, number-range => number).
Renderless components
HeadlessTable and HeadlessPagination expose slot props only, so you can build any UI design system.
<HeadlessTable :meta="meta" v-slot="{ state, toggleSort, setFilter, visit }"> <button @click="toggleSort('name')">Sort by name</button> <input :value="state.filters.name ?? ''" @input="setFilter('name', $event.target.value, { submit: false })" /> <button @click="visit()">Apply</button> </HeadlessTable>
Example app (shadcn-vue)
A complete usage example with Laravel + Inertia + shadcn-vue components is available in:
examples/laravel-vue-shadcn
Query format
For table name users, the default query keys are:
users_pageusers_per_pageusers_sort(nameor-name)users_filters[name]=...
Range filters use nested from / to values:
users_filters[age][from]=18users_filters[age][to]=65users_filters[created_at][from]=2026-01-01users_filters[created_at][to]=2026-01-31
Filter::numberRange(...) and Filter::dateRange(...) apply inclusive bounds (>= from, <= to).
Customize this in config/inertify-table.php.