Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ruler Bundle Laravel Package

rezzza/ruler-bundle

Symfony bundle integrating the Ruler rule engine, letting you define and evaluate business rules and contexts via configuration. Useful for stateless, expression-based decision logic in PHP applications.

View on GitHub
Deep Wiki
Context7

Getting Started

Read the source before you composer require anything. The bundle is ~a few hundred lines of Symfony 2 container wiring around the standalone ruler/ruler library. Clone both, open services.yml, the config tree class (look for Configuration.php / DependencyInjection/), and one test file. In an hour you will have seen the entire pattern: an operator registry, a rule built from a string, and Rule::evaluate(array $context): bool. That is the whole engine. There is nothing else to learn.

Do not install this in a Laravel app. It ships no service provider, no facade, no publishable config, and pins symfony/* to 2.x, which will not resolve against a modern Laravel/PHP 8 stack. composer why-not rezzza/ruler-bundle against your composer.json will confirm this in about thirty seconds. Treat the repo as a reference implementation of a decision layer, not a dependency.

First real use case: feature gating or discount eligibility. Pick one production if/else block in your domain — for example:

// Before: scattered policy
if ($user->plan === 'pro' && $user->seats <= 25 && $user->country === 'US') {
    $eligible = true;
}

The Ruler pattern turns that into a string rule evaluated against a context array:

$rule = 'context.user.plan == "pro"
    and context.user.seats <= 25
    and context.user.country == "US"';

$result = $evaluator->evaluate($rule, [
    'context' => ['user' => $user->toArray()],
]);

Install the underlying engine only if you must. If you want an off-the-shelf dependency rather than a port, use ruler/ruler directly (composer require ruler/ruler) — but first verify PHP 8 compatibility in its own composer.json. It is as old as the bundle. If it fails to resolve, do not fork it; write the ~200-line evaluator yourself (see below). That is cheaper than a fork.

Where to look first, in order:

  1. ruler/ruler's Rule.php, Operator/*.php, and Context.php — this is the engine and the only code worth mimicking.
  2. The bundle's services.yml — shows you what a minimal service registration for an evaluator looks like.
  3. The test suite (if present) — read the tests that exercise type coercion and precedence; they encode the semantics most likely to trip you up later.
  4. The config tree class — shows how rule sets were declared, which informs the shape of your Laravel published-config file.

Implementation Patterns

Pattern 1: Port the evaluator into a first-party Laravel package (recommended)

This is the path the technical assessment recommends and, day-to-day, it is the one that will not burn you. Target a framework-agnostic primitive at roughly 150–300 lines:

namespace App\Rules;

final class RuleSet
{
    public function __construct(
        private readonly string $name,
        private readonly string $version,
        private readonly array $operators,
    ) {}

    public function evaluate(string $expression, array $facts): Decision
    {
        $ast = $this->parse($expression);
        $matched = $this->walk($ast, $facts, $trace = []);

        return new Decision($matched, $this->name, $this->version, $trace);
    }
}

The Decision value object carries the boolean plus the matched-rule trace and rule-set version. This is the single change that pays for itself on the first "why did this account get this result?" incident — the upstream bundle has nothing like it, so you are not giving anything up by going custom.

Pattern 2: Bind it Laravel-natively with one service provider and one config file

The Symfony bundle's entire contribution was: DI service definitions, a config schema, and rule-set declaration. Replicate exactly that in Laravel and nothing more:

// App\Providers\RuleServiceProvider
public function register(): void
{
    $this->mergeConfigFrom(config_path('rules.php'), 'rules');

    $this->app->singleton(RuleEvaluator::class, function ($app) {
        return new RuleEvaluator()
            ->registerRuleSet('pricing', config('rules.sets.pricing'));
    });
}

public function boot(): void
{
    $this->publishes([
        __DIR__ . '/../../config/rules.php' => config_path('rules.php'),
    ], 'rules-config');
}

Inject RuleEvaluator by contract, never resolve it via app() at call sites. The point of the pattern is a clean seam.

Pattern 3: Rule definitions as versioned fixtures, not PHP string literals scattered in code

This is where the bundle is weak and you should deliberately diverge. Rules belong in files that are diffable, reviewable, and testable independently of application code:

// config/rules.php
return [
    'sets' => [
        'pricing' => [
            'version' => '2.1.0',
            'rules' => [
                'eu_discount' => 'context.cart.total > 100 and context.customer.region == "EU" and context.customer.returning == true',
                'us_promo'    => 'context.cart.total >= 250 and context.customer.country == "US"',
            ],
        ],
    ],
];

A rule-set version string costs one line and immediately makes every logged decision attributable to a known rules snapshot.

Pattern 4: Wrap existing policy branches behind the interface (strangler)

Do not rewrite all your if/else at once. Introduce the evaluator behind an interface and migrate one decision at a time, keeping the old branch as a fallback until the new path has golden-case coverage:

interface EligibilityChecker
{
    public function check(string $rule, array $facts): bool;
}

Then config('rules.experiments.eligibility_driver') chooses between LegacyChecker and RuleEvaluatorChecker. Swap the default when the rules path is fully tested. This is exactly the "adapter + tested fallback" the engineering pitch calls for.

Pattern 5: Log the decision, not just the outcome

Instrument every decision boundary from day one:

Log::info('rule.decision', [
    'set'      => $decision->setName,
    'version'  => $decision->version,
    'rule'     => $rule,
    'input_hash' => substr(hash('sha256', json_encode($facts)), 0, 16),
    'result'   => $decision->matched(),
    'matched'  => $decision->trace(),
    'us_ms'    => $decision->latencyMs(),
]);

Without this you have no provenance — the bundle gives you none, and a wrong pricing decision with no trace is unbounded incident cost.

Pattern 6: Test rules as data, not as code inside feature tests

Keep a golden-case suite next to the rule fixture:

it('applies eu_discount to first-time EU customers over 100', function () {
    $decision = app(RuleEvaluator::class)->evaluate(
        config('rules.sets.pricing.rules.eu_discount'),
        ['cart' => ['total' => 120], 'customer' => ['region' => 'EU', 'returning' => true]],
    );
    expect($decision->matched())->toBeTrue();
});

This is how you catch the operator-coercion drift that is the #1 silent-failure class in this engine family.

Gotchas and Tips

Type coercion will bite you — this is the highest-risk class of bug. The PHP 5 vs. PHP 7/8 string-versus-numeric comparison rules changed ("10" == "1e1" was true on PHP 5, false on PHP 8). Any rule ported or copied from an example written against the Symfony 2 era may behave differently today. Write an explicit coercion layer or an is_numeric guard in your operator implementations and test both sides of every comparison. Never rely on PHP's implicit casting inside a rule.

Missing context keys fail the worst possible way. A missing key in the Symfony 2 version typically either throws a container/context exception or silently evaluates to false, which looks identical to a legitimate "not eligible." Design your context contract explicitly: either require keys and throw (MissingFactException) or make absences explicit in the decision trace. "No" and "unknown" must be distinguishable.

Validate rules at load time, not at evaluation time. The bundle does not validate rule strings on load. Unknown operators or typos in field names surface only when the rule is first evaluated, potentially days later in a specific region. Add a php artisan rules:validate console command that parses every rule set at boot (or in CI) and fails fast. This is a small command with outsized payoff.

Short-circuit ordering is a performance lever, not a semantics question. The engine evaluates rules sequentially with no compiled cache and no memoization. Cost is linear in rule count × context size. Keep the most-frequently-false conditions first. If you hit the hundreds-of-rules range at high per-request volume, partition rule sets by domain (pricing, entitlements, eligibility) and evaluate only the relevant set — do not throw a cache at it first.

Do not teach anyone the Symfony 2 wiring. The dead-end skill here is Symfony 2 container semantics and PSR-0 autoloading. Onboarding docs should point new engineers at your evaluator and your config file, full stop. Refer anyone reading the upstream repo to the Decision::trace() pattern as the reason to have copied it.

Beware and vs && precedence in string rules. If you implement your own parser, precedence must be defined and tested — a or b and c means different things depending on whether you follow C-style precedence versus left-to-right. The upstream engine uses left-to-right in places; if you are porting, document your choice and make it a hard rule. Wrong-but-plausible is the failure mode here.

Do not conflate this with a feature-flag system. Pennant is feature flags; this is a boolean evaluator over a context. You will get stakeholder pressure to use one for the other. Rules engine for "does this cart qualify", Pennant for "is the checkout flow v2 on". Different tools.

Rule-set versioning is not optional for production. Emit the version into every decision log and every error report. When an incident lands ("why did Acme get 20% off?") the answer must be reconstructable from the log line alone — set name, version, rule expression, input hash, result. If you skip this in week one you will backfill it during an incident.

Keep the seam honest. Call sites depend on RuleEvaluator (your interface), never on any concrete implementation and never on the upstream ruler/ruler classes directly. If you ever need to swap to OPA/Cedar or a hosted decision service because business users need to author rules, the swap must be local to your service provider. The whole reason to adopt this pattern instead of the bundle is that the seam preserves that optionality.

Do not position this as a BRMS to anyone outside engineering. It is a code-first boolean evaluator appropriate to roughly one hundred engineer-owned rules. If business users need authoring, versioning, or audit beyond a git log, the correct answer is to evaluate a real decision service — stop now, before you build a rules editor on top of this.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle