kphoen/rulerz-bridge
Bridge package that brings the RulerZ rule engine to Laravel, letting you filter Eloquent models and collections with reusable rule strings. Define specifications once and apply them consistently across database queries and in-memory data.
First, check what you actually installed. kphoen/rulerz-bridge is a Symfony bundle (RulerzBundle with a DependencyInjection\Extension and compiler passes). Despite the description, it ships no Laravel service provider, no facade, and no auto-discovery. Day one of using it in a Laravel app is therefore a decision point, not an install:
composer require kphoen/rulerz-bridge -W
cat vendor/kphoen/rulerz-bridge/composer.json # look at require: symfony/framework-bundle?
composer why-not kphoen/rulerz-bridge # expect illuminate/* conflicts
If that require block references symfony/framework-bundle or symfony/dependency-injection, remove the bridge and keep only the core:
composer remove kphoen/rulerz-bridge
composer require kphoen/rulerz
Where to look first — in the core, in this order: RulerZ\RulerZ (the three public methods: satisfies, filter, apply), RulerZ\Spec\Specification, then src/Target/* (the compilation targets). Those four files are the entire mental model. Ignore the bridge source except as a reference for how someone else registered operators.
Minimal wiring (~50 lines you own). Register the engine as a singleton, mirroring what the Symfony bundle's DI extension does:
// app/Providers/RulerzServiceProvider.php
$this->app->singleton(RulerZ::class, function () {
// Constructor args vary across core versions — read vendor/kphoen/rulerz/src/RulerZ.php
$rulerz = new RulerZ(
new Compiler(new EvalEvaluator()),
[new Target\ArrayTarget(), new Target\DoctrineORMTarget()],
[new Operator\Like(), new Operator\In()]
);
return $rulerz;
});
Add a facade only if your team likes facades:
class Rulerz extends Facade
{
protected static function getFacadeAccessor(): string { return RulerZ::class; }
}
First use case — kill an if chain. Take the gnarliest eligibility rule in the codebase (discount, gating, tier) and express it as a string:
Rulerz::satisfies(
'user.group in :groups and user.age >= :min_age',
$user,
['groups' => ['admin', 'staff'], 'min_age' => 18]
);
Property paths (user.group) resolve through Symfony PropertyAccess, so they map to getters or public properties — not Eloquent attributes through __get in every case. Verify against a real model before writing a second rule.
Rules as strings in config, specs as classes. The two workable shapes are:
// config/rules.php — editable without touching a class
'promotions' => [
'free_shipping' => 'cart.total >= 50 and cart.country in :eligible',
],
// app/Rules/DiscountEligible.php — composable, testable
class DiscountEligible implements Specification
{
public function getRule(): string { return 'user.tier = "gold" and order.total > 100'; }
public function getParameters(): array { return []; }
}
Use config strings for rules ops tweaks weekly; use Specification classes when rules need to be composed (->and()), injected, or unit-tested independently. Avoid a hybrid where half the truth lives in YAML and half in PHP.
Eloquent is the weak spot — choose a path deliberately. RulerZ has no native Eloquent target. Two options, both real:
Filter in PHP (start here). Hydrate, then filter:
$collection = Product::where('active', true)->get();
$cheap = Rulerz::filter($spec, $collection); // returns a filtered collection/iterator
$ok = Rulerz::satisfies($spec, $product); // single-object check
Fine for hundreds to low thousands of rows. Not fine for a catalogue page.
Write a custom compilation target (when the PHP path hurts). Implement RulerZ\Compiler\CompilationTarget producing an Eloquent Builder, so the rule becomes WHERE clauses. This is the single most valuable extension point in the package and the one you will actually maintain. Budget a sprint; make it the only place SQL semantics leak in.
Pass parameters, never interpolate. Build rule strings from code, not from user input; put every variable in the parameter array. There is no escaping story in the DSL and none is needed if you follow this.
Compose specs instead of concatenating strings. String concatenation across modules produces unreadable rules and duplicate operators; Specification::and()/or() keeps provenance.
Compile once, cache aggressively. The compiler turns a rule string into PHP/SQL. Use the file/eval evaluator with a persistent cache directory keyed on the rule hash — or warm rules at deploy. Without caching, latency scales with rule count and every request pays for parsing.
Test with fixtures, not vibes. For each production rule, store (rule, parameters, input, expected) tuples and assert them. This is your only defence against a silent PHP-upgrade behaviour change, and it doubles as documentation.
Integrate at boundaries. Wrap RulerZ calls in a small RuleEngine service (isEligible(User, Specification), filterQuery(...)) so application code never imports RulerZ\*. If you later replace the DSL, you change one class.
config/rulerz.php published. Anything in the README implying framework plumbing is Symfony plumbing. Reimplementing the bridge in a service provider is not porting a dependency — it is owning ~50 lines you should own anyway.composer.json constraints to collide with Laravel 10/11 (PHP 8.1+). Run composer why-not before you build a habit on it; a lockfile conflict can freeze your framework upgrade path. Pin the version, and decide up front whether you forkmaintain the core.satisfies() vs filter() are not interchangeable. filter() returns an iterator/collection and may short-circuit differently; satisfies() is a boolean check on one subject. Don't assert one against the other in tests.in or like shadows built-ins or throws; namespace your custom ones (app_has_feature) and register them in one place.user.tier = "gold" vs user.tier = gold behave differently — the latter is parsed as a field path. Prefer parameters over literals so the quoting question disappears.null usually evaluates false rather than erroring. A rule that quietly stops matching for everyone after a refactor looks identical to "nobody qualified this week." Log rule + params + result at the call site for a release cycle.filter(). Filtering a hydrated Eloquent collection means every relationship the rule touches lazy-loads. Eager-load everything the rule references, or push the rule into a query target.user.group goes through PropertyAccess: getters, is*/has*, public properties, array keys. Eloquent accessors work because they're getters; __get-only dynamic attributes may not. Test with a real hydrated model, not a stdClass.Operator, custom CompilationTarget, and the executor/evaluator choice (eval vs file cache). Everything else is fixed; don't fight it.How can I help you explore Laravel packages today?