# Connect an API source

> Translate search, nested filters, sorting, and pagination into an HTTP API request.

Source: https://docs.inertiax.dev/table/guide/api-source/

Use a [custom source](https://docs.inertiax.dev/table/guide/custom-sources/) to translate table requests into your API’s format. This catalog example maps field names, nested filters, search, sorting, and pagination.

## Translate the request

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

**Laravel**

CatalogSource::fetch() — request excerpt

```php
$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](https://docs.inertiax.dev/table/guide/api-source/#complete-api-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.

## Return rows and the matching total

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

**Laravel**

CatalogSource::fetch() — result excerpt

```php
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](https://docs.inertiax.dev/table/reference/source/#result-validation).

## Translate filters

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

**Laravel**

Example condition mapping

```php
// 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.

## Declare the supported controls

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
<?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**

```php
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

```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](https://docs.inertiax.dev/table/quick-start/).

Use `filterMode('basic')` if your API supports only a flat AND group. For input transformations, custom comparisons, and options callback timing, see [Source API](https://docs.inertiax.dev/table/reference/source/#filters-and-options).

## Complete API source

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
<?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']),
        ];
    }
}
```

## Set up the API client

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
<?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
<?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**

```php
$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](https://docs.inertiax.dev/table/guide/security/) for access scoping.
