Skip to content

Connect an API source

Use a custom source to translate table requests into your API’s format. This catalog example maps field names, nested filters, search, sorting, and pagination.

Inside CatalogSource::fetch(), map the table query to the API’s request fields:

Laravel

CatalogSource::fetch() — request excerpt
$criteria = [
'offset' => $query->offset(),
'limit' => $query->paginationEnabled ? $query->pageSize : null,
'order_by' => array_map(fn (array $sort): array => [
'field' => $this->field($sort['key']), 'direction' => $sort['direction'],
], $query->effectiveSorting()),
'q' => $query->search,
'search_in' => array_map(fn (string $key): string => $this->searchField($key), $query->searchableColumns),
'where' => $this->group($query->filters),
];
$response = $this->api->search($criteria);

The helpers translate field names and filter groups; their implementations are in the complete source below. This example API accepts null as the limit for unpaginated requests.

effectiveSorting() preserves sort precedence and adds the row key when an active sort omits it. With no requested sort it returns an empty list; use a stable default order in your API for numbered pages.

The API returns items and total. Map each item to the table’s column keys:

Laravel

CatalogSource::fetch() — result excerpt
return new TableResult(
rows: array_map(fn (array $item): array => [
'id' => $item['sku'], 'name' => $item['title'],
'price' => $item['unit_price'], 'status' => $item['availability'],
], $response['items']),
total: $response['total'],
);

total counts all matching records before pagination. The API handles search, filters, sorting, and page selection; InertiaX formats the returned cells. See result requirements.

The source maps each { key, clause, value } condition to the API’s { field, comparison, argument }:

Laravel

Example condition mapping
// Table condition:
['key' => 'price', 'clause' => 'between', 'value' => [20, 40]];
// API rule:
['field' => 'unit_price', 'comparison' => 'range', 'argument' => [20, 40]];

group() walks nested groups, translating and to all and or to any. It preserves the grouping and rejects unsupported comparisons and status values before the HTTP request.

Restrict each filter to the comparisons your API supports. This Table obtains its select options from CatalogSource::statusOptions(), an ordinary application method:

app/Tables/CatalogTable.php
<?php
namespace App\Tables;
use App\Sources\CatalogSource;
use InertiaX\Table\Columns\NumberColumn;
use InertiaX\Table\Columns\TextColumn;
use InertiaX\Table\Enums\DataSourceKind;
use InertiaX\Table\Filters\Clauses\Enums\ClauseType;
use InertiaX\Table\Filters\NumberFilter;
use InertiaX\Table\Filters\SelectFilter;
use InertiaX\Table\Filters\TextFilter;
use InertiaX\Table\Table;
class CatalogTable extends Table
{
protected static bool $defaultAutoFilters = false;
protected function data(): CatalogSource
{
return app(CatalogSource::class);
}
protected function columns(): array
{
return [
NumberColumn::make('id', 'ID')->sortable(),
TextColumn::make('name', 'Name')->sortable()->searchable(),
NumberColumn::make('price', 'Price')->sortable(),
TextColumn::make('status', 'Status'),
];
}
protected function filters(): array
{
return [
TextFilter::make('name')->setClauses([ClauseType::Equals, ClauseType::Contains]),
NumberFilter::make('price')->setClauses([ClauseType::Equals, ClauseType::Between]),
SelectFilter::make('status')->options(
fn (CatalogSource $source): array => $source->statusOptions(),
DataSourceKind::Custom,
),
];
}
}

The source translates nested AND/OR groups, so enable advanced filters when returning the Table:

Laravel

use App\Tables\CatalogTable;
use Inertia\Inertia;
return Inertia::render('products', [
CatalogTable::make('catalog')
->pageSizeOptions([10, 25, 50])
->filterMode('advanced'),
]);

React

resources/js/pages/products.tsx
import { AppTable } from '@/components/app-table';
export default function Products() {
return <AppTable prop="catalog" />;
}

Return the PHP example from a controller action and add a route, as in Your first table.

Use filterMode('basic') if your API supports only a flat AND group. For input transformations, custom comparisons, and options callback timing, see Source API.

This class combines the excerpts above with constructor dependencies, field mappings, filter translation, and provider checks. InertiaX validates the table request; the source checks what this API supports.

CatalogSource.php — complete source
app/Sources/CatalogSource.php
<?php
namespace App\Sources;
use InertiaX\Table\Sources\Contracts\TableSource;
use InertiaX\Table\Sources\TableQuery;
use InertiaX\Table\Sources\TableResult;
use InvalidArgumentException;
final class CatalogSource implements TableSource
{
private const FIELDS = ['id' => 'sku', 'name' => 'title', 'price' => 'unit_price', 'status' => 'availability'];
public function __construct(private CatalogApi $api) {}
public function fetch(TableQuery $query): TableResult
{
// Translate and reject unsupported operations before the HTTP request.
$criteria = [
'offset' => $query->offset(),
'limit' => $query->paginationEnabled ? $query->pageSize : null,
'order_by' => array_map(fn (array $sort): array => [
'field' => $this->field($sort['key']), 'direction' => $sort['direction'],
], $query->effectiveSorting()),
'q' => $query->search,
'search_in' => array_map(fn (string $key): string => $this->searchField($key), $query->searchableColumns),
'where' => $this->group($query->filters),
];
$response = $this->api->search($criteria);
return new TableResult(
rows: array_map(fn (array $item): array => [
'id' => $item['sku'], 'name' => $item['title'],
'price' => $item['unit_price'], 'status' => $item['availability'],
], $response['items']),
total: $response['total'],
);
}
public function statusOptions(): array
{
return ['available' => 'Available', 'reserved' => 'Reserved'];
}
private function field(string $key): string
{
return self::FIELDS[$key] ?? throw new InvalidArgumentException("Catalog does not support field [$key].");
}
private function searchField(string $key): string
{
if ($key !== 'name') {
throw new InvalidArgumentException("Catalog does not support search on [$key].");
}
return $this->field($key);
}
private function group(array $group): array
{
return [
'join' => $group['operator'] === 'and' ? 'all' : 'any',
'rules' => array_map(function (array $node): array {
if (isset($node['conditions'])) {
return $this->group($node);
}
$allowed = match ($node['key']) {
'name' => ['equals' => 'eq', 'contains' => 'substring'],
'price' => ['equals' => 'eq', 'between' => 'range'],
'status' => ['equals' => 'eq', 'not-equals' => 'neq'],
default => [],
};
$comparison = $allowed[$node['clause']] ?? throw new InvalidArgumentException(
"Catalog does not support [{$node['key']}:{$node['clause']}].",
);
if ($node['key'] === 'status' && ! in_array($node['value'], array_keys($this->statusOptions()), true)) {
throw new InvalidArgumentException('Catalog does not support that status value.');
}
return ['field' => $this->field($node['key']), 'comparison' => $comparison, 'argument' => $node['value']];
}, $group['conditions']),
];
}
}

The client below targets a fictional API. To run the example locally, add both files and the container bindings below. The fake service supplies 137 records and processes requests without making external calls.

app/Sources/CatalogApi.php
<?php
namespace App\Sources;
use Illuminate\Http\Client\Factory;
/** Application client for the example provider's API, independent of InertiaX. */
final class CatalogApi
{
public function __construct(private Factory $http) {}
public function search(array $criteria): array
{
return $this->http->post('https://catalog.example.test/search', $criteria)->throw()->json();
}
}
FakeCatalog.php — local API implementation
app/Sources/FakeCatalog.php
<?php
namespace App\Sources;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\Request;
use LogicException;
/** Local provider fixture: only this fake service filters/sorts/slices the full catalog. */
final class FakeCatalog
{
public array $requests = [];
public function client(): Factory
{
$http = new Factory;
$http->preventStrayRequests();
return $http->fake(['catalog.example.test/search' => function (Request $request) use ($http) {
$criteria = $request->data();
$this->requests[] = $criteria;
$rows = array_values(array_filter($this->records(), function (array $row) use ($criteria): bool {
$matchesSearch = $criteria['q'] === '' || count(array_filter(
$criteria['search_in'],
fn (string $field): bool => str_contains(strtolower($row[$field]), strtolower($criteria['q'])),
)) > 0;
return $matchesSearch && $this->matches($row, $criteria['where']);
}));
usort($rows, function (array $left, array $right) use ($criteria): int {
foreach ($criteria['order_by'] as $sort) {
$order = $left[$sort['field']] <=> $right[$sort['field']];
if ($order !== 0) {
return $sort['direction'] === 'asc' ? $order : -$order;
}
}
return 0;
});
return $http->response([
'total' => count($rows),
'items' => array_slice($rows, $criteria['offset'], $criteria['limit']),
]);
}]);
}
private function records(): array
{
return array_map(fn (int $id): array => [
'sku' => $id,
'title' => sprintf('%s %03d', $id % 2 === 0 ? 'Desk' : 'Lamp', $id),
'unit_price' => ($id % 10 + 1) * 10,
'availability' => $id % 3 === 0 ? 'reserved' : 'available',
], range(1, 137));
}
private function matches(array $row, array $group): bool
{
$results = array_map(function (array $rule) use ($row): bool {
if (isset($rule['rules'])) {
return $this->matches($row, $rule);
}
$value = $row[$rule['field']];
$argument = $rule['argument'];
return match ($rule['comparison']) {
'eq' => $value === $argument,
'neq' => $value !== $argument,
'substring' => str_contains(strtolower($value), strtolower($argument)),
'range' => $value >= $argument[0] && $value <= $argument[1],
default => throw new LogicException('Unsupported fake catalog comparison.'),
};
}, $group['rules']);
return $group['join'] === 'all' ? ! in_array(false, $results, true) : in_array(true, $results, true);
}
}

Add these bindings to your AppServiceProvider::register() method:

Laravel

$this->app->singleton(\App\Sources\FakeCatalog::class);
$this->app->singleton(\App\Sources\CatalogApi::class, fn ($app) => new \App\Sources\CatalogApi(
$app->make(\App\Sources\FakeCatalog::class)->client(),
));

With the fake service, the table starts with 137 records; selecting “Available” leaves 92, and searching for “Desk” leaves 46 of those. Sorting and changing pages send fresh requests through the same source.

For a real API, replace the fake client and example endpoint with your authenticated client. Keep timeouts and retry policy in application code. See Authorization & security for access scoping.

View Markdown