complex-heart/criteria
Bring the Criteria pattern to Laravel Eloquent with complex-heart/criteria. Encapsulate query constraints in reusable, composable classes so filtering stays clean, testable, and out of your models. Ideal for flexible repository queries and complex search logic.
complex-heart/criteria is a small, framework-agnostic PHP library that gives you value objects for the Criteria pattern: a Criteria describes what to fetch (filters, ordering, pagination) while your repository decides how. There is no service provider, no config file, and no migrations — it is plain PHP with a Composer autoload, so installation is the whole setup:
composer require complex-heart/criteria
Look here first, in this order:
composer.json — confirm the PSR-4 namespace (expect ComplexHeart\...) so you know the exact FQCNs.src/ — read the Criteria class/interface first, then whatever represents filters/operators, ordering, and pagination. With 5 stars and zero dependents there is effectively no external documentation; the source is the spec.tests/ (if present) — the fastest way to learn intended usage is the assertions, not the README.First use case: collapse a widening repository signature into one that takes a Criteria.
// Before: every new filter breaks the interface and every implementation
$repo->findActiveByTenant($tenantId, $sort, $page, $perPage);
// After: the interface is stable forever
$repo->findByCriteria(
Criteria::create()
->withFilter('tenant_id', '=', $tenantId)
->withFilter('status', '=', 'active')
->orderBy('created_at', 'desc')
->paginate(page: 1, perPage: 25)
);
(Class and method names above are illustrative — mirror whatever src/ actually exposes. Verify the exact factory names before writing them into a dozen call sites.)
Criteria as named domain concepts, not ad-hoc filters. The single biggest payoff is replacing query-shaped code in controllers with intention-revealing constructors: Criteria::forActiveSubscriptions(), Criteria::forOverdueInvoices($asOf). Callers stop knowing column names, and the query becomes unit-testable without a database.
One adapter per persistence technology. Keep the package DB-agnostic and write a translator in your infrastructure layer:
final class EloquentCriteriaApplier
{
public function apply(Builder $query, Criteria $criteria): Builder { /* ... */ }
}
Everything persistence-specific — column mapping, LIKE wildcard wrapping, IN expansion — lives here. That adapter is also the only place you need to touch if you ever drop the package.
Decorator for mandatory scoping. Wrap your repository so tenant/authorization filters are injected before the caller's criteria:
final class TenantScopedRepository implements OrderRepository
{
public function findByCriteria(Criteria $criteria): Collection
{
return $this->inner->findByCriteria(
$criteria->withFilter('tenant_id', '=', $this->tenantId)
);
}
}
This is the pattern that prevents the classic "someone forgot the tenant clause" data leak — and it is cheap to add on day one.
In-memory parity for tests. The strongest day-to-day argument for the pattern: apply the same Criteria to a plain array collection in unit tests and to SQL in production. Your filtering logic gets tested in milliseconds without a database, provided your adapter supports both.
Composition over mega-criteria. Build small criteria in each use case and merge them (merge, and, or equivalent) rather than mutating one shared object. This keeps each piece independently assertable.
Request → Criteria mapping. In Laravel, add a thin CriteriaFromRequest that validates filter.* and sort against a whitelist and only then builds the Criteria. Keep this out of controllers and out of the Criteria itself.
Testing strategy. Assert equality of Criteria objects for use-case tests (requires the value objects to implement equals or __toString), and assert generated SQL/bindings for adapter tests. Two layers, both fast.
^x.y, and consider pinning an exact version. Wrap the library behind your own Criteria interface so replacing it later is a one-file change.$criteria->withFilter(...) returning a new instance) silently do nothing if you forget to assign. The symptom is "my filter isn't applied." Dump the criteria tree before it hits the adapter whenever a query looks wrong.Criteria objects start carrying LIKE '%...%' or raw column names, you have re-created the coupling you adopted the pattern to remove.== and === across nested value objects is a common source of cache misses.with() names from the caller.var_dump the criteria → log the final SQL with bindings → compare. If the SQL is right but results are wrong, the bug is in the adapter's operator mapping (> vs >=, case sensitivity, NULL handling), which is where nearly all adapter bugs live.How can I help you explore Laravel packages today?