Skip to content

Global query filters

A global query filter is a predicate applied to every query on a model unless that query opts out. It generalises the “hide the rows nobody asked to see” pattern a soft-delete behavior applies to a single table, and covers multi-tenancy, which nothing did.

Propulsion::addGlobalQueryFilter('Book', 'not-deleted', function ($q) {
$q->filterByDeletedAt(null);
});
Propulsion::addGlobalQueryFilter('Book', 'tenant', function ($q) {
$q->filterByTenantId(CurrentTenant::id());
});

From here, BookQuery::create()->find() sees only the current tenant’s undeleted books, and so does every count(), update() and delete().

The first argument is the model’s name as its query class reports it (ModelCriteria::getModelName()), namespace included if it has one. The second is a name unique per model: re-registering a name replaces rather than stacks, so a bootstrap that runs twice — a test, a worker reload — cannot apply its filters twice, and a query can name that one filter to drop it without dropping the others.

The two motivating cases split on exactly this. Soft delete is knowable at bootstrap; the current tenant is not — it is a property of the request. The closure runs when the query is built and receives the query itself, so it can use the generated filterByX() methods and read whatever request state it needs at that moment.

That is also the rule to follow when registering one under a persistent worker: capture the lookup, never a value. Filters are process-scoped configuration and survive Session::reset(), which is what a worker needs — a filter that vanished at the request boundary would silently stop filtering, and for tenancy that is a data leak rather than a degraded feature. A closure that captured $tenantId by value during one request would keep applying that tenant’s id to every later request.

BookQuery::create()->withoutGlobalFilter('not-deleted')->find(); // include soft-deleted rows
BookQuery::create()->withoutGlobalFilter('a', 'b')->find(); // several by name
BookQuery::create()->withoutGlobalFilters()->find(); // all of them

Naming a filter that isn’t registered is not an error, so a query written against a deployment that configures soft delete still runs on one that doesn’t.

At the four SQL-building seams: SELECT, COUNT, UPDATE and DELETE. Filtering only reads would be worse than not filtering at all for tenancy — a delete() reaching rows the matching find() hides is a cross-tenant write.

Each query applies its filters once, even on the keepQuery(false) path that reuses the query object rather than cloning it, so a second find() does not add the conditions again.

deleteAll() is exempt on purpose: it is the explicit “empty this table” operation and has no WHERE clause to narrow.

A joined or merged secondary query is not filtered by its own model’s registrations. The predicate would have to be rewritten against the join’s alias, and there is no general way to do that to an arbitrary closure that may add joins or orWhere groups of its own — a rewriter that handled filterByX() and silently mis-scoped anything else would be worse than the stated limitation. Add the condition explicitly when you join.

Propulsion::addGlobalQueryFilter($model, $name, $filter)register or replace
Propulsion::removeGlobalQueryFilter($model, $name)drop one; a no-op if it was never registered
Propulsion::clearGlobalQueryFilters(?$model = null)drop all for a model, or for every model — mostly for test isolation
Propulsion::getGlobalQueryFilters()the registry itself, to inspect what is registered