Mental model
One thought explains everything else: the library does not build your query, it layers
onto it. The query stays yours — with every JOIN, subquery and piece of business logic
— and the grid adds exactly three things: filter conditions, an order, and page bounds.
The idea: an overlay, not a generator
Most “server-side tables” are generators: you describe a model and the library decides what
SQL comes out. That is convenient until the task stops being typical; the moment you need a
tricky JOIN, a computed column or a CTE, you hit the generator’s edges and start fighting
it.
This is the other way round. You hand over a finished repository — with SELECT,
JOINs and a base WHERE — and the library writes on top of it:
your repository what MuiGrid::wrap() adds
───────────────────── ─────────────────────────
SELECT a.id, a.title, au.name
FROM articles a
LEFT JOIN authors au ON …
WHERE a.is_published = true
AND (a.title ILIKE :v0) ← filters from the schema
ORDER BY a.views DESC ← sort from the schema
LIMIT 25 OFFSET 50 ← the page
+ a separate COUNT(*) ← the totalThree consequences worth keeping in mind:
- Anything expressible in SQL works. Recursive CTEs, window functions,
EXISTS, computed expressions — the library never sees them and cannot get in their way. - The base
WHEREis untouchable. A user’s filters are added withandWhere(), so they narrow your set rather than replacing it. A row your business condition excluded cannot be reached through the grid. ORDER BYis overwritten. Sorting is what the user controls, so the order is set rather than appended. If order matters to your logic, it belongs in the schema’sdefaultOrder(), not in the base query.
Consequence: the schema is the door
Since the query is yours, the question becomes: what from the browser has any right to enter it? The answer is: only what the schema lists.
GridColumn::for('authorName', 'au.name')->filterable(FilterType::String)->sortable();
// ↑ name from the browser ↑ SQL from your codeOn the left, a string that came from the user. On the right, an expression you wrote. The schema translates one into the other, and only the right-hand side reaches SQL. The field name from the request never enters the query text.
That gives a trust boundary worth stating explicitly:
| Source | Where it goes | How it is handled |
|---|---|---|
field from the request |
nowhere | used as a lookup key into the schema |
operator from the request |
branch selection | cast to an enum; unknown ones fail |
value from the request |
into SQL | always as a bound parameter |
| a column’s SQL expression | into SQL | inserted verbatim — it is your code |
| a sort expression | into SQL | inserted verbatim — it is your code |
There is one practical rule: never assemble a column’s SQL expression from request data. Everything else the library handles safely on its own.
Consequence: a whitelist, not a blacklist
A freshly created column can do nothing — neither filter nor sort. Capabilities are opted into:
GridColumn::for('body', 'a.body'); // declared but useless
GridColumn::for('body', 'a.body')->filterable(FilterType::String); // may be filtered
GridColumn::for('body', 'a.body')->sortable(); // may be sortedThis is not a stylistic detail. Under a blacklist (“forbid the dangerous things”) a forgotten column is a hole; under a whitelist a forgotten column simply doesn’t work, and you hear about it from a tester rather than from an attacker.
Consequence: filters are rejected, sorts are ignored
The two operations react differently to an unknown field — deliberately.
| Situation | Filtering | Sorting |
|---|---|---|
| field declared and enabled | applied | applied |
| field declared, operation not enabled | 400 | silently skipped |
| field not in the schema | 400 | silently skipped |
| operator not allowed for the type | 400 | — |
The reason is who produces those requests. A filter is a deliberate act: if it didn’t apply,
showing an empty table instead of an error would be a lie. A sort state, on the other hand,
is emitted by the table itself — including for hidden and reordered columns — and failing on
that would be hostile to the UI. When no usable sort remains, defaultOrder() takes over.
More in Whitelist & safety.
Consequence: two kinds of filter
Not every filter is a table column. It pays to separate them from the start:
- Grid filters — what the user types into the table’s UI. They arrive in
filterModel, are checked against the schema and applied by the library. - Domain filters — the query’s scope: “mine only”, “in this category”, a global search
box. These are your own request fields, applied to the base query before
wrap(), and they take no part in the schema.
if ($request->authorId) {
$repo->andWhere(Qb::eq('a.author_id', $request->authorId)); // domain filter
}
return MuiGrid::wrap($repo, $request, $schema); // grid filtersRecipes for the second kind are in Filters outside the grid.
When this fits, and when it doesn’t
It fits when the data set is larger than is sensible to ship to a browser and the user expects familiar pagination, sorting and per-column filters — that is, nearly any admin listing.
It doesn’t fit when there are only a few hundred rows: shipping everything at once and
letting the table filter on the client is cheaper in both code and round trips. Nor does it
fit an infinite feed: a cursor suits that better than OFFSET plus COUNT.
Next steps
- Joined columns — how the model maps onto related tables.
- Whitelist & safety — the same trust boundary, in detail and against the code.
- API reference — exact signatures.