kphoen/rulerz
RulerZ is a PHP rule engine for writing business rules and specifications in a readable, dedicated language, then applying them to arrays, Doctrine, Elasticsearch, or Laravel models—keeping filtering logic reusable, testable, and out of your controllers.
RulerZ is a pure PHP package — no Laravel service provider is bundled. Install it directly and wire it into the container yourself:
composer require kphoen/rulerz
Register a single engine instance in a service provider so the compiler (which is the expensive part) is reused:
// app/Providers/RulerZServiceProvider.php
use RulerZ\Compiler\Compiler;
use RulerZ\RulerZ;
public function register(): void
{
$this->app->singleton(RulerZ::class, fn () => new RulerZ(Compiler::create()));
}
The three core methods you will use day to day:
| Method | Use |
|---|---|
satisfies($target, $ruleOrSpec) |
boolean check of one record |
filter($target, $ruleOrSpec) |
returns a filtered iterable / query builder |
apply($target, $ruleOrSpec) |
mutates and returns the target (query builder) |
First use case — an in-memory eligibility check plus a list filter using the same rule string:
$rule = 'subscription.active = true and points > :threshold';
$params = ['threshold' => 30];
$isEligible = $rulerz->satisfies($user, $rule, $params); // bool
$eligible = $rulerz->filter($usersCollection, $rule, $params); // iterable
$query = $rulerz->filter(User::query(), $rule, $params); // Eloquent builder
Where to look first: the package's doc/ directory for the DSL reference (operators, literals, boolean composition) and the target adapters under src/Target/ (Native, Doctrine, Eloquent, Elasticsearch). Read the Eloquent target source before assuming it behaves like the array target — that is where most surprises live.
The DSL is small: comparisons (=, !=, <, <=, >, >=), and/or/not, in, like, parameter placeholders (:name), and parenthesis grouping. If you can write a SQL WHERE, you can write a rule.
Wrap the engine behind an application interface. Even for a single-team app, define App\Rules\RuleEngine with satisfies(), filter(), and validate(), and implement it with RulerZ. This is what the assessment's "mitigation" advice actually looks like in code, and it lets you swap the implementation (Symfony ExpressionLanguage, custom Specifications) if the package stalls.
Push down to Eloquent for lists, evaluate in memory for single records. The whole point of RulerZ is that one rule string compiles to both. Keep the source of truth as a string (or Specification) and choose the target by context:
// list page: let the database do the work
$paginator = $rulerz->filter(User::query(), $rule, $params)->paginate(25);
// detail page / webhook: single record, no query round-trip
$allowed = $rulerz->satisfies($user, $rule, $params);
Use Specification classes for anything named and reused. Specifications are the composable unit; string literals are fine for throwaway filters but not for business rules:
use RulerZ\Spec\AbstractSpecification;
final class HighValueCustomer extends AbstractSpecification
{
public function getRule(): string
{
return 'total_spent > :min and last_order_at > :since';
}
public function getParameters(): array
{
return ['min' => 1000, 'since' => now()->subDays(30)];
}
}
Compose them with the provided operators, which is where the pattern earns its keep:
$spec = (new HighValueCustomer())
->andX(new Not(new Churned()))
->orX(new EnterpriseAccount());
$rulerz->satisfies($customer, $spec);
Store rule expressions as first-class data, not as constants. A rules table (name, expression, parameters JSON, target, status, version, timestamps) lets you ship new tiers, promotions, or segments without a deploy. Validate on save with a try/catch parse, and only activate rows that pass.
Cache compiled rules. Compilation is CPU work; per-request re-parsing of the same rule is the classic performance trap. Cache the compiled artifact keyed by hash(expression + parameters + target_type) — or, if the API does not expose compiled output cleanly, cache the parsed/validated rule object and reuse the RulerZ singleton across the request.
Service-layer usage, not controller usage. A SegmentService or EligibilityService accepts a rule and a repository, calls RulerZ, and returns domain results. Controllers and Livewire components never see RulerZ directly, which keeps the abstraction boundary honest and makes contract tests possible.
Admin filter builder → rule string, but re-validate server-side. A UI that assembles field op value AND ... into a rule string is a legitimate feature, but treat the resulting string as untrusted input: whitelist fields and operators, run it through your validator, and reject anything that references columns not in the allowlist before it reaches Eloquent.
Adapter parity is not guaranteed. The array/native target and the Eloquent target are separate compilers and can disagree on nulls, empty strings, dates/timezones, enums, and case sensitivity. Write contract tests that run the same rule against a fixture and against the database and assert identical results.
Multi-tenancy and global scopes are not the rule engine's job. RulerZ compiles where clauses onto the query builder you hand it; it does not know about tenant scopes, soft deletes, or policies. Enforce tenant filters before passing the builder in, and add a test that proves a rule cannot escape the tenant boundary.
Parameterize everything. Use :named placeholders and pass values through the params array. Never concatenate user input into a rule string — RulerZ compiles to where() calls with bound values, but that protection is void if you interpolate first.
Generated SQL is your debugger. When a rule misbehaves, call ->toSql() / ->getBindings() on the resulting Eloquent builder and look at the output. For the array target, add a small wrapper that dumps the rule string and parameters on exception — parser errors from RulerZ are terse and easy to misread.
Invalid rules throw at evaluation time. If rules live in the database, a bad row crashes whichever page first uses it. Validate on write, wrap reads in try/catch, log the offending expression, and fail closed (deny access / hide content) rather than open.
Watch query cost on OR-heavy rules. a OR b OR c with LIKE patterns compiles to something that can bypass indexes. Log DB::listen() slow queries on rule-filtered screens, and prefer rules that keep the leading column selective. In-memory filtering over large collections does not scale at all — only use the array target on small, already-loaded sets.
Custom operators are the supported extension point. If you need distance(lat, lng) < :km or has_role(:role), register a custom operator + executor rather than trying to smuggle logic into a whereRaw. This keeps the DSL portable and testable; raw SQL snippets in a rule string defeat both purposes.
Pin the version and keep the abstraction boundary. The package is MIT and community-maintained with a small dependent base. Pin an exact Composer constraint, run your rule contract tests on every Laravel/PHP upgrade, and keep the RuleEngine interface so a fork or replacement is a contained change rather than a rewrite. Budget for the possibility that you become the maintainer — the license permits it, the roadmap should account for it.
Feature-flag the first rollout. Ship the first rule family behind a flag with dual evaluation (old code path vs RulerZ) and log disagreements for a week before cutover. This is the cheapest defense against adapter parity bugs in production.
How can I help you explore Laravel packages today?