frontastic/rulerz
frontastic/rulerz is a PHP rule engine and specification library for writing business rules once as reusable predicates and applying them to arrays, query builders, and other data sources. Use its concise DSL or PHP rules for filtering, validation, and dynamic access control.
Install (and vendor) it first. This is a zero-star single-vendor fork; treat installation as a supply-chain decision, not a composer require.
# Prefer a pinned commit over a branch or a floating version
composer config repositories.rulerz vcs https://github.com/FrontasticGmbH/rulerz
composer require "frontastic/rulerz:dev-master#<commit-sha>"
Before writing code, check composer.lock for hoa/compiler / hoa/ruler. If they're still pulled in, that's the unmaintained transitive dependency that historically breaks on PHP 8.x — confirm the fork actually patched it, and check whether upstream k-phoen/rulerz is healthier.
Where to look first, in this order:
| Class / namespace | Why it matters |
|---|---|
RulerZ\RulerZ |
The whole public API: satisfies(), filter(), matching() plus the *Specification() variants |
RulerZ\Spec\AbstractSpecification |
Where your named rules live |
RulerZ\Spec\{AndX, OrX, Not, Rule} |
Composition and the "rule loaded from DB" wrapper |
RulerZ\Target\Native\Native |
The in-memory target — the only one that works with zero extra work in Laravel |
RulerZ\Compiler\Compiler |
Rule → AST → compiled PHP; controls the rule cache |
Bind it once, behind your own interface. Do this on day one, not later:
// app/Rules/SpecificationEngine.php
namespace App\Rules;
use RulerZ\Spec\Specification;
interface SpecificationEngine
{
public function satisfies(object|array $subject, Specification $spec): bool;
public function filter(iterable $items, Specification $spec): iterable;
}
// app/Providers/RuleServiceProvider.php
use RulerZ\Compiler\Compiler;
use RulerZ\RulerZ;
use RulerZ\Target\Native\Native;
public function register(): void
{
$this->app->singleton(SpecificationEngine::class, function () {
return new class(
new RulerZ(Compiler::create(), [new Native()])
) implements SpecificationEngine {
public function __construct(private RulerZ $rulerz) {}
public function satisfies(object|array $s, \RulerZ\Spec\Specification $spec): bool
{
return $this->rulerz->satisfiesSpecification($s, $spec);
}
public function filter(iterable $items, \RulerZ\Spec\Specification $spec): iterable
{
return $this->rulerz->filterSpecification($items, $spec);
}
};
});
}
Controllers and models then depend on SpecificationEngine, never on RulerZ. That is what makes the library a replaceable detail.
First real use case: a rule currently buried in an if block. Free-shipping eligibility is a good one.
// app/Rules/Shipping/FreeShippingEligible.php
use RulerZ\Spec\AbstractSpecification;
final class FreeShippingEligible extends AbstractSpecification
{
public function __construct(private float $threshold) {}
public function getRule(): string
{
return 'cart_total >= :threshold and customer_tier != :excluded';
}
public function getParameters(): array
{
return ['threshold' => $this->threshold, 'excluded' => 'blocked'];
}
}
$engine->satisfies($cart->toArray(), new FreeShippingEligible(50.00)); // bool
$eligible = iterator_to_array($engine->filter($carts, new FreeShippingEligible(50.00)));
Write the rule string, the params, and a unit test asserting satisfies() is true/false for three fixtures. That's the whole loop.
One class per rule, named in the business's language. GoldTierCustomer, InStock, NotAlreadyDiscounted. This is the pattern's entire payoff: a rule change becomes a diff in one small file with one failing test, instead of an archaeology expedition through a service.
Rules from the database via RulerZ\Spec\Rule. Store the DSL string and a JSON params column on an Eloquent model, then hydrate:
$spec = new \RulerZ\Spec\Rule($ruleModel->expression, $ruleModel->parameters);
This is where the "no more deploys for commercial logic" story actually comes from — but you own persistence, versioning, and audit (created_by, superseded_at, a rule_versions table). The package ships none of it.
Validate at write time, never at read time. A malformed rule should fail when an admin saves it, not during checkout. Compile it once in the save path:
// throws on a syntax error / unknown operator
$rulerz->satisfiesSpecification(['cart_total' => 0], new Rule($expression, $params));
Wrap that in a form request rule and return a friendly validation error.
Compose specifications instead of growing rule strings.
use RulerZ\Spec\{AndX, OrX, Not};
$spec = new AndX(
new InStock(),
new OrX(new GoldTier(), new CartOver(100.0)),
new Not(new AlreadyDiscounted()),
);
Keep parameter names globally unique. AbstractSpecification composition merges parameter arrays; two specs both using :threshold will silently collide and one wins. Prefix by concern (:shipping_threshold, :promo_threshold) or suffix with a hash. This is the single most likely production bug in a composed-rule codebase.
Pick the execution path deliberately.
Native target): works today, no adapter code, fine up to low thousands of rows. Use for previews, admin "who matches this?" tools, and tests.Target + visitor for Illuminate\Database\Query\Builder (Eloquent), roughly 200–400 LOC. Do not assume matching() accepts an Eloquent builder — it doesn't.Cache compiled rules. Compilation is the expensive part; parameter values are not part of the cache key, so a parameterized rule compiles once. Wire an evaluator pointed at a real cache dir and clear it on deploy the way you'd clear compiled views:
// storage/framework/rulerz must be writable and gitignored
$compiler = Compiler::create(new \RulerZ\Compiler\FileEvaluator(storage_path('framework/rulerz')));
Confirm which evaluator Compiler::create() wires by default in your pinned version — if it's the eval-based one, you're paying parse cost on every request.
Contract-test across targets. If a rule runs in more than one place, assert identical result sets:
#[DataProvider('rules')]
public function test_targets_agree(string $rule, array $params, array $fixtures): void
{
$inMemory = array_column(iterator_to_array($this->native->filter($fixtures, $rule, $params)), 'id');
$fromDb = $this->ids($this->doctrine->matching($this->qb(), $rule, $params));
$this->assertEqualsCanonicalizing($inMemory, $fromDb);
}
This is the only defence against silent drift between "what the preview showed" and "what production did".
Custom operators go through RulerZ\Compiler\CustomOperator, registered once on the engine. Keep them scalar and pure (is_even, starts_with). Anything that needs to become SQL must be implemented at the visitor level for that target — an operator that works in-memory will not automatically translate to DQL or ES. Register an allowlist of these and reject anything else at authoring time.
Log which specification matched when rules drive money (discounts, eligibility). A matched_rules array on the order is worth more than any dashboard when someone asks why a discount fired.
filter() returns a Generator. You can iterate it once; count() and repeated foreach will surprise you. Convert with iterator_to_array() or iterator_count() at the boundary.
The DSL is not PHP. No in_array(), no strtolower(), no &&. Use in, and, or, not, =, !=, >, >=, <, <=. Strings are quoted; booleans are true/false. Add explicit parentheses around composed and/or — precedence is easy to misread and impossible to eyeball in a stored rule.
Parameterize every value; never interpolate. A rule saved as 'points > 30' works fine until someone stores 'points > ' . $userInput. Placeholders (:min_points) go through the compiler's binding path; string interpolation becomes generated SQL. If end users can ever author rules, enforce an operator allowlist and cap expression complexity — the DSL is an injection surface, not just a filter syntax.
NOT on nullable columns silently drops rows. not (discount_code = 'X') excludes rows where discount_code IS NULL under SQL three-valued logic; the in-memory target may disagree. This is the classic in-memory-vs-SQL divergence, along with '30' vs 30 coercion and date formatting. Write fixtures with nulls and mixed types in the contract tests.
Missing properties don't fail gracefully. satisfies() over an array/object without the field the rule references produces a notice or exception depending on target. Validate rule fields against the subject shape when a rule is saved, or normalize subjects through a DTO before evaluation.
Unsupported operators fail at compile time, per target. A rule that runs in-memory may be untranslatable to DQL. Fail loudly and early rather than falling back — a rule that silently returns everything on the search path is worse than a 500.
Doctrine target assumptions. The visitor derives the query alias from the QueryBuilder's root alias. matching() on a builder with joins, multiple roots, or a non-standard alias is where it breaks. Keep the builder simple, or build the where yourself from the returned builder.
Compiled-rule cache directory must exist, be writable by the web user, and be cleared on deploy (or versioned). A stale compiled file is the "I changed the rule and nothing happened" bug.
Debugging recipe: (1) assert the rule parses with a one-line satisfies() in tinker; (2) inspect the generated PHP file in the rule cache dir to see exactly what the visitor produced; (3) for query targets, log the resulting DQL/SQL before execution. The compiled output is the fastest way to understand an unexpected result — the DSL layer is thin, the visitor is where semantics live.
Laravel has no first-party target. Illuminate\Database\Eloquent\Builder is not supported out of the box. Plan either in-memory filtering (simple, doesn't scale with catalog size) or budget a custom target. Do not discover this in sprint three.
Supply chain is the dominant risk. Zero stars, zero dependents, a fork of a dormant project. Mitigations that cost little: pin a commit SHA, mirror into an internal namespace you control, run PHPUnit/PHPStan in your own CI, and keep rules behind your SpecificationEngine interface so swapping to symfony/expression-language or a hand-rolled predicate tree is an internal refactor. Name an owner; budget a week a year for maintenance.
Extension points, ranked by usefulness: AbstractSpecification (daily), Rule + your own persistence layer (weekly), CustomOperator (occasionally — scalar only), custom Target/visitor (once, significant), replacing the Compiler (only if hoa/* forces it).
When not to reach for it. Three static conditions in one service is over-engineering — a plain method and a test is cheaper and clearer. Adopt this when rules genuinely churn monthly, or when the same condition is duplicated across a controller, a query builder, and an admin filter and the copies have already drifted.
How can I help you explore Laravel packages today?