> ## Documentation Index
> Fetch the complete documentation index at: https://tally.outhebox.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Laravel

> Serve the Tally Query Protocol from Eloquent and the query builder with tally/laravel.

`tally/laravel` backs a grid from a Laravel app. A grid class declares the columns, `Route::tally()` exposes it, and the adapter compiles filter, sort, group, aggregate, facet and page requests into SQL. A client connects with `connectHttpSource` exactly as it does to `tally-server`.

```sh theme={null}
composer require tally/laravel
```

It requires PHP 8.4 and Laravel 13. The service provider is discovered automatically.

## Grid

A grid extends `Tally\Laravel\Grid` and implements `query()` and `columns()`.

```php app/Grids/AccountsGrid.php theme={null}
use App\Models\Account;
use Illuminate\Database\Eloquent\Builder;
use Tally\Laravel\Grid;

final class AccountsGrid extends Grid
{
    public function query(): Builder
    {
        return Account::query()->whereBelongsTo(auth()->user()->team);
    }

    public function columns(): array
    {
        return [
            ['id' => 'account', 'label' => 'Account'],
            ['id' => 'plan', 'type' => 'enum', 'options' => [['value' => 'free'], ['value' => 'pro'], ['value' => 'team']]],
            ['id' => 'mrr', 'label' => 'MRR', 'type' => 'currency', 'aggregate' => 'sum'],
            ['id' => 'renewal', 'type' => 'date'],
        ];
    }
}
```

Columns use the same shape as `createSchema` in `tally-core` and are sent to the client unchanged. Grids are resolved from the container, so the constructor can take dependencies.

Every method below can be overridden:

| Method | Default | Notes |
| - | - | - |
| `query()` | | An Eloquent or query builder. Its scopes and constraints apply to reads, updates and deletes. |
| `columns()` | | Column definitions. |
| `key()` | `id` | Key field. It need not be a column. |
| `columnMap()` | `[]` | Column id to SQL column name, for `['fullName' => 'full_name']`. |
| `flags()` | `[]` | Named closures that receive a query builder and add the `where` clauses a `flag` filter means. |
| `types()` | `[]` | Extra `ColumnType` instances for custom column types. |
| `dialect()` | from the driver | `SqliteDialect`, `PostgresDialect`, `MysqlDialect` or your own `Dialect`. |
| `pagination()` | `Pagination::Offset` | `Offset` or `Cursor`. Cursors are opaque and encode the offset. |
| `maxLimit()` | `1000` | Page sizes above this are clamped. Requests without a page return at most this many rows. |
| `mutable()` | `true` | `false` refuses `PATCH /rows`. |
| `today()` | today | ISO date that relative ranges such as `last 30 days` resolve against. |
| `bins()` | `20` | Histogram bins. |
| `maxTerms()` | `50` | Terms facet size. |
| `maxGroups()` | `1000` | Group summaries are cut to the first this many groups, and pages past them are empty. |

### Flags

A flag filter such as `is:paying` calls the closure of the same name with the query builder.

```php FlaggedAccountsGrid.php theme={null}
<?php

declare(strict_types=1);

namespace Tally\Laravel\Tests\Fixtures;

use Illuminate\Database\Query\Builder;

final class FlaggedAccountsGrid extends AccountsGrid
{
    public function flags(): array
    {
        return [
            'paying' => fn (Builder $query): Builder => $query->where('mrr', '>', 0),
            'everyone' => fn (Builder $query): Builder => $query,
        ];
    }

    public function maxLimit(): int
    {
        return 3;
    }

    public function mutable(): bool
    {
        return false;
    }
}
```

## Routes

`Route::tally($uri, $grid)` registers the four protocol routes under `$uri`. Wrap it in middleware like any other route.

```php routes/web.php theme={null}
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function () {
    Route::tally('/api/accounts', AccountsGrid::class);
});
```

| Route | Body | Response |
| - | - | - |
| `GET /schema` | | `{ version, columns, capabilities, key }` |
| `POST /query` | `QueryRequest` | `QueryResponse` |
| `PATCH /rows` | `{ version, mutations }` | `MutationResult`, or `405` when the grid is read only |
| `GET /events` | | `404` |

The schema route reports `live: false`, so the client opens no event stream. Every request is validated against the `tally-protocol` JSON Schema and then against the grid's columns before any SQL runs. Invalid requests return `400` with `{ code, message, details }`, where every detail names the path of a problem. See [Protocol](https://tally.outhebox.dev/docs/backends/protocol.md#errors).

## Semantics

The adapter passes the `tally-protocol` conformance suite on SQLite, Postgres and MySQL, so results match the in-memory engine:

* Empty means `NULL`, and for text columns also `''`. Empty values sort last in both directions and never match comparisons, except `neq`, `notContains` and `notIn`, which keep them.
* Text comparisons are case insensitive through `lower()`. SQLite folds ASCII only.
* Enum columns sort and facet in option order.
* Date groups bucket to `YYYY-MM-DD`, ISO Monday weeks, `YYYY-MM`, `YYYY-Qn` or `YYYY`.
* Search matches the stored value of searchable columns, not the formatted text.
* Group order follows the sort on the group column when there is one, otherwise option or natural order, newest dates first.
* Empty values form one group, `NULL` and `''` alike, which sorts last and pages like any other group.

## Dialects

A `Dialect` supplies the functions SQL engines disagree on: `lower`, `text`, `float`, `floor`, `dayNumber`, `dateBucket`, plus `encode` and `decode` for values crossing the driver. The dialect is picked from the connection driver: `sqlite`, `pgsql`, `mysql` and `mariadb`. Extend `Tally\Laravel\Sql\Dialect` for another engine and return it from `dialect()`.

## Mutations

Mutations run in one database transaction and return the canonical rows read back from the database. Deletes and updates are scoped by `query()`, so a grid never writes to rows it cannot read.

> [!WARNING]
>
> Writes go through the query builder, so model events, casts and timestamps do not run.

When the transaction fails, every mutation is rolled back, the exception is reported, and the response is `{ accepted: false, errors: [{ message: 'Mutation failed' }] }`.

## Without HTTP

The adapter runs without a route, for jobs, commands or your own controllers.

```php theme={null}
use Tally\Laravel\Adapter;
use Tally\Laravel\Query\QueryRequest;

$response = (new Adapter(new AccountsGrid))->query(QueryRequest::fromArray($input));
```

## Testing

Test a grid with Laravel's HTTP test helpers. After `Route::tally('/api/accounts', AccountsGrid::class)`, a request that does not fit the columns answers with every problem:

```php tests/Feature/AccountsGridTest.php theme={null}
it('rejects requests that do not fit the schema', function (): void {
    $this->postJson('/api/accounts/query', [
        'version' => 1,
        'sort' => [['column' => 'nope', 'direction' => 'asc']],
        'filter' => ['type' => 'condition', 'column' => 'plan', 'op' => 'contains', 'value' => 'x'],
        'group' => ['column' => 'mrr', 'granularity' => 'day'],
        'aggregates' => [['column' => 'plan', 'fn' => 'sum']],
        'facets' => ['email'],
    ])->assertStatus(400)->assertExactJson([
        'code' => 'invalid_query',
        'message' => 'Query request is invalid',
        'details' => [
            'filter.op: operator "contains" is not supported by type "enum"',
            'sort.0.column: unknown column "nope"',
            'group.column: column "mrr" is not groupable',
            'group.granularity: only date columns accept a granularity',
            'aggregates.0.fn: aggregate "sum" is not supported by "plan"',
            'facets.0: column "email" has no facet',
        ],
    ]);
});
```

The package's own suite runs the `tally-protocol` conformance cases. The JSON Schema files in `resources/schemas` and the fixtures in `tests/Fixtures/conformance.json` are built from `tally-protocol`, so the PHP adapter is checked against the same cases and expected results as the TypeScript adapters, on every supported database:

```sh theme={null}
composer check
DB_CONNECTION=pgsql DB_DATABASE=tally DB_USERNAME=postgres vendor/bin/pest
DB_CONNECTION=mysql DB_DATABASE=tally DB_USERNAME=root vendor/bin/pest
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.