power-components / turbine
Headless, UI-agnostic table engine for Laravel: describe the grid in PHP, get JSON for Inertia, Livewire, REST or plain AJAX.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/pagination: ^12.0 || ^13.0
- illuminate/pipeline: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.29
- laravel/scout: ^11.3
- openspout/openspout: ^4.0 || ^5.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0 || ^5.0
Suggests
- laravel/scout: Required to use Laravel Scout as a datasource.
- openspout/openspout: Required to export XLS and CSV (supports v4 and v5)
README
The framework-agnostic data engine behind the Turbine table component.
It runs search, filters, sort, pagination, and row transformations over Eloquent, Query Builder, Collections, or Scout, and returns a typed JSON envelope. There is no Blade and no JavaScript in the box — feed Inertia (React / Vue), Livewire, REST APIs, or plain AJAX with the exact same engine.
You describe the grid once in PHP. The engine does the rest. Your front-end just renders JSON.
Contents
- Requirements & Install
- How it fits together
- Quickstart
- Portable Grid Definitions
- The Request Contract
- The Response Envelope
- Actions & Rules
- Filters
- Datasources
- Exporting Data
- State Persistence
- Reusing Components & Low-Level API
- Credits
- License
Requirements & Install
- PHP 8.3+
- Laravel 12 / 13 (
illuminate/*components)
composer require power-components/turbine
How it fits together
query params (state)
Front-end ───────────────────────────────▶ Turbine (PHP definition)
(React / Vue / │
Livewire / AJAX) ▼
Turbine engine
search · filter · sort · paginate
│
JSON envelope { data, meta, columns, filters, actions }
Front-end ◀───────────────────────────────────────┘
renders table
The front-end owns rendering and interaction; the core owns data and rules. State travels in the request; results travel in the envelope.
Quickstart
Describe your grid in PHP using the Turbine builder:
use PowerComponents\Turbine\{Turbine, Column, Fields, Button}; use PowerComponents\Turbine\Components\Filters\FilterInputText; class UserGridController { public function __invoke(Request $request) { /** @var \PowerComponents\Turbine\Response\GridResponse $envelope */ $envelope = Turbine::make() ->datasource(fn () => User::query()) ->fields( (new Fields()) ->add('id') ->add('name') ->add('email') ) ->columns([ Column::make('ID', 'id')->sortable(), Column::make('Name', 'name')->searchable()->sortable(), Column::make('Email', 'email')->searchable()->sortable(), ]) ->filters([ new FilterInputText('name'), ]) ->actions(fn (User $user) => [ Button::add('edit')->slot('Edit')->route('users.edit', ['user' => $user->id]), ]) ->fromRequest($request) ->envelope(); // typed GridResponse — or ->toArray() for plain array, ->toResponse() for JsonResponse // Typed access // $envelope->data; // array<int, array<string, mixed>> // $envelope->meta->pagination; // PaginationResponse // $envelope->meta->sort; // SortResponse // $envelope->meta->search; // ?string // $envelope->columns; // list<ColumnSchema> // $envelope->filters; // ?list<FilterSchema> // $envelope->actions; // ?array<string, list<ActionDescriptor>> } }
// routes/web.php or routes/api.php Route::get('/users/grid', UserGridController::class);
Builder reference
| Method | Description |
|---|---|
datasource(Closure) |
Returns Eloquent Builder, Scout Builder, or Collection. Required. |
fields(Fields) |
Maps row shape and output keys. |
columns(array) |
List of Column instances for schema, sorting, and search. |
filters(array) |
List of Filter* components. |
actions(Closure) |
fn ($row) => Button[] — per-row actions. |
actionRules(Closure) |
fn ($row) => Rule[] — conditional rules per row/action/cell. |
relationSearch(array) |
Search across relations, e.g. ['category' => ['name']]. |
fromRequest(Request) |
Reads state from the turbine request parameter. |
setUp(array) |
List of SetUp components (Header, Footer, Detail, Exportable, …) serialized under meta.setup. |
state(array) |
Sets state from a raw array (Inertia JSON body, tests, etc.). |
toArray() / envelope() / toResponse() |
Returns array, GridResponse, or JsonResponse. |
Portable Grid Definitions
The builder is fluent, but you can also describe a grid as a single framework-neutral class by extending GridDefinition. The same class drives an Inertia controller, a REST endpoint, or a Livewire PowerGrid component — write it once, switch front-ends without rewriting the grid.
use PowerComponents\Turbine\{GridDefinition, Column, Fields, Turbine}; class UsersGrid extends GridDefinition { public string $tableName = 'users'; public int $perPage = 10; public function datasource(): mixed { return User::query(); } public function fields(): Fields { return Fields::make()->add('id')->add('name')->add('email'); } public function columns(): array { return [ Column::make('ID', 'id')->sortable(), Column::make('Name', 'name')->searchable()->sortable(), ]; } public function setUp(): array { return [Turbine::footer()->showPerPage(10, [10, 25, 50])]; } }
Build fields and SetUp components with the static factories — Fields::make() and Turbine::header() / footer() / detail() / exportable($file) / cache() / filterBuilder() / responsive(). These are the same construction calls the Livewire PowerGrid facade exposes (PowerGrid::footer()), so a definition reads identically on both stacks.
Consume it from an Inertia controller:
public function __invoke(Request $request) { $grid = new UsersGrid(); return Inertia::render('users', [ 'columns' => fn () => $grid->columns(), 'grid' => fn () => $grid->envelope($request), ]); }
Every builder option has a matching overridable method — datasource, fields, columns, filters, actions, actionRules, relationSearch, searchMorphs, transformQuery, setUp — plus the $tableName, $primaryKey, $perPage, $pageName properties. envelope($request), toArray($request), toResponse($request) and context($request) (for exporting) feed the request in for you. Only datasource() is required.
The class implements PowerComponents\Turbine\Contracts\GridSchema, the shared declaration surface both Turbine and Livewire PowerGrid understand.
Migrating between Livewire and Inertia? Keep the
UsersGridclass untouched and swap only the adapter. Inertia calls->envelope($request)(or->toArray($request)for a plain array); a Livewire PowerGrid component points itsdefinition()at the same class (see the PowerGrid README). Columns, fields, filters, actions and setUp are identical on both sides — the difference is purely the wiring.
The Request Contract
Grid state (search, sort, filters) is read from the turbine query parameter on the incoming request. Pagination uses the standard Laravel page parameter.
The Response Envelope
->envelope() returns a GridResponse DTO. ->toArray() returns a plain array. ->toResponse() wraps it in a JsonResponse. All properties are typed:
{
"data": [
{ "id": 1, "name": "Ana", "email": "ana@acme.test" }
],
"meta": {
"pagination": { "current_page": 1, "per_page": 15, "total": 84, "last_page": 6 },
"sort": { "field": "name", "direction": "desc" },
"search": "ana",
"filters": { "input_text": { "name": "ana" } },
"setup": { "footer": { "name": "footer", "perPage": 10, "perPageValues": [10, 25, 50], "pageName": "page" } }
},
"columns": [
{ "field": "name", "title": "Name", "sortable": true, "searchable": true, "hidden": false }
],
"filters": [
{ "key": "input_text", "field": "name", "column": "name", "title": null }
],
"actions": {
"1": [
{
"id": "edit",
"label": "Edit",
"icon": null,
"tag": "button",
"visible": true,
"disabled": false,
"attributes": {
"event": { "type": "link", "href": "/users/1/edit" }
}
}
]
}
}
DTO classes
| Class | Key properties |
|---|---|
GridResponse |
data, meta, columns, filters, actions |
MetaResponse |
pagination, sort, search, filters, filterBuilder, setup |
PaginationResponse |
currentPage, perPage, from, to, total, lastPage |
SortResponse |
field, direction, multiSort, sortArray |
ColumnSchema |
field, title, sortable, searchable, hidden |
FilterSchema |
key, field, column, title |
ActionDescriptor |
id, label, icon, tag, visible, disabled, attributes |
Every DTO implements JsonSerializable and exposes ->all() for array access.
Actions & Rules
Actions use the Button DSL and resolve on the server:
Button::add('edit')->slot('Edit')->route('users.edit', ['user' => $user->id]); Button::add('delete')->slot('Delete')->dispatch('deleteUser', ['id' => $user->id])->confirm('Are you sure?');
The ActionDescriptor resolves into the attributes bag:
$descriptor = $actions[0]; $descriptor->id; // 'edit' $descriptor->label; // 'Edit' $descriptor->visible; // true $descriptor->attributes['event']; // ['type' => 'dispatch', 'event' => 'deleteUser', 'params' => ['id' => 1]] $descriptor->attributes['wire:confirm']; // 'Are you sure?' (Livewire) $descriptor->attributes['wire:click']; // '$dispatch(...)' (Livewire)
Event types: link, dispatch, dispatchTo, dispatchSelf, modal, toggleDetail, call.
Conditional Rules
Apply server-side rules per row, button, or cell:
use PowerComponents\Turbine\Components\Rules\{RuleActions, RuleRows, RuleCheckbox, RuleToggleable}; Turbine::make() ->actionRules(fn (User $user) => [ // Hide delete action for admins (new RuleActions('delete'))->when(fn ($u) => $u->is_admin)->hide(), // Highlight admin rows (new RuleRows())->when(fn ($u) => $u->is_admin)->setAttribute('class', 'bg-blue-50'), // Disable checkboxes for system users (new RuleCheckbox())->when(fn ($u) => $u->is_system)->disable(), ]);
Supported rule target classes: RuleActions, RuleRows, RuleCheckbox, RuleRadio, RuleToggleable, RuleEditOnClick.
Filters
Turbine provides built-in filter components in PowerComponents\Turbine\Components\Filters\*:
| Filter Class | Request Key | Description |
|---|---|---|
FilterInputText |
input_text |
Text matching (contains, starts_with, exact, etc.) |
FilterSelect / FilterEnumSelect |
select |
Single choice from collection, array, or PHP Enum |
FilterMultiSelect / FilterMultiSelectAsync |
multi_select |
Multiple choices in-memory or from async endpoint |
FilterBoolean |
boolean |
True / false toggle |
FilterNumber |
number |
Numeric range filter |
FilterDatePicker / FilterDateTimePicker |
date / datetime |
Date and date-time range filters |
FilterDynamic |
custom |
Custom front-end component props |
// Standard filter usage new FilterSelect('status')->dataSource(UserStatusEnum::cases()); // Custom query logic callback new FilterInputText('title')->builder(fn ($query, $value) => $query->whereRaw('LOWER(title) LIKE ?', ["%{$value}%"]));
Datasources
Pass any supported data source to datasource():
// Eloquent Query Builder or Model Turbine::make()->datasource(fn () => User::query()); // Laravel Scout Turbine::make()->datasource(fn () => User::search($term)); // Array or Collection Turbine::make()->datasource(fn () => collect([['id' => 1, 'name' => 'Alice']]));
Custom Datasources
Implement DataSourceProcessor to handle custom APIs or repositories:
use PowerComponents\Turbine\Contracts\DataSourceProcessor; use PowerComponents\Turbine\DataSource\Processors\DataSourceBase; class CustomApiProcessor extends DataSourceBase implements DataSourceProcessor { public static function match(mixed $datasource): bool => $datasource instanceof MyCustomClient; public function process(array $properties = [], mixed $datasource = null): array { // Fetch data, return length-aware paginator in results return ['results' => $paginator, 'actionsByRow' => []]; } } // Register globally Turbine::registerDataSource(CustomApiProcessor::class);
Exporting Data
Generate CSV or Excel (.xlsx) files directly from your grid context:
composer require openspout/openspout # Optional: required for XLSX exports
use PowerComponents\Turbine\Export\ExportEngine; $filePath = app(ExportEngine::class)->build( context: $turbine->context(), exportType: 'xlsx', // 'xlsx' or 'csv' fileName: 'users_export' ); return response()->download($filePath)->deleteFileAfterSend(true);
State Persistence
Save grid state (filters, sorting, column visibility) across requests:
use PowerComponents\Turbine\Support\State\StatePersister; $persister = new StatePersister(); // Save state to Cookie, Session, or Cache $persister->serializeState(['columns', 'filters', 'sorting'], 'users', $stateArray); // Restore saved state $savedState = $persister->getPersistedState('users');
Reusing Components & Low-Level API
Re-use an existing Turbine component as a data engine endpoint:
return (new UserTable())->toDataResponse($request);
Or drop down to low-level context primitives:
use PowerComponents\Turbine\Response; use PowerComponents\Turbine\Support\State\{ArrayGridContext, State}; $context = new ArrayGridContext( state: State::fromRequest($request), datasourceResolver: fn () => User::query(), fields: $fields, columns: $columns, ); /** @var \PowerComponents\Turbine\Response\GridResponse $envelope */ $envelope = Response::make($context)->envelope();
Credits
Originally extracted from versions 6.x and 7.x of Livewire PowerGrid. Special thanks to all contributors!
License
MIT