Whitelist & safety
A table in the browser is a user-driven query builder. It sends field names, operators and values, and the server has to turn them into SQL without letting them turn into anything at all. This page is about where exactly the trust boundary runs and why it is drawn that way.
The problem
The naive server-side table looks like this:
// NEVER do this
$sql .= " ORDER BY {$request->sortModel[0]['field']} {$request->sortModel[0]['sort']}";That is SQL injection through a column name. Worse, parameter binding cannot close it: a placeholder substitutes a value, and a column name and sort direction are not values — they are part of the query’s structure. The only workable defense is to check the incoming name against a list known in advance.
That is where the schema comes from. It is not “extra validation on top”; it is the only mechanism capable of closing this class of problem at all.
The trust boundary
Every part of the request has a source, and the source decides the treatment:
| Data | Source | Where it goes | How it is handled |
|---|---|---|---|
field |
browser | nowhere | a lookup key into the schema |
operator |
browser | branch selection | cast to an enum; unknown → 400 |
value |
browser | into SQL | always a bound parameter |
sort |
browser | ASC / DESC |
validated with #[In], else 400 |
page, pageSize |
browser | LIMIT / OFFSET |
integers, range-checked by validation |
| a column’s SQL expression | your code | into SQL verbatim | trusted |
| a sort expression | your code | into SQL verbatim | trusted |
defaultOrder() |
your code | into SQL verbatim | trusted |
The first row is the key one. A field name from the request never enters the query text. It is only used as a key into an associative array of columns; what reaches SQL is the expression you wrote in the schema yourself.
{ "field": "authorName" } → looked up in the schema
↓ found
GridColumn::for('authorName', 'au.name')
↓ the right-hand side goes to SQL
au.name ILIKE :iqb0 ← the request's name never got hereWhich gives a rule worth stating on its own.
Never build a column expression from request data
Everything you pass to GridColumn::for(), sortable() and defaultOrder() enters the
query without escaping — it is your SQL. A schema assembled from user input reinstates
exactly the hole it exists to close.
The order of checks
One filter item goes through four steps, and the order matters:
1. the field is looked up in the schema
not found → MuiGridException (400)
2. the field exists but filterable() was never declared
→ MuiGridException (400)
3. the operator is checked against the FilterType set
not in it → MuiGridException (400)
4. your filterUsing() resolver is called
returned a Qb → that is used
returned null → the standard operator mappingThe type gate sits before the resolver, deliberately: a user resolver is extra logic, not a replacement for the check. An operator the column’s type disallows never reaches your code, so it is not possible to write a resolver that accidentally weakens the guard.
Why filters are rejected but sorts are not
The two operations react differently to an unknown field:
| Situation | Filtering | Sorting |
|---|---|---|
| field declared and enabled | applied | applied |
| field declared, operation not enabled | 400 | skipped |
| field not in the schema | 400 | skipped |
| operator outside the type’s set | 400 | — |
The asymmetry comes from who produces these requests.
A filter is a deliberate act. The user entered a condition and expects a result. If the condition cannot be applied, showing an empty table lies to them: they conclude there are no matches when in fact the filter never ran. Applying it partially, in silence, lies too. Hence an explicit error.
A sort is UI state. DataGrid emits sortModel when columns are reordered, when they are
hidden, when saved state is restored — including for fields not currently on screen.
Answering 400 to a component’s transient state would break the UI for no reason. So
unusable entries are dropped, and when none remain defaultOrder() applies.
The practical consequence for a developer: a name mismatch shows up differently. Mistype
a table column’s field and the filter produces a visible error while the sort simply does
nothing — clicking the header changes nothing at all. The second is easy to miss.
What the grid cannot bypass
User filters are added to the repository with andWhere(), which means they can only
narrow the set:
$repo->where(Qb::eq('a.is_published', true)); // your condition
// … the grid then adds:
$repo->andWhere($filtersFromRequest); // AND — not OR, not a replacementA row excluded by your base condition cannot be reached from the browser — not by any
combination of filters, logicOperator: "or" included: the filter model is wrapped in
parentheses as a whole before being joined to your condition with AND.
This is exactly why scope — “mine only”, “my organization only” — belongs in the base query rather than in a schema column. A column is something the user can simply leave blank; a base condition is not.
Permission-gated columns
The schema is built per request, so it can depend on who is asking. That is the simplest way to hide a column from some users:
public function grid(ArticleGridRequest $request, bool $canSeeStats): MuiGridResponse
{
$repo = ArticleRepository::instance('a')->select(
'a.id, a.title' . ($canSeeStats ? ', a.views' : '')
);
$columns = [
GridColumn::for('title', 'a.title')->filterable(FilterType::String)->sortable(),
];
if ($canSeeStats) {
$columns[] = GridColumn::for('views', 'a.views')
->filterable(FilterType::Number)
->sortable();
}
return MuiGrid::wrap($repo, $request, GridSchema::make(...$columns)->defaultOrder('a.id DESC'));
}A user without the permission can neither see, filter, nor sort by views: the column is not
in their schema, so a filter on it is a 400 and a sort is ignored.
A hidden column leaks through sorting
Even when a column is left out of the SELECT, leaving it sortable in the schema lets a user
order the list by an invisible value and thereby reconstruct it — a classic leak (sorting
by salary, for instance). Remove the column from the schema, not just from the projection.
Capping the page size
pageSize is capped at 10,000 by request validation. That guards not against an attacker but
against an honest “give me everything” — a request that looks like pagination while dumping
the whole table and, on a large set, taking down both the database and the worker.
If your endpoint has a lower sensible ceiling, declare it in your request subclass:
public function __construct(
int $page = 0,
#[Min(1), Max(200)] int $pageSize = 25,
// …
) {
parent::__construct($page, $pageSize, $sortModel, $filterModel);
}What the library does not do
Knowing the limits helps. The library does not:
- verify that a column’s SQL expression exists in the database — an unknown-column error will come from the database at run time;
- bound the cost of a query — a correlated subquery inside a column expression runs in both
the page query and the
COUNT; - perform authorization — who sees what is decided by you, through the schema and the base query;
- prevent enumeration — if a user may filter by a field, they may probe values for it; rate limiting stays at the application level.
Next steps
- Dialects & indexes — the other deep-dive page: how the match spelling is chosen and what it costs.
- Mental model — the same boundary, briefly.
- Custom filters — where your logic plugs into this order.