Быстрый старт
Соберём один рабочий эндпоинт — список статей с пагинацией, сортировкой и фильтрами, который таблица в браузере получает по частям. Пример сквозной: каждый шаг достраивает предыдущий, в конце — весь код целиком.
Что будем строить
Две таблицы: статьи и их авторы.
articles authors
-------- -------
id BIGINT PK id INT PK
author_id INT FK→authors name VARCHAR
title VARCHAR
views INT
is_published BOOL
created_at TIMESTAMPВ браузере — таблица с колонками «Заголовок», «Просмотры», «Автор» и «Создано». Каждая фильтруется и сортируется, данные приходят страницами по 25 строк.
Шаг 1. Запрос
Запрос таблицы состоит из двух частей: конверт грида (страница, размер страницы,
модель сортировки, модель фильтров) и ваши собственные параметры. Конверт уже описан
в MuiGridRequest — наследуйтесь и добавьте только своё.
<?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, // ваш собственный фильтр
// конверт грида — объявляем заново и передаём наверх
int $page = 0,
int $pageSize = 20,
#[ListOf(MGSortItem::class)] array $sortModel = [],
#[Valid] MGFilterModel $filterModel = new MGFilterModel(),
) {
parent::__construct($page, $pageSize, $sortModel, $filterModel);
}
}Зачем объявлять конверт заново
Параметры конструктора в PHP не наследуются: чтобы дочерний класс принимал page,
sortModel и остальное, их нужно перечислить ещё раз и передать в parent::__construct().
Атрибуты #[ListOf] и #[Valid] на этих параметрах обязательно сохранять — иначе
гидратор не будет знать, как собирать вложенные типы. Разбор всех тонкостей — в
Запросе и ответе.
Шаг 2. Схема
Схема объявляет, какие поля существуют для таблицы, какому SQL они соответствуют и что с ними разрешено делать.
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');Читается построчно: поле title, которое присылает браузер, соответствует выражению
a.title; по нему разрешено фильтровать строковыми операторами и сортировать. Поля, не
перечисленного здесь, для таблицы не существует.
defaultOrder() — порядок, который применяется, когда пользователь ничего не отсортировал.
Уникальный «хвост» (a.id DESC) в нём обязателен: без него строки с одинаковой датой могут
переставляться между страницами, и пользователь увидит дубли или пропуски.
Шаг 3. Сервис
Базовый запрос — ваш: SELECT, JOIN и бизнесовые условия. Библиотека накладывает
поверх фильтры, сортировку и пагинацию.
<?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');
}
}Обратите внимание на $request->authorId: это ваш фильтр, а не колонка таблицы, поэтому
он применяется к базовому запросу и в схеме не упоминается. Разница между этими двумя
видами фильтров разобрана в
Фильтрах вне грида.
Шаг 4. Контроллер
<?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()
);
}
}Грид — это POST: модель фильтров и модель сортировки приходят телом JSON, а не строкой
запроса.
Шаг 5. Проверяем
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" }]
}
}'Результат
{
"rowCount": 134,
"rows": [
{ "id": 12, "title": "Winter internals", "views": 9001, "created_at": "2026-03-04 10:12:00", "author_name": "Ada" }
]
}rowCount — сколько всего строк подходит под фильтр (для пагинатора таблицы), rows —
текущая страница. Проверьте заодно, что защита работает: попросите отфильтровать по полю,
которого нет в схеме, — вернётся 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" }Шаг 6. Подключаем таблицу
В серверном режиме таблица отдаёт своё состояние наружу, а данные принимает готовыми.
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}
/>Значения field у колонок таблицы должны совпадать с именами полей в схеме — это тот же
контракт, только со стороны браузера. Подробности, включая режимы загрузки и обработку
ошибок, — в Подключении таблицы.
Что дальше
- Ментальная модель — одна идея, из которой следует всё остальное поведение.
- Колонки из JOIN — фильтрация и сортировка по связанным таблицам.
- Свои фильтры — когда стандартного сопоставления оператора и SQL не хватает.
- Справочник API — точные сигнатуры.