Package · mui-data-grid

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.

text
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

In 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.

app/Article/ArticleGridRequest.php
<?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.

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

app/Article/ArticleService.php
<?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

app/Article/ArticleController.php
<?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

bash
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

json
{
"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.

bash
curl -X POST http://localhost:8080/articles \
-H 'Content-Type: application/json' \
-d '{"filterModel":{"items":[{"field":"is_published","operator":"is","value":false}]}}'
json
{ "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.

tsx
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