Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Inertiajs Tables Laravel Query Builder Laravel Package

adesin-fr/inertiajs-tables-laravel-query-builder

Build interactive Inertia.js data tables in Laravel with Laravel Query Builder. Supports server-side search, filters, sorting, and pagination, making it easy to create fast, reusable tables for admin panels and apps.

View on GitHub
Deep Wiki
Context7

Getting Started

This package sits between spatie/laravel-query-builder on the backend and a set of Vue table components on the frontend. It gives you sortable headers, a search box, filter dropdowns, per-page selection, and pagination wired to Inertia partial reloads, without you re-implementing the glue each time.

Prerequisites

  • Laravel 10/11 app with Inertia + Vue 3 (React support, if present, is secondary — verify against the README for the fork's exact adapters).
  • spatie/laravel-query-builder installed and familiar to you; this package does not replace it, it renders it.

Install in three steps

composer require adesin-fr/inertiajs-tables-laravel-query-builder
php artisan vendor:publish --provider="AdesinFr\InertiajsTables\InertiajsTablesServiceProvider"
npm install && npm run dev

Then register/publish the Vue components (the fork publishes them into resources/js/Components/Table — check the published path in the README before wiring imports, as forks sometimes change namespaces).

First use case: a Users index page

Controller — the single source of truth for what the table may do:

use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedSort;

public function index()
{
    $users = QueryBuilder::for(User::class)
        ->allowedSorts(['name', 'email', 'created_at'])
        ->allowedFilters([
            AllowedFilter::scope('search'),
            AllowedFilter::exact('status'),
        ])
        ->defaultSort('-created_at')
        ->paginate(request('per_page', 15))
        ->withQueryString();

    return Inertia::render('Users/Index', ['users' => $users]);
}

Model scope for the free-text box:

public function scopeSearch($query, $term): void
{
    $query->where(fn ($q) => $q
        ->where('name', 'like', "%{$term}%")
        ->orWhere('email', 'like', "%{$term}%"));
}

Page:

<script setup>
import { Table, TableHead, TableBody, TableRow, TableCell, TableSearch, TableFilter, TablePagination } from '@/Components/Table'
defineProps({ users: Object })
</script>

<template>
  <Table :resource="users" only="users">
    <template #head>
      <TableSearch />
      <TableFilter name="status" :options="{ active: 'Active', invited: 'Invited' }" />
    </template>
    <TableHead>
      <TableCell name="name" sortable>Name</TableCell>
      <TableCell name="email" sortable>Email</TableCell>
      <TableCell name="created_at" sortable>Joined</TableCell>
    </TableHead>
    <TableBody>
      <TableRow v-for="user in users.data" :key="user.id">
        <TableCell>{{ user.name }}</TableCell>
        <TableCell>{{ user.email }}</TableCell>
        <TableCell>{{ user.created_at }}</TableCell>
      </TableRow>
    </TableBody>
    <TablePagination />
  </Table>
</template>

Where to look first

  1. The README's "Basic usage" example — it is usually the whole API surface in one page.
  2. The published Vue components — they are plain SFCs you can read and fork locally; this is the fastest way to understand what props and events exist in this specific fork.
  3. The controller-side QueryBuilder chain — almost every bug you hit will be a mismatch between what the UI sends and what allowedSorts/allowedFilters permits.

Implementation Patterns

Treat the controller as the contract. The frontend only ever sends sort, filter[...], per_page, and a search term. Everything else — which columns are sortable, which filters exist, the default ordering — lives in allowedSorts/allowedFilters/defaultSort. Reviewers should be able to read the controller and know exactly what the table can do. Never trust the UI to be the limit; QueryBuilder already throws on unlisted params, so keep the lists tight and explicit.

The only prop is the reload channel. The table performs an Inertia partial reload restricted to the props you name. Keep prop names short, stable, and identical between the controller and the component:

<Table :resource="users" only="users">

If you later rename the prop to paginatedUsers, every sort click silently stops updating your rows. This is the single most common failure mode.

Debounce search, preserve state. Search and filter inputs should debounce ~300ms and fire a partial reload rather than a full navigation:

router.reload({
  only: ['users'],
  data: { filter: { search: term } },
  preserveState: true,
  preserveScroll: true,
  replace: true, // keeps the back button sane
})

Using router.get instead of reload will remount the page component and lose focus in the search box mid-typing.

Filter taxonomy. Use AllowedFilter::exact() for enums and foreign keys, AllowedFilter::scope() when the filter needs custom logic (date ranges, trashed, ownedBy), and a callback filter when you need one-off SQL without polluting the model. Keep enum option lists on the backend and pass them as props so the dropdown can't drift from what the filter accepts.

Custom sorts for relationships. Sort on joined columns explicitly, qualified with the table name:

AllowedSort::custom('company', new CompanySort())
// or
->allowedSorts([AllowedSort::custom('company', fn ($q, $dir) =>
    $q->orderBy(Company::select('name')->whereColumn('companies.id', 'users.company_id'), $dir)
)])

Eager-load anything you render (->with('company')) — the table re-renders on every sort and filter, so N+1s amplify fast.

Multiple tables on one page. Namespace the props (users, orders) and pass a distinct only per table. If the fork supports a table key/query-string prefix, use it; otherwise the two tables will share sort and filter params and fight each other.

Reusable index trait. For apps with many CRUD index pages, extract the chain:

trait SortsAndFilters
{
    protected function table(string $model, array $sorts, array $filters)
    {
        return QueryBuilder::for($model)
            ->allowedSorts($sorts)
            ->allowedFilters($filters)
            ->defaultSort('-created_at')
            ->paginate(request('per_page', 15))
            ->withQueryString();
    }
}

Row actions go in slots, not in the package. Delete/edit buttons, status badges, avatars — render them with plain Vue in the TableCell slot. The package is for querying and layout; it should not grow business logic.

Testing. Assert both halves: a feature test hitting ?sort=-name&filter[status]=active against the index route and asserting Inertia props, and a component test (or manual check) that the right column emits the right sort. Spatie's QueryBuilder is easy to test in isolation; the component is the part that breaks silently.

Gotchas and Tips

Invalid query exceptions surface as 400s. QueryBuilder throws InvalidQueryException for unlisted sorts/filters. In local dev that's a useful error; in production it means a stale frontend (cached JS with an old column) can 400 the page. Keep allowedSorts in sync when you rename columns, and consider a small exception handler that logs the offending param.

withQueryString() matters more than you think. Without it, pagination links drop the active sort/filter and clicking page 2 resets the table. Add it once in the trait and never think about it again.

Per-page needs whitelisting too. per_page is just another query param — either validate it (in:10,25,50,100) or pass it through AllowedFilter. Letting arbitrary integers through invites ?per_page=100000.

Ambiguous column errors on joins. orderBy('name') against a join with two name columns throws. Always qualify: users.name. Same for filters used with AllowedFilter::exact on joined tables.

Published Vue components drift. Because the SFCs are published into your app, upgrading the Composer package does not upgrade your copy. After composer update, diff vendor/adesin-fr/inertiajs-tables-laravel-query-builder/resources/js against resources/js/Components/Table and re-publish deliberately. This is the main upgrade tax of the publish-to-app model.

It's a fork — pin it and watch upstream. With 28 stars and a recent release, the fork is maintained but small. Pin the version in composer.json, skim the changelog before bumping, and keep a note of which upstream commits you're relying on. If you hit a bug, it's a one-file Vue fix in your published copy more often than a PR-and-wait situation.

Debounce and disable during flight. Rapid clicks on sortable headers queue overlapping reloads. Disable the header while router.reload is in flight (Inertia exposes onStart/onFinish) or rely on replace: true to at least keep history clean.

Don't render heavy data in cells. Every sort/filter reload transmits the whole page payload. Return only the columns you display; a User::with('posts.comments') resource will make each keystroke in the search box feel slow.

Config caching. If the package ships a config file, php artisan config:cache in deploy scripts will mask local edits. Clear it when debugging "my setting isn't applied."

Extension points, in order of preference: (1) props and slots on the existing components, (2) a locally forked TableCell/TableFilter SFC, (3) wrapping the whole table in your own <DataTable> that composes the package's pieces. Avoid patching vendor files — with a small fork, your changes will be lost on the next composer update.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource