tally / laravel
Serves the Tally Query Protocol from Eloquent and the Laravel query builder.
Requires
- php: ^8.4
- illuminate/contracts: ^13.37
- illuminate/database: ^13.37
- illuminate/http: ^13.37
- illuminate/routing: ^13.37
- illuminate/support: ^13.37
- opis/json-schema: ^2.6
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pint: ^1.32
- orchestra/testbench: ^11.3
- pestphp/pest: ^5.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Serves the Tally Query Protocol from Eloquent and the Laravel query builder. A grid class declares the columns, a route macro exposes it, and the adapter compiles filter, sort, group, aggregate, facet and page requests into SQL, so a Laravel app backs a <Tally> grid exactly like tally-server does in Node.
Not published yet.
Install
composer require tally/laravel
Requires PHP 8.4 and Laravel 13. The service provider is discovered automatically.
Grid
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. |
Every request is validated against the tally-protocol JSON Schema and then against the grid's columns before any SQL runs.
Semantics
The adapter passes the tally-protocol conformance suite on SQLite, Postgres and MySQL, so filters, sorts, groups, aggregates, facets and pages match the in-memory engine:
- Empty means
NULL, and for text columns also''. Empty values sort last in both directions and never match comparisons, exceptneq,notContainsandnotIn, 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-QnorYYYY. - 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,
NULLand''alike, which sorts last and pages like any other group.
Dialects
A Dialect supplies the few 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 Dialect for another engine and return it from dialect().
Routes
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. Live patches are not supported yet. |
The schema route reports live: false. Invalid requests return 400 with { code, message, details } where every detail names the path of a problem.
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. 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' }] }.
The adapter can be used without HTTP:
use Tally\Laravel\Adapter; use Tally\Laravel\Query\QueryRequest; $response = (new Adapter(new AccountsGrid))->query(QueryRequest::fromArray($input));
Conformance
The JSON Schema files in resources/schemas and the fixtures in tests/Fixtures/conformance.json are generated from tally-protocol by pnpm --filter tally-protocol build, so the PHP suite runs the same cases, with the same expected results, as the TypeScript adapters.
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