spindle.by jsj
spindle/table

Getting started

Install spindle/table, describe a table in a controller, and render it with one Svelte component.

Spindle Table describes a table in fluent PHP and delivers it to the page as an ordinary Inertia prop. A framework-independent TypeScript controller and an optional Svelte 5 UI read that prop through one versioned contract. There are no table routes: sorting, filtering and paging are partial reloads of the page, and mutations post back to the page that rendered the table.

The prebuilt Svelte table showing projects with status badges, owners, budgets and due dates

Packages

Package Purpose
spindle/table Laravel query, schema, authorization, mutation and saved-view layer
@spindle/table-core Headless state, selection, view handling, formatting and Inertia navigation; zero runtime dependencies
@spindle/table-svelte Svelte adapter and accessible prebuilt components built on shadcn-svelte/bits-ui and Tailwind CSS v4

Requirements

  • PHP 8.3+ and Laravel 12 or 13
  • Inertia Laravel 3.4+, Inertia Svelte 3 and Svelte 5
  • Tailwind CSS v4 for the prebuilt components

Vue and React adapters are future additions. The core has no Svelte dependency, so a different renderer can sit on the same contract.

Installation

Not on the registries yet. Spindle Table is in development and has not been published to Packagist or npm. Until it is, install it from a checkout of the source as described below.

Once published, installation will be:

composer require spindle/table
npm install @spindle/table-core @spindle/table-svelte

From a checkout

Add a Composer path repository to your application, using the actual checkout path:

{
  "repositories": [{ "type": "path", "url": "../table" }],
  "require": { "spindle/table": "@dev" }
}

Run composer update spindle/table. Laravel discovers Spindle\Table\TableServiceProvider automatically. Build the frontend with npm ci && npm run build in the checkout. Run npm pack --workspace @spindle/table-core and npm pack --workspace @spindle/table-svelte; install both generated tarballs in the application with npm install /absolute/path/core.tgz /absolute/path/svelte.tgz. The application must already use Svelte 5 and @inertiajs/svelte 3.

Saved views

Saved views are optional. Publish and run their migration if you enable them:

php artisan vendor:publish --tag=spindle-table-migrations
php artisan migrate
# Optional defaults:
php artisan vendor:publish --tag=spindle-table-config

Do not publish the migration twice. It intentionally has no foreign key to a particular user model; numeric, UUID and ULID identifiers are supported. Delete saved-view rows when deleting their owner if your retention rules require that.

Controller

Use one table factory for reads and writes, rebuilding the same query scope on every request. The route parameter or authenticated user is the source of the tenant; do not trust a tenant ID from the table state.

namespace App\Http\Controllers;

use App\Models\Project;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spindle\Table\Actions\EditAction;
use Spindle\Table\Columns\TextColumn;
use Spindle\Table\Filters\SelectFilter;
use Spindle\Table\Table;
use Spindle\Table\Views\PresetView;

class ProjectController extends Controller
{
    private function table(Request $request): Table
    {
        $team = $request->user()->team_id;

        return Table::make('projects')
            ->query(Project::query()->where('team_id', $team))
            ->columns([
                TextColumn::make('name')->searchable()->sortable(),
                TextColumn::make('status')->badge(),
                TextColumn::make('budget')->money('USD')->sortable(),
            ])
            ->filters([
                SelectFilter::make('status')->options([
                    'active' => 'Active', 'completed' => 'Completed',
                ]),
            ])
            ->recordActions([
                EditAction::make()->rules(['name' => ['required', 'string', 'max:100']])
                    ->data(fn ($record) => ['name' => $record->name])->handler('projectEditor'),
            ])
            ->presetViews([
                PresetView::make('active')->state(['filters' => ['status' => 'active']]),
            ])
            ->savedViews('team:'.$team);
    }

    public function index(Request $request)
    {
        $this->authorize('viewAny', Project::class);
        return Inertia::render('Projects/Index', ['projects' => $this->table($request)]);
    }
}

The controller example assumes the usual AuthorizesRequests trait in your application controller. Alternatively use Gate::authorize().

Route::middleware('auth')->group(function () {
    Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
});

That is the only route

Table mutations (actions, bulk actions, inline edits, reordering, saved views and background-job controls) POST to the page’s own URL with an X-Spindle-Table header. The package’s global middleware (RouteTableMutations) dispatches that POST to the page’s GET route, and RestoreTableMutationMethod (first in the web group) turns it back into a POST once the route has matched, so the page’s middleware, gates and controller run as for a normal visit while Laravel’s CSRF check and Inertia’s mutation semantics (no asset-version 409) still apply.

When the controller builds the table prop, the table executes the operation after verifying the CSRF token, then the page renders fresh props in the same Inertia response. Custom action responses (redirects, downloads) replace the page response; Laravel validation errors use the normal Inertia error bag. The page controller never sees mutation input.

Set 'route_mutations' => false in config/spindle-table.php to disable the middleware. ->endpoint(url) plus a POST route calling $table->handle($request) remains supported for tables that must mutate through a separate URL. Define the model policies used by built-in actions.

Svelte page

<script lang="ts">
  import { Table } from '@spindle/table-svelte';
  import type { TablePayload } from '@spindle/table-core';
  let { projects }: { projects: TablePayload } = $props();
</script>

<Table table={projects} prop="projects" />

Add the theme to the stylesheet that loads Tailwind CSS v4. It declares the --st-* variables, binds them to Tailwind theme colors and registers the package components with @source:

@import "tailwindcss";
@import "@spindle/table-svelte/theme.css";

The theme reads your shadcn tokens (--background, --foreground, --primary, --border, --muted-foreground, --popover, --destructive) when they exist and falls back to a neutral Apple-like palette. Dark mode follows a .dark class or data-theme="dark" on an ancestor, like shadcn.

prop is the exact Inertia prop key. The PHP table name controls URL state and pagination parameters; use distinct names for multiple tables on one page. Changing a controller’s prop key after mounting requires remounting the component.

Register a frontend projectEditor handler for the edit action. See frontend-owned actions for a complete integration. The package does not generate forms from PHP.

Where next

  • Tables: pagination, search, state, grouping and performance.
  • Columns and Filters: what goes in the table.
  • Actions: row, bulk and toolbar actions, with your own UI.
  • Frontend: styling, keyboard, accessibility and the headless core.
  • Coming from Filament: how the feature set maps across.