xelqun / tables
PHP-defined, schema-driven tables for Laravel, Inertia and Vue.
Requires
- php: ^8.4
- ext-intl: *
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
- xelqun/forms: ^0.6
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.3
- pestphp/pest-plugin-laravel: ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-06 13:42:36 UTC
README
Describe a table in PHP and generic Vue components render it. Normal tables need no Vue at all.
Pre-alpha: the API will change. Requires Laravel 13, Inertia 3, Vue 3.5, Tailwind 4 and
xelqun/forms.
Example
class ProductsTable extends Table { protected function boot(): void { $this->defaultSort('name')->paginationPageOptions([10, 25, 50]); } public function query(): Builder { return Product::query()->where('team_id', $this->context['team']); } public function columns(): array { return [ TextColumn::make('name')->searchable()->sortable(), TextColumn::make('category.name')->searchable()->sortable(), TextColumn::make('status')->badge()->color(fn (string $state): string => $state === 'active' ? 'success' : 'gray'), TextColumn::make('price')->money('USD')->sortable(), ]; } public function filters(): array { return [SelectFilter::make('category')->relationship('category', 'name')]; } public function recordActions(): array { return [ Action::make('archive') ->authorize('update') ->requiresConfirmation() ->action(fn (Product $record) => $record->archive()), ]; } public function bulkActions(): array { return [ BulkAction::make('delete') ->authorize('delete') ->action(fn (Collection $records) => $records->each->delete()), ]; } }
Features
- Write PHP, not Vue. Normal tables need no frontend code.
- Search and sort through relations. Relations load up front, so rows don't each run a query.
- Filters are form fields. Anything
xelqun/formscan do works in a filter, including dependent options. - Actions reuse forms. Row, header and bulk actions get modals, uploads, live fields and notifications with no new code, and an
ActionGroupputs them in a menu. - Links keep working. Search, sort, filters and the page live in the URL, so a refresh or a shared link shows the same rows.
- Secure by default. Tables rebuild from an encrypted token, and every row comes back through your query on every request.
- Custom columns. A column of your own is a PHP class and a Vue cell.
Installation
Tables is built on forms and uses its styles, so install both:
composer require xelqun/tables php artisan xelqun-forms:install php artisan xelqun-tables:install
The installers add the matching npm packages and import their stylesheets in resources/css/app.css.
Usage
class ProductController { public function index(Request $request) { return Inertia::render('Products/Index', [ 'table' => ProductsTable::make(['team' => $request->user()->team_id])->toSchema(), ]); } }
<script setup lang="ts"> import { XqTable, type TableSchema } from '@xelqun/tables'; defineProps<{ table: TableSchema }>(); </script> <template> <XqTable :table="table" /> </template>
php artisan make:xelqun-table ProductsTable creates a table class.
Plugins
Other packages can add columns, with the same sorting, search and links as built-in ones. A tables plugin is a forms plugin, so install one with php artisan xelqun-forms:plugin acme/sparkline, or build one with the plugin guide.
Before going to production
- Add your rules to
authorize(). It allows everything by default. - Give each action its own
authorize(). An action does nothing without one, and the table'sauthorize()doesn't count. - Scope
query()to what the viewer may see. Every row and every action stays inside it, so this is your main line of defence.
Things to know
query()also runs on the table's own requests, where the page's route parameters are gone. Pass what it needs throughmake($context), as above. The context travels in an encrypted token, so keep it to scalar values.authorize()runs on every request.- A missing
authorize()on an action hides its button. - Closures get values by parameter name. Write
$record,$records,$state,$query,$dataor$direction. Another name gets a value from the container, not your row. $setand$getdo nothing in a table action, because the table has no form state. Use the record.- Don't use
Action::record()in a table. The table already finds the row. - Hidden columns are left out entirely. They aren't sent, searched or sorted.
Current limits
- A list in a cell has one color and icon, and its
color()closure receives the array. %and_in a search act as wildcards.- A relationship
SelectFiltersends every related record. For a long list, use aFilterwith an asyncSelect. - Sorting through a relation works over one
BelongsToorHasOne. Anything else needs a closure. - A column that isn't a real column, such as a
withCount()alias or an accessor, needs a closure to sort or search.
Grouping, summaries, reordering, editable columns and session persistence aren't built yet.
Inspiration
Xelqun Tables is heavily inspired by Filament. I'm a big fan of Filament and have built several packages for it, and this project brings its PHP-first way of building tables to Inertia and Vue.
License
MIT
