Scopes
Scopes map a virtual criteria key to a reusable criteria fragment. The fragment is defined once in the repository; callers activate it by passing the key like any other criteria entry.
// 'adult' is not a column — the repository expands it
$users = $repo->findBy(['status' => 'active', 'adult' => true]);Defining Scopes
Override scopes() in your repository. Each handler receives the key's value and returns a criteria fragment — or null to add no condition:
class UserRepository extends BaseRepository
{
protected function scopes(): array
{
return [
// Boolean toggle
'adult' => fn($value) => filter_var($value, FILTER_VALIDATE_BOOL)
? ['age' => ['>=' => 18]]
: null,
// Parameterized: the value feeds the fragment
'min_age' => fn($value) => $value === null
? null
: ['age' => ['>=' => (int) $value]],
// Any criteria syntax works, including OR groups and relation keys
'verified' => fn($value) => filter_var($value, FILTER_VALIDATE_BOOL)
? ['OR' => [
'verified_at' => ['!=' => null],
'documents.approved' => 1,
]]
: null,
];
}
}The fragment passes through the regular criteria compiler, so the full criteria syntax is available: operators, OR/AND groups, and relation dot-notation (compiled to EXISTS).
Naming Rules
Scope definitions are validated once, on the repository's first query. A violation throws InvalidArgumentException:
- names must be non-empty strings and plain identifiers — letters, digits, underscores; no dots, no
!; - names must not collide with reserved criteria keywords (
OR,AND,LIKE,IN,BETWEEN— case-insensitive), the primary key column, or the soft-delete column: the library generates criteria with those keys internally, and a scope would hijack them; - never name a scope after a real column — the scope shadows the column at the top level of every criteria array.
Handler Contract
- Handlers must accept
mixed(untyped ormixed $value). The dispatch happens inside the library, which runs understrict_types, so a narrower parameter type likefn(int $v)throwsTypeErroron HTTP string input. - Handlers must return
arrayornull. Anything else throws anInvalidArgumentExceptionnaming the scope. - Keep handlers pure: build and return a fragment. Running queries inside a handler is unsupported.
How It Works
- Before compiling SQL, scope keys are looked up at the top level of the criteria array.
- A matched key is removed and its handler is called with the key's value.
- A non-empty fragment is appended as a nested group, AND-joined with the remaining criteria.
nullor[]adds no condition — the key is still consumed, so it never reaches SQL.
Scope expansion runs before soft-delete processing, so the implicit deleted_at IS NULL filter is applied after — and unaffected by — your scopes.
Scopes apply to every criteria-accepting method: findBy(), findOneBy(), count(), exists(), aggregates, updateBy(), deleteBy() and forceDeleteBy(). (delete($id) and restore($id) build their criteria from the primary key internally; forceDelete($id) deletes by id without criteria at all.)
$repo->count(['adult' => true]);
$repo->updateBy(['adult' => true], ['plan' => 'full']);Scope keys are top-level only
A scope key nested inside an OR/AND group or a list-form entry is compiled as a regular column — likely an unknown-column SQL error, or a silently wrong filter if a real column shares the name. For the same reason scopes cannot reference other scopes from their fragments. Keep scope activation at the top level of the criteria array.
Write Safety
On write paths (updateBy, deleteBy, forceDeleteBy), criteria that scope expansion collapsed to nothing are refused with an InvalidArgumentException — a filter that evaluates to "no condition" must not silently become an unbounded write. Passing [] explicitly keeps its usual full-table meaning.
$repo->deleteBy(['adult' => '0']); // throws: expansion left no conditions
$repo->updateBy([], ['flag' => 0]); // allowed: explicit full-table updateSoft Delete Sentinel
Scope fragments cannot control soft-delete visibility: the special '*' value is recognized only as a top-level criteria entry supplied by the caller.
// Works: '*' stays top-level, the scope expands independently
$repo->findBy(['adult' => true, 'deleted_at' => '*']);
// Does not work: '*' inside a fragment compiles as a literal comparison
'with_trashed' => fn($v) => ['deleted_at' => '*'],HTTP Filters
Scopes are designed for externally supplied filters: a flat query-string key activates a predefined condition without a translation layer in between.
// ?adult=1&min_age=21
$criteria = array_intersect_key($request->getQueryParams(), array_flip([
'status', 'adult', 'min_age',
]));
$users = $repo->findBy($criteria);Handlers receive the raw value ('1', 'true', '0', an array for ?key[]= input, …) — normalize it inside the scope, e.g. with filter_var($value, FILTER_VALIDATE_BOOL).
User input
Scopes do not replace an allow-list. Any unknown key in the criteria array is still treated as a column filter — always restrict externally supplied keys as shown above.
Filter-Only Relations
Scope fragments may use relation dot-notation, which requires a configured relation. A relation can be configured without a setter purely for filtering — see Filter-Only Relations. Requesting such a relation via with() throws immediately.
See Also
For reusable query logic exposed as repository methods (rather than criteria keys), see Extending Repositories.