Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ jobs:
testbench-version: '^10.0'
laravel-version: '12.x'
- php-version: '8.4'
testbench-version: '^10.0'
laravel-version: '12.x'
testbench-version: '^11.0'
laravel-version: '13.x'
- php-version: '8.5'
testbench-version: '^10.0'
laravel-version: '12.x'
testbench-version: '^11.0'
laravel-version: '13.x'

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand All @@ -41,6 +41,14 @@ jobs:
coverage: none
tools: composer:v2

# Laravel 10/11 are EOL: their advisories will never get patched releases, so
# Composer's advisory blocking would make these compatibility legs unresolvable.
- name: Allow advisory-affected versions on EOL Laravel legs
if: matrix.laravel-version == '10.x' || matrix.laravel-version == '11.x'
run: |
jq '.config.policy.advisories.block = false' composer.json > composer.tmp.json
mv composer.tmp.json composer.json

- name: Install dependencies
run: |
composer require --dev orchestra/testbench:${{ matrix.testbench-version }} --no-update
Expand All @@ -67,7 +75,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand All @@ -86,7 +94,7 @@ jobs:
run: composer test-coverage

- name: Upload coverage artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: clover-coverage
path: build/logs/clover.xml
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ All notable changes to RuleFlow PHP will be documented in this file.
- Added optional rule `metadata` for ownership, version, ticket, and rollout context.
- Exposed matched rule metadata through `metadata()`, `toArray()`, and `explain()` results.
- Documented production guidance for rule ownership metadata.
- Added PHP 8.4 and 8.5 (Laravel 12) to the CI test matrix.
- Fixed field resolution crashing with an uncaught `Error` when context objects expose non-public properties; they now count as missing.
- Added context support for `ArrayAccess` offsets and magic `__isset`/`__get` accessors, so Eloquent models work as evaluation context.
- Added `ValidatesValueInterface` so operators can validate rule `value` shapes; `regex`, `between`, `in`, and `not_in` now report unusable values (invalid patterns, malformed ranges, non-array lists) at validation time.
- Stopped invalid regex patterns from emitting PHP warnings during evaluation; they degrade to a non-match.
- Added Laravel 13 support (Testbench 11, PHPUnit 13 allowed) with compatibility docs.
- Added PHP 8.4 and 8.5 to the CI test matrix; PHP 8.4/8.5 legs run against Laravel 13.
- Fixed CI for EOL Laravel 10/11 legs by relaxing Composer advisory blocking there only.
- Refreshed dev dependencies (Testbench 10 / Laravel 12 locally, PHPStan 2.2).

## v0.3.3 - 2026-05-03
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,10 @@ Evaluate rules:
$result = app(\RuleFlow\RuleFlow::class)->evaluate($context);
```

The context accepts nested arrays, public object properties, `ArrayAccess`
offsets, and magic `__isset`/`__get` accessors — so Eloquent models can be
passed directly, for example `['order' => $order]`.

Validate configured rules:

```bash
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,9 @@ php artisan vendor:publish --tag=ruleflow-config
$result = app(\RuleFlow\RuleFlow::class)->evaluate($context);
```

context 支持嵌套数组、对象公开属性、`ArrayAccess` 以及魔术 `__isset`/`__get`
访问器——因此可以直接传 Eloquent 模型,例如 `['order' => $order]`。

校验配置中的规则:

```bash
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
"php": "^8.1"
},
"require-dev": {
"orchestra/testbench": "^8.0|^9.0|^10.0",
"phpunit/phpunit": "^10.5|^11.5|^12.0",
"orchestra/testbench": "^8.0|^9.0|^10.0|^11.0",
"phpunit/phpunit": "^10.5|^11.5|^12.0|^13.0",
"squizlabs/php_codesniffer": "^3.9",
"phpstan/phpstan": "^2.1"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ RuleFlow documentation is organized by usage stage.

- [quickstart.md](quickstart.md): the smallest useful setup
- [decision-list-model.md](decision-list-model.md): the evaluation model, trade-offs, and why RuleFlow is not a RETE engine
- [decision-list-model.zh-CN.md](decision-list-model.zh-CN.md): Chinese version of the decision list model overview
- [rule-format.md](rule-format.md): rule structure and JSON format
- [semantics.md](semantics.md): evaluation behavior and trace contract

Expand All @@ -20,7 +21,9 @@ RuleFlow documentation is organized by usage stage.

- [laravel.md](laravel.md): package integration basics
- [laravel-compatibility.md](laravel-compatibility.md): supported Laravel versions and integration boundaries
- [laravel-compatibility.zh-CN.md](laravel-compatibility.zh-CN.md): Chinese version of the Laravel compatibility guide
- [laravel-installation.md](laravel-installation.md): real Laravel project installation and smoke test checklist
- [laravel-installation.zh-CN.md](laravel-installation.zh-CN.md): Chinese version of the Laravel installation checklist
- [laravel-example.md](laravel-example.md): production-style Laravel order risk example
- [laravel-example.zh-CN.md](laravel-example.zh-CN.md): Chinese version of the Laravel order risk example

Expand Down
38 changes: 38 additions & 0 deletions docs/custom-operators.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,41 @@ $result = Engine::makeWithOperators(
'action' => 'allow',
]
```

## Optional: Validate Definition Values

Operators can also implement `ValidatesValueInterface` so that
`RuleValidator` (and `php artisan ruleflow:validate`) reports unusable
`value` shapes at validation time instead of failing silently at evaluation
time:

```php
use RuleFlow\Operators\OperatorInterface;
use RuleFlow\Operators\ValidatesValueInterface;

final class IpRangeOperator implements OperatorInterface, ValidatesValueInterface
{
public function name(): string
{
return 'ip_in_range';
}

public function evaluate(mixed $actual, mixed $expected): bool
{
// ...
}

public function validateValue(mixed $value): ?string
{
if (!is_string($value) || !str_contains($value, '/')) {
return 'must be a CIDR string such as 10.0.0.0/8.';
}

return null;
}
}
```

Returning `null` accepts the value; returning a string reports it as a
validation error. The built-in `regex`, `between`, `in`, and `not_in`
operators implement this interface.
1 change: 1 addition & 0 deletions docs/laravel-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The package is tested against:
| 10.x | 8.x | 8.1 |
| 11.x | 9.x | 8.2 |
| 12.x | 10.x | 8.3 |
| 13.x | 11.x | 8.3+ (tested on 8.4 and 8.5) |

This matrix is reflected in GitHub Actions.

Expand Down
1 change: 1 addition & 0 deletions docs/laravel-compatibility.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ RuleFlow 分为两层:
| 10.x | 8.x | 8.1 |
| 11.x | 9.x | 8.2 |
| 12.x | 10.x | 8.3 |
| 13.x | 11.x | 8.3+(在 8.4 和 8.5 上测试) |

这个矩阵会在 GitHub Actions 中显式验证。

Expand Down
3 changes: 2 additions & 1 deletion docs/laravel-installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ RuleFlow is tested with:
| 10.x | 8.1+ |
| 11.x | 8.2+ |
| 12.x | 8.3+ |
| 13.x | 8.3+ |

The core package only requires PHP 8.1+. Laravel is optional and loaded through
package auto-discovery when the package is installed in a Laravel application.
Expand Down Expand Up @@ -142,7 +143,7 @@ Redis. For local smoke tests, Laravel's default cache store is enough.

Before publishing a RuleFlow release, verify:

- GitHub Actions passes for Laravel 10, 11, and 12.
- GitHub Actions passes for Laravel 10, 11, 12, and 13.
- A clean Laravel project can install the package.
- `vendor:publish --tag=ruleflow-config` works.
- `php artisan ruleflow:validate` works.
Expand Down
3 changes: 2 additions & 1 deletion docs/laravel-installation.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ RuleFlow 当前测试:
| 10.x | 8.1+ |
| 11.x | 8.2+ |
| 12.x | 8.3+ |
| 13.x | 8.3+ |

核心包只要求 PHP 8.1+。Laravel 是可选集成,在 Laravel 项目里通过 package
auto-discovery 自动加载。
Expand Down Expand Up @@ -138,7 +139,7 @@ php artisan tinker

发布 RuleFlow 版本前,确认:

- GitHub Actions 通过 Laravel 10、11、12 测试矩阵。
- GitHub Actions 通过 Laravel 10、11、12、13 测试矩阵。
- 干净 Laravel 项目可以安装这个包。
- `vendor:publish --tag=ruleflow-config` 正常。
- `php artisan ruleflow:validate` 正常。
Expand Down
13 changes: 11 additions & 2 deletions docs/semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,17 @@ Built-in failure reason codes include:
Fields are resolved from the input context using dot notation, for example
`user.risk_score`.

The context may be an array or an object. Nested arrays and public object
properties are supported.
The context may be an array or an object. Each path segment is resolved in
this order:

1. array keys
2. public object properties
3. `ArrayAccess` offsets (this covers Eloquent models)
4. magic `__isset`/`__get` accessor pairs

Private and protected properties are treated as missing instead of producing
an error. For magic accessors, `isset()` semantics apply: an attribute whose
value is `null` counts as missing.

When a field does not exist:

Expand Down
5 changes: 5 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,8 @@ $validation = (new RuleValidator($operators))->validate($rules);
- required condition keys: `field`, `operator`, `value`
- non-empty field paths
- registered operators
- operator value shapes: `regex` patterns must compile, `between` requires
exactly two numeric bounds in order, `in`/`not_in` require a non-empty array

Custom operators can opt into value-shape validation by implementing
`RuleFlow\Operators\ValidatesValueInterface`.
79 changes: 71 additions & 8 deletions src/FieldAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@

namespace RuleFlow;

use ArrayAccess;

final class FieldAccessor
{
/**
* Reads nested values with dot notation, for example user.risk_score.
*
* Each path segment is resolved against arrays, public object properties,
* ArrayAccess offsets (which covers Eloquent models), and magic
* __isset/__get pairs, in that order. Non-public properties are treated
* as missing instead of being read.
*
* @param array<string,mixed>|object $context
*/
public function get(array|object $context, string $path, mixed $default = null): mixed
Expand All @@ -35,14 +42,35 @@ private function resolve(array|object $context, string $path): array
$current = $context;

foreach (explode('.', $path) as $segment) {
if (is_array($current) && array_key_exists($segment, $current)) {
$current = $current[$segment];
continue;
$step = $this->step($current, $segment);

if (!$step['exists']) {
return [
'exists' => false,
'value' => null,
];
}

if (is_object($current) && property_exists($current, $segment)) {
$current = $current->{$segment};
continue;
$current = $step['value'];
}

return [
'exists' => true,
'value' => $current,
];
}

/**
* @return array{exists:bool,value:mixed}
*/
private function step(mixed $current, string $segment): array
{
if (is_array($current)) {
if (array_key_exists($segment, $current)) {
return [
'exists' => true,
'value' => $current[$segment],
];
}

return [
Expand All @@ -51,9 +79,44 @@ private function resolve(array|object $context, string $path): array
];
}

if (!is_object($current)) {
return [
'exists' => false,
'value' => null,
];
}

// get_object_vars() from this external scope only exposes public
// properties, so private and protected properties stay unreadable
// instead of triggering property access errors.
$publicProperties = get_object_vars($current);

if (array_key_exists($segment, $publicProperties)) {
return [
'exists' => true,
'value' => $publicProperties[$segment],
];
}

if ($current instanceof ArrayAccess && $current->offsetExists($segment)) {
return [
'exists' => true,
'value' => $current->offsetGet($segment),
];
}

// isset() on inaccessible properties delegates to __isset(), which
// means null values reported by magic accessors count as missing.
if (method_exists($current, '__isset') && method_exists($current, '__get') && isset($current->{$segment})) {
return [
'exists' => true,
'value' => $current->{$segment},
];
}

return [
'exists' => true,
'value' => $current,
'exists' => false,
'value' => null,
];
}
}
21 changes: 20 additions & 1 deletion src/Operators/BetweenOperator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace RuleFlow\Operators;

final class BetweenOperator implements OperatorInterface
final class BetweenOperator implements OperatorInterface, ValidatesValueInterface
{
public function name(): string
{
Expand All @@ -21,4 +21,23 @@ public function evaluate(mixed $actual, mixed $expected): bool

return is_numeric($min) && is_numeric($max) && $actual >= $min && $actual <= $max;
}

public function validateValue(mixed $value): ?string
{
if (!is_array($value) || count($value) !== 2) {
return 'must be an array of exactly two numeric values.';
}

[$min, $max] = array_values($value);

if (!is_numeric($min) || !is_numeric($max)) {
return 'must be an array of exactly two numeric values.';
}

if ($min > $max) {
return 'minimum must not be greater than maximum.';
}

return null;
}
}
Loading