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.
Why a callable, not a stored condition
Section titled “Why a callable, not a stored condition”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.
Opting out
Section titled “Opting out”BookQuery::create()->withoutGlobalFilter('not-deleted')->find(); // include soft-deleted rowsBookQuery::create()->withoutGlobalFilter('a', 'b')->find(); // several by nameBookQuery::create()->withoutGlobalFilters()->find(); // all of themNaming 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.
Where filters are applied
Section titled “Where filters are applied”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.
The scope is the query’s own model
Section titled “The scope is the query’s own model”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.
Managing the registry
Section titled “Managing the registry”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 |
Related
Section titled “Related”- Archivable behavior — Propulsion’s built-in soft-delete/archive behavior.
- ModelCriteria & Query — the query API the closure receives.
- Running under a persistent worker — why the closure must read state rather than capture it.