Quickstart
We’ll build one working endpoint — a list of articles with pagination, sorting and filters that the browser receives one page at a time. The example runs end to end: each step builds on the last, and the whole code is collected at the end.
What we’re building
Two tables: articles and their authors.
articles authors
-------- -------
id BIGINT PK id INT PK
author_id INT FK→authors name VARCHAR
title VARCHAR
views INT
is_published BOOL
created_at TIMESTAMPIn the browser: a table with Title, Views, Author and Created columns. Each one filters and sorts, and data arrives in pages of 25 rows.
Step 1 — the request
A grid request has two parts: the grid envelope (page, page size, sort model, filter
model) and your own parameters. The envelope is already declared in MuiGridRequest —
extend it and add only what’s yours.
<?php
namespace App\Article;
use Flytachi\Winter\Kernel\Http\Request\Validation\ListOf;
use Flytachi\Winter\Kernel\Http\Request\Validation\Positive;
use Flytachi\Winter\Kernel\Http\Request\Validation\Valid;
use Flytachi\Winter\MuiDataGrid\Entity\MGFilterModel;
use Flytachi\Winter\MuiDataGrid\Entity\MGSortItem;
use Flytachi\Winter\MuiDataGrid\MuiGridRequest;
class ArticleGridRequest extends MuiGridRequest
{
public function __construct(
#[Positive]
public ?int $authorId = null, // a domain filter of your own
// the grid envelope — redeclared and forwarded
int $page = 0,
int $pageSize = 20,
#[ListOf(MGSortItem::class)] array $sortModel = [],
#[Valid] MGFilterModel $filterModel = new MGFilterModel(),
) {
parent::__construct($page, $pageSize, $sortModel, $filterModel);
}
}Why redeclare the envelope
PHP constructor parameters are not inherited: for the child to accept page, sortModel
and the rest, you list them again and pass them to parent::__construct(). Keep the
#[ListOf] and #[Valid] attributes on those parameters — without them the hydrator won’t
know how to build the nested types. All the details are in the
API reference.
Step 2 — the schema
The schema declares which fields exist for the table, which SQL they map to and what may be done with them.
use Flytachi\Winter\MuiDataGrid\Schema\FilterType;
use Flytachi\Winter\MuiDataGrid\Schema\GridColumn;
use Flytachi\Winter\MuiDataGrid\Schema\GridSchema;
$schema = GridSchema::make(
GridColumn::for('title', 'a.title')->filterable(FilterType::String)->sortable(),
GridColumn::for('views', 'a.views')->filterable(FilterType::Number)->sortable(),
GridColumn::for('authorName', 'au.name')->filterable(FilterType::String)->sortable(),
GridColumn::for('createdAt', 'a.created_at')->filterable(FilterType::Date)->sortable(),
)->defaultOrder('a.created_at DESC, a.id DESC');Read it line by line: the field title that the browser sends maps to the expression
a.title; it may be filtered with string operators and sorted. A field not listed here does
not exist as far as the table is concerned.
defaultOrder() is the order applied when the user hasn’t sorted anything. The unique
tiebreaker (a.id DESC) in it is mandatory: without one, rows sharing a timestamp can
reshuffle between pages and the user sees duplicates or gaps.
Step 3 — the service
The base query is yours: SELECT, JOIN and business conditions. The library layers
filters, sorting and pagination on top.
<?php
namespace App\Article;
use Flytachi\Winter\Cdo\Qb;
use Flytachi\Winter\MuiDataGrid\MuiGrid;
use Flytachi\Winter\MuiDataGrid\MuiGridResponse;
use Flytachi\Winter\MuiDataGrid\Schema\FilterType;
use Flytachi\Winter\MuiDataGrid\Schema\GridColumn;
use Flytachi\Winter\MuiDataGrid\Schema\GridSchema;
class ArticleService
{
public function grid(ArticleGridRequest $request): MuiGridResponse
{
$repo = ArticleRepository::instance('a')
->select('a.id, a.title, a.views, a.created_at, au.name author_name')
->joinLeft(AuthorRepository::instance('au'), 'au.id = a.author_id')
->where(Qb::eq('a.is_published', true));
if ($request->authorId) {
$repo->andWhere(Qb::eq('a.author_id', $request->authorId));
}
return MuiGrid::wrap($repo, $request, $this->schema());
}
private function schema(): GridSchema
{
return GridSchema::make(
GridColumn::for('title', 'a.title')->filterable(FilterType::String)->sortable(),
GridColumn::for('views', 'a.views')->filterable(FilterType::Number)->sortable(),
GridColumn::for('authorName', 'au.name')->filterable(FilterType::String)->sortable(),
GridColumn::for('createdAt', 'a.created_at')->filterable(FilterType::Date)->sortable(),
)->defaultOrder('a.created_at DESC, a.id DESC');
}
}Note $request->authorId: it is your filter, not a table column, so it applies to the base
query and never appears in the schema. The difference between the two kinds of filter is
covered in Filters outside the grid.
Step 4 — the controller
<?php
namespace App\Article;
use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson;
use Flytachi\Winter\Kernel\Http\Request\Validation\Valid;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Route\Annotation\PostMapping;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
#[RequestMapping('articles')]
class ArticleController
{
public function __construct(private ArticleService $service)
{
}
#[PostMapping]
public function grid(
#[Valid] #[RequestJson] ArticleGridRequest $request
): ResponseEntity {
return ResponseEntity::ok(
$this->service->grid($request)->toArray()
);
}
}A grid is a POST: the filter and sort models arrive as a JSON body, not as query
parameters.
Step 5 — check it
curl -X POST http://localhost:8080/articles \
-H 'Content-Type: application/json' \
-d '{
"page": 0,
"pageSize": 25,
"sortModel": [{ "field": "views", "sort": "desc" }],
"filterModel": {
"logicOperator": "and",
"items": [{ "field": "title", "operator": "contains", "value": "winter" }]
}
}'Result
{
"rowCount": 134,
"rows": [
{ "id": 12, "title": "Winter internals", "views": 9001, "created_at": "2026-03-04 10:12:00", "author_name": "Ada" }
]
}rowCount is how many rows match the filter in total (for the table’s pager); rows is the
current page. While you’re here, confirm the guard works — ask to filter by a field that
isn’t in the schema and you get a 400.
curl -X POST http://localhost:8080/articles \
-H 'Content-Type: application/json' \
-d '{"filterModel":{"items":[{"field":"is_published","operator":"is","value":false}]}}'{ "message": "Column 'is_published' is not allowed for filtering" }Step 6 — wire the table
In server mode the table pushes its state outward and takes data ready-made.
const [rows, setRows] = useState([]);
const [rowCount, setRowCount] = useState(0);
const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 25 });
const [sortModel, setSortModel] = useState([]);
const [filterModel, setFilterModel] = useState({ items: [] });
useEffect(() => {
fetch('/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
page: paginationModel.page,
pageSize: paginationModel.pageSize,
sortModel,
filterModel,
}),
})
.then((r) => r.json())
.then((data) => { setRowCount(data.rowCount); setRows(data.rows); });
}, [paginationModel, sortModel, filterModel]);
<DataGrid
rows={rows}
rowCount={rowCount}
paginationMode="server"
sortingMode="server"
filterMode="server"
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
onSortModelChange={setSortModel}
onFilterModelChange={setFilterModel}
/>The field values of the table’s columns must match the field names in the schema — the
same contract, seen from the browser. The details, including loading states and error
handling, are in Wiring the table.
Next steps
- Mental model — the one idea the rest of the behavior follows from.
- Joined columns — filtering and sorting across related tables.
- Custom filters — when the default operator-to-SQL mapping isn’t enough.
- API reference — exact signatures.