Filters outside the grid
Not every filter is a column. Query scope (“mine only”), a selection in a category tree, a single search box spanning several fields — these are your own request parameters, applied to the base query before the grid layers anything on. The schema neither knows nor should know about them.
Why they shouldn’t be columns
A schema column is a promise to the UI: “this field exists, it can be filtered and sorted”. A domain filter promises something else: it narrows the data set before the user types anything.
| Grid filter | Domain filter | |
|---|---|---|
| Where it comes from | the table’s filterModel |
your own request field |
| Who validates it | the schema (whitelist) | your code |
| When it applies | inside wrap() |
before wrap(), on the base query |
| Visible to the user | as a column filter | as a UI control, or not at all |
Dressing scope up as a column breaks both sides: the UI grows a strange column, and on the server the user gains the ability to lift the restriction simply by clearing the filter.
Declare your own parameter
Add a field to your MuiGridRequest subclass — it is validated like any request field:
class ArticleGridRequest extends MuiGridRequest
{
public function __construct(
#[Positive] public ?int $authorId = null,
public ?string $search = null,
public array $tagIds = [],
int $page = 0,
int $pageSize = 20,
#[ListOf(MGSortItem::class)] array $sortModel = [],
#[Valid] MGFilterModel $filterModel = new MGFilterModel(),
) {
parent::__construct($page, $pageSize, $sortModel, $filterModel);
$this->search = $this->search !== null ? trim($this->search) : null;
}
}Why the base class isn't a readonly class
MuiGridRequest marks each property readonly individually rather than the whole class.
That is precisely for the line above: a subclass must be able to normalize its own
fields in the constructor. Don’t declare the child as a readonly class — that would forbid
exactly this.
Query scope
The simplest and most common case: narrow the set to what the user is allowed to see at all.
$repo = ArticleRepository::instance('a')
->select('a.id, a.title, a.views')
->where(Qb::eq('a.is_published', true));
if ($request->authorId) {
$repo->andWhere(Qb::eq('a.author_id', $request->authorId));
}
return MuiGrid::wrap($repo, $request, $schema);Grid filters are added on top with andWhere(), so they can only narrow further. Widening
the set back from the browser is impossible — see the
Mental model.
A global search box
One input that searches several columns at once is an OR over columns you choose, not a
structured table filter:
if ($request->search) {
$s = $request->search;
$m = TextMatch::forRepository($repo); // the same dialect the grid will use
$repo->andWhere(Qb::clip(Qb::or(
$m->match('a.title', "%$s%"),
$m->match('a.body', "%$s%"),
$m->match('au.name', "%$s%"),
)));
}Two things are easy to get wrong here:
Qb::clip()is mandatory. Without parentheses theORmixes with your other conditions throughANDand the query starts returning the wrong rows. The brackets are semantics, not style.- Pass the pattern into each call separately. Don’t reuse one bind object across
conditions: the
COUNTquery wraps yourSELECT, and a reused placeholder breaks native prepared statements.
TextMatch::forRepository() resolves the spelling of case-insensitive matching from the
repository’s driver, keeping your search portable across PostgreSQL, MySQL and SQLite with no
changes. Details in Match modes.
EXISTS and many-to-many relations
Selecting by tags through a junction table means EXISTS. Values from the request must
travel as bound parameters or pass strict sanitization:
if ($request->tagIds) {
$ids = array_values(array_filter(array_map('intval', $request->tagIds)));
if ($ids !== []) {
$in = Qb::in('at.tag_id', $ids); // builds the placeholders and binds for you
$repo->andWhere(Qb::raw(
"EXISTS (SELECT 1 FROM article_tags at
WHERE at.article_id = a.id AND {$in->getQuery()})",
$in->getBinds(),
));
}
}Result
EXISTS (SELECT 1 FROM article_tags at
WHERE at.article_id = a.id AND at.tag_id IN (:iqb0, :iqb1, :iqb2))Borrowing Qb::in() saves you from assembling a placeholder list by hand: the condition
yields ready-made text and ready-made binds, which Qb::raw() accepts as its second argument
untouched.
Qb::raw does not escape
The first argument enters the query verbatim — it is your SQL. Everything that came from the
user must travel in the second argument: either as CDOBind objects or as name => value
pairs. Qb::raw() placeholders are named (:tag); positional ? is not supported.
The int cast in the example is a second line of defense, not a substitute for binding.
Filtering by a tree
Selecting “a node and all its descendants” is a recursive CTE. It is declared on the repository and then used like an ordinary table:
if ($request->categoryId) {
$repo->withRecursive('descendants',
CategoryRepository::instance()
->select('id')
->where(Qb::eq('id', $request->categoryId))
->unionAll(
CategoryRepository::instance('c')
->joinInner('descendants d', 'c.parent_id = d.id')
->select('c.id')
)
);
$repo->andWhere(Qb::raw('a.category_id IN (SELECT id FROM descendants)'));
}The pagination COUNT query wraps your whole SELECT, so the CTE stays intact and the
total is computed over the same set.
Order of calls
Domain filters apply before wrap(). After it the repository has already been mutated —
the paginator set LIMIT/OFFSET on it — and it is too late to add conditions.
1. instance() + select() + join() + where() ← the base query
2. andWhere() for your parameters ← domain filters
3. MuiGrid::wrap(repo, request, schema) ← grid filters, order, page
4. the responseReusing the repository
wrap() mutates the repository it is given. If you need it afterwards — for a second query
with summary figures, say — clone it beforehand.
Next steps
- Custom filters — when a filter is a column, but needs non-standard SQL.
- Joined columns — related tables as ordinary columns.
- API reference — exact signatures.