diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..31af517
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,20 @@
+name: "CodeQL"
+
+on: [pull_request]
+
+jobs:
+ lint:
+ name: CodeQL
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Run CodeQL
+ env:
+ COMPOSER_ROOT_VERSION: dev-main
+ run: |
+ docker run --rm -v $PWD:/app -w /app -e COMPOSER_ROOT_VERSION=$COMPOSER_ROOT_VERSION composer:2.8 sh -c \
+ "git config --global --add safe.directory /app && composer install --profile --ignore-platform-reqs && composer check"
+
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..5f1247e
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,20 @@
+name: "Linter"
+
+on: [pull_request]
+
+jobs:
+ lint:
+ name: Linter
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Run Linter
+ env:
+ COMPOSER_ROOT_VERSION: dev-main
+ run: |
+ docker run --rm -v $PWD:/app -w /app -e COMPOSER_ROOT_VERSION=$COMPOSER_ROOT_VERSION composer:2.8 sh -c \
+ "git config --global --add safe.directory /app && composer install --profile --ignore-platform-reqs && composer lint"
+
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..4699c2d
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,33 @@
+name: "Tests"
+
+on: [pull_request]
+
+jobs:
+ tests:
+ name: PHP ${{ matrix.php }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.2', '8.3', '8.4', '8.5']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+ extensions: mbstring
+
+ - name: Validate composer.json and composer.lock
+ run: composer validate --strict
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-interaction --no-progress
+
+ - name: Run unit tests
+ run: composer test
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..794c2e8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+vendor/
+/.phpunit.cache
+var/
+
diff --git a/README.md b/README.md
index 1f0a85f..33fbcbf 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,111 @@
-# waf
-Lite & fast micro PHP WAF rules management that is easy to learn.
+# Utopia WAF
+
+Lite & fast micro PHP Web Application Firewall (WAF) rules management library that is **easy to use** and fits naturally inside the [Utopia](https://github.com/utopia-php) ecosystem.
+
+The library ships with:
+
+- A `Condition` builder that mirrors the API of [`Utopia\Database\Query`](https://github.com/utopia-php/database/blob/main/src/Database/Query.php), including JSON parsing helpers and logical operators.
+- Action specific rule classes (`Bypass`, `Deny`, `Challenge`, `RateLimit`, `Redirect`).
+- A dependency-free `Firewall` orchestrator that evaluates rules against any set of request attributes.
+
+## Installation
+
+```bash
+composer require utopia-php/waf
+```
+
+## Usage
+
+```php
+setAttribute('requestIP', '127.0.0.1');
+$firewall->setAttribute('requestMethod', 'GET');
+$firewall->setAttribute('requestPath', '/index');
+$firewall->setAttribute('headers', [
+ 'X-Country' => 'US',
+]);
+
+$firewall->addRule(new Deny([
+ Condition::equal('ip', ['127.0.0.1']),
+ Condition::notEqual('path', '/status'),
+]));
+
+$firewall->addRule(new Bypass([
+ Condition::equal('country', ['US']),
+ Condition::equal('method', ['GET']),
+]));
+
+$firewall->addRule(new Challenge([
+ Condition::startsWith('path', '/admin'),
+], Challenge::TYPE_CAPTCHA));
+
+$firewall->addRule(new RateLimit([
+ Condition::equal('method', ['POST']),
+], limit: 100, interval: 3600));
+
+$firewall->addRule(new Redirect([
+ Condition::startsWith('path', '/legacy'),
+], location: '/new-home', statusCode: 301));
+
+var_dump($firewall->verify()); // bool(true|false)
+
+if ($rule = $firewall->getLastMatchedRule()) {
+ echo 'Matched action: ' . $rule->getAction();
+}
+```
+
+### Building Conditions
+
+Conditions can be created fluently or by parsing JSON definitions:
+
+```php
+$condition = Condition::and([
+ Condition::equal('ip', ['10.0.0.1']),
+ Condition::notEqual('path', '/health'),
+]);
+
+$json = $condition->toString();
+$parsed = Condition::parse($json);
+```
+
+Available operators mirror the database query builder: `equal`, `notEqual`, `lessThan`, `greaterThan`, `contains`, `between`, `startsWith`, `endsWith`, `isNull`, `and`, `or`, and more.
+
+### Rate Limiting
+
+`RateLimit` rules only store the metadata required for external throttling (`limit` + `interval`). Once a rate limit rule matches, the firewall returns `true` and exposes the matched rule via `getLastMatchedRule()` so you can call any third-party rate limiter with the provided metadata.
+
+```php
+$firewall->addRule(new RateLimit([
+ Condition::equal('ip', ['203.0.113.12']),
+], limit: 500, interval: 60));
+
+if ($firewall->verify()) {
+ $matched = $firewall->getLastMatchedRule();
+ if ($matched instanceof RateLimit) {
+ // Invoke your preferred rate limiter here using $matched->getLimit() and $matched->getInterval()
+ }
+}
+```
+
+### Testing Locally
+
+```bash
+composer install
+composer test
+```
+
+## License
+
+MIT
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..1a60556
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,33 @@
+{
+ "name": "utopia-php/waf",
+ "description": "Lite and extensible Web Application Firewall rules management library for the Utopia PHP ecosystem.",
+ "type": "library",
+ "license": "MIT",
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.18",
+ "phpstan/phpstan": "^1.11",
+ "phpunit/phpunit": "^11.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Utopia\\WAF\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Utopia\\WAF\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "check": "vendor/bin/phpstan analyse -c phpstan.neon --memory-limit 512M",
+ "lint": "vendor/bin/pint --test",
+ "format": "vendor/bin/pint",
+ "test": "vendor/bin/phpunit --configuration phpunit.xml"
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
+}
+
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..bb92df9
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,1909 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "2ea83540769daf9a5da7ac560f807ae3",
+ "packages": [],
+ "packages-dev": [
+ {
+ "name": "laravel/pint",
+ "version": "v1.26.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/pint.git",
+ "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f",
+ "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-tokenizer": "*",
+ "ext-xml": "*",
+ "php": "^8.2.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.90.0",
+ "illuminate/view": "^12.40.1",
+ "larastan/larastan": "^3.8.0",
+ "laravel-zero/framework": "^12.0.4",
+ "mockery/mockery": "^1.6.12",
+ "nunomaduro/termwind": "^2.3.3",
+ "pestphp/pest": "^3.8.4"
+ },
+ "bin": [
+ "builds/pint"
+ ],
+ "type": "project",
+ "autoload": {
+ "psr-4": {
+ "App\\": "app/",
+ "Database\\Seeders\\": "database/seeders/",
+ "Database\\Factories\\": "database/factories/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "An opinionated code formatter for PHP.",
+ "homepage": "https://laravel.com",
+ "keywords": [
+ "dev",
+ "format",
+ "formatter",
+ "lint",
+ "linter",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/pint/issues",
+ "source": "https://github.com/laravel/pint"
+ },
+ "time": "2025-11-25T21:15:52+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.6.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "3a454ca033b9e06b63282ce19562e892747449bb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb",
+ "reference": "3a454ca033b9e06b63282ce19562e892747449bb",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2"
+ },
+ "time": "2025-10-21T19:32:17+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "1.12.32",
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8",
+ "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://phpstan.org/user-guide/getting-started",
+ "forum": "https://github.com/phpstan/phpstan/discussions",
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "security": "https://github.com/phpstan/phpstan/security/policy",
+ "source": "https://github.com/phpstan/phpstan-src"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ }
+ ],
+ "time": "2025-09-30T10:16:31+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "11.0.11",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4",
+ "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^5.4.0",
+ "php": ">=8.2",
+ "phpunit/php-file-iterator": "^5.1.0",
+ "phpunit/php-text-template": "^4.0.1",
+ "sebastian/code-unit-reverse-lookup": "^4.0.1",
+ "sebastian/complexity": "^4.0.1",
+ "sebastian/environment": "^7.2.0",
+ "sebastian/lines-of-code": "^3.0.1",
+ "sebastian/version": "^5.0.2",
+ "theseer/tokenizer": "^1.2.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.5.2"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "11.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-27T14:37:49+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "5.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6",
+ "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-27T05:02:59+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "5.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2",
+ "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^11.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T05:07:44+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
+ "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T05:08:43+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "7.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
+ "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T05:09:35+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "11.5.44",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "c346885c95423eda3f65d85a194aaa24873cda82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c346885c95423eda3f65d85a194aaa24873cda82",
+ "reference": "c346885c95423eda3f65d85a194aaa24873cda82",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.2",
+ "phpunit/php-code-coverage": "^11.0.11",
+ "phpunit/php-file-iterator": "^5.1.0",
+ "phpunit/php-invoker": "^5.0.1",
+ "phpunit/php-text-template": "^4.0.1",
+ "phpunit/php-timer": "^7.0.1",
+ "sebastian/cli-parser": "^3.0.2",
+ "sebastian/code-unit": "^3.0.3",
+ "sebastian/comparator": "^6.3.2",
+ "sebastian/diff": "^6.0.2",
+ "sebastian/environment": "^7.2.1",
+ "sebastian/exporter": "^6.3.2",
+ "sebastian/global-state": "^7.0.2",
+ "sebastian/object-enumerator": "^6.0.1",
+ "sebastian/type": "^5.1.3",
+ "sebastian/version": "^5.0.2",
+ "staabm/side-effects-detector": "^1.0.5"
+ },
+ "suggest": {
+ "ext-soap": "To be able to generate mocks based on WSDL files"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "11.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.44"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsors.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-11-13T07:17:35+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180",
+ "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T04:41:36+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64",
+ "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "security": "https://github.com/sebastianbergmann/code-unit/security/policy",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-03-19T07:56:08+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "183a9b2632194febd219bb9246eee421dad8d45e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e",
+ "reference": "183a9b2632194febd219bb9246eee421dad8d45e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T04:45:54+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "6.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8",
+ "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.2",
+ "sebastian/diff": "^6.0",
+ "sebastian/exporter": "^6.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.4"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.3-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-10T08:07:46+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "ee41d384ab1906c68852636b6de493846e13e5a0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0",
+ "reference": "ee41d384ab1906c68852636b6de493846e13e5a0",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T04:49:50+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "6.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544",
+ "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0",
+ "symfony/process": "^4.2 || ^5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T04:53:05+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "7.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4",
+ "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.3"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-05-21T11:55:47+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "6.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "70a298763b40b213ec087c51c739efcaa90bcd74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74",
+ "reference": "70a298763b40b213ec087c51c739efcaa90bcd74",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.2",
+ "sebastian/recursion-context": "^6.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.3-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-24T06:12:51+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "7.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "3be331570a721f9a4b5917f4209773de17f747d7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7",
+ "reference": "3be331570a721f9a4b5917f4209773de17f747d7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "sebastian/object-reflector": "^4.0",
+ "sebastian/recursion-context": "^6.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T04:57:36+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "3.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a",
+ "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T04:58:38+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "6.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "f5b498e631a74204185071eb41f33f38d64608aa"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa",
+ "reference": "f5b498e631a74204185071eb41f33f38d64608aa",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "sebastian/object-reflector": "^4.0",
+ "sebastian/recursion-context": "^6.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T05:00:13+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9",
+ "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-07-03T05:01:32+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "6.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc",
+ "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-13T04:42:22+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "5.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
+ "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/5.1.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-09T06:55:48+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "5.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874",
+ "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "security": "https://github.com/sebastianbergmann/version/security/policy",
+ "source": "https://github.com/sebastianbergmann/version/tree/5.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-09T05:16:32+00:00"
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-20T05:08:20+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-11-17T20:03:58+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": true,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.2"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.6.0"
+}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..44afaee
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,7 @@
+parameters:
+ level: 6
+ paths:
+ - src
+ - tests
+ tmpDir: var/phpstan
+
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..5580fa4
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ tests
+
+
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..65b49a8
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,8 @@
+{
+ "preset": "psr12",
+ "rules": {
+ "no_unused_imports": true,
+ "single_quote": true
+ }
+}
+
diff --git a/src/Condition.php b/src/Condition.php
new file mode 100644
index 0000000..db28855
--- /dev/null
+++ b/src/Condition.php
@@ -0,0 +1,572 @@
+
+ */
+ private const TYPES = [
+ self::TYPE_EQUAL,
+ self::TYPE_NOT_EQUAL,
+ self::TYPE_LESS_THAN,
+ self::TYPE_LESS_THAN_EQUAL,
+ self::TYPE_GREATER_THAN,
+ self::TYPE_GREATER_THAN_EQUAL,
+ self::TYPE_BETWEEN,
+ self::TYPE_NOT_BETWEEN,
+ self::TYPE_CONTAINS,
+ self::TYPE_NOT_CONTAINS,
+ self::TYPE_STARTS_WITH,
+ self::TYPE_NOT_STARTS_WITH,
+ self::TYPE_ENDS_WITH,
+ self::TYPE_NOT_ENDS_WITH,
+ self::TYPE_IS_NULL,
+ self::TYPE_IS_NOT_NULL,
+ self::TYPE_AND,
+ self::TYPE_OR,
+ ];
+
+ private string $method;
+
+ private string $attribute;
+
+ /**
+ * @var array
+ */
+ private array $values;
+
+ /**
+ * @param array $values
+ */
+ public function __construct(string $method, string $attribute = '', array $values = [])
+ {
+ if (!self::isMethod($method)) {
+ throw new ConditionException("Unsupported condition method: {$method}");
+ }
+
+ $this->method = $method;
+ $this->attribute = $attribute;
+ $this->values = $this->normalizeValues($values);
+ }
+
+ public function __clone(): void
+ {
+ foreach ($this->values as $index => $value) {
+ if ($value instanceof self) {
+ $this->values[$index] = clone $value;
+ }
+ }
+ }
+
+ public function getMethod(): string
+ {
+ return $this->method;
+ }
+
+ public function getAttribute(): string
+ {
+ return $this->attribute;
+ }
+
+ /**
+ * @return array
+ */
+ public function getValues(): array
+ {
+ return $this->values;
+ }
+
+ public function isLogical(): bool
+ {
+ return \in_array($this->method, self::LOGICAL_TYPES, true);
+ }
+
+ public static function isMethod(string $value): bool
+ {
+ return \in_array($value, self::TYPES, true);
+ }
+
+ /**
+ * Parses a JSON encoded condition.
+ */
+ public static function parse(string $payload): self
+ {
+ try {
+ $decoded = \json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new ConditionException('Invalid condition payload: ' . $exception->getMessage());
+ }
+
+ if (!\is_array($decoded)) {
+ throw new ConditionException('Invalid condition payload. Expecting array definition.');
+ }
+
+ return self::fromArray($decoded);
+ }
+
+ /**
+ * @param array $payload
+ */
+ public static function fromArray(array $payload): self
+ {
+ $method = $payload['method'] ?? '';
+ $attribute = $payload['attribute'] ?? '';
+ $values = $payload['values'] ?? [];
+
+ if (!\is_string($method)) {
+ throw new ConditionException('Invalid condition method definition.');
+ }
+
+ if (!\is_string($attribute)) {
+ throw new ConditionException('Invalid condition attribute definition.');
+ }
+
+ if (!\is_array($values)) {
+ throw new ConditionException('Invalid condition values definition.');
+ }
+
+ if (\in_array($method, self::LOGICAL_TYPES, true)) {
+ $values = array_map(
+ static function (mixed $value): self {
+ if (!\is_array($value)) {
+ throw new ConditionException('Invalid nested condition definition.');
+ }
+
+ return self::fromArray($value);
+ },
+ $values
+ );
+ }
+
+ return new self($method, $attribute, $values);
+ }
+
+ /**
+ * @param array> $conditions
+ * @return array
+ */
+ public static function fromArrays(array $conditions): array
+ {
+ return array_map(static fn (array $condition): self => self::fromArray($condition), $conditions);
+ }
+
+ /**
+ * @return array{
+ * method: string,
+ * attribute?: string,
+ * values: array
+ * }
+ */
+ public function toArray(): array
+ {
+ $result = ['method' => $this->method];
+
+ if ($this->attribute !== '') {
+ $result['attribute'] = $this->attribute;
+ }
+
+ if ($this->isLogical()) {
+ $result['values'] = array_map(
+ static fn (self $condition): array => $condition->toArray(),
+ $this->values
+ );
+ } else {
+ $result['values'] = $this->values;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Encode condition as JSON string.
+ *
+ * @throws ConditionException
+ */
+ public function toString(): string
+ {
+ try {
+ return \json_encode($this->toArray(), flags: JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new ConditionException('Unable to encode condition: ' . $exception->getMessage());
+ }
+ }
+
+ /**
+ * @param array $values
+ */
+ public static function equal(string $attribute, array $values): self
+ {
+ return new self(self::TYPE_EQUAL, $attribute, $values);
+ }
+
+ public static function notEqual(string $attribute, string|int|float|bool $value): self
+ {
+ return new self(self::TYPE_NOT_EQUAL, $attribute, [$value]);
+ }
+
+ public static function lessThan(string $attribute, string|int|float $value): self
+ {
+ return new self(self::TYPE_LESS_THAN, $attribute, [$value]);
+ }
+
+ public static function lessThanEqual(string $attribute, string|int|float $value): self
+ {
+ return new self(self::TYPE_LESS_THAN_EQUAL, $attribute, [$value]);
+ }
+
+ public static function greaterThan(string $attribute, string|int|float $value): self
+ {
+ return new self(self::TYPE_GREATER_THAN, $attribute, [$value]);
+ }
+
+ public static function greaterThanEqual(string $attribute, string|int|float $value): self
+ {
+ return new self(self::TYPE_GREATER_THAN_EQUAL, $attribute, [$value]);
+ }
+
+ /**
+ * @param array $values
+ */
+ public static function contains(string $attribute, array $values): self
+ {
+ return new self(self::TYPE_CONTAINS, $attribute, $values);
+ }
+
+ /**
+ * @param array $values
+ */
+ public static function notContains(string $attribute, array $values): self
+ {
+ return new self(self::TYPE_NOT_CONTAINS, $attribute, $values);
+ }
+
+ public static function between(string $attribute, string|int|float $start, string|int|float $end): self
+ {
+ return new self(self::TYPE_BETWEEN, $attribute, [$start, $end]);
+ }
+
+ public static function notBetween(string $attribute, string|int|float $start, string|int|float $end): self
+ {
+ return new self(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]);
+ }
+
+ public static function startsWith(string $attribute, string $value): self
+ {
+ return new self(self::TYPE_STARTS_WITH, $attribute, [$value]);
+ }
+
+ public static function notStartsWith(string $attribute, string $value): self
+ {
+ return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]);
+ }
+
+ public static function endsWith(string $attribute, string $value): self
+ {
+ return new self(self::TYPE_ENDS_WITH, $attribute, [$value]);
+ }
+
+ public static function notEndsWith(string $attribute, string $value): self
+ {
+ return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]);
+ }
+
+ public static function isNull(string $attribute): self
+ {
+ return new self(self::TYPE_IS_NULL, $attribute);
+ }
+
+ public static function isNotNull(string $attribute): self
+ {
+ return new self(self::TYPE_IS_NOT_NULL, $attribute);
+ }
+
+ /**
+ * @param array $conditions
+ */
+ public static function and(array $conditions): self
+ {
+ return new self(self::TYPE_AND, '', $conditions);
+ }
+
+ /**
+ * @param array $conditions
+ */
+ public static function or(array $conditions): self
+ {
+ return new self(self::TYPE_OR, '', $conditions);
+ }
+
+ /**
+ * Evaluate the condition against resolved attributes.
+ *
+ * @param array $attributes
+ */
+ public function matches(array $attributes): bool
+ {
+ if ($this->isLogical()) {
+ return $this->matchesLogical($attributes);
+ }
+
+ $value = $this->resolveValue($attributes);
+
+ return match ($this->method) {
+ self::TYPE_EQUAL => $this->matchesEqual($value),
+ self::TYPE_NOT_EQUAL => !$this->matchesEqual($value),
+ self::TYPE_LESS_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result < 0),
+ self::TYPE_LESS_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result <= 0),
+ self::TYPE_GREATER_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result > 0),
+ self::TYPE_GREATER_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result >= 0),
+ self::TYPE_CONTAINS => $this->matchesContains($value, $this->values),
+ self::TYPE_NOT_CONTAINS => !$this->matchesContains($value, $this->values),
+ self::TYPE_BETWEEN => $this->matchesRange($value, true),
+ self::TYPE_NOT_BETWEEN => !$this->matchesRange($value, true),
+ self::TYPE_STARTS_WITH => $this->matchesPrefix($value),
+ self::TYPE_NOT_STARTS_WITH => !$this->matchesPrefix($value),
+ self::TYPE_ENDS_WITH => $this->matchesSuffix($value),
+ self::TYPE_NOT_ENDS_WITH => !$this->matchesSuffix($value),
+ self::TYPE_IS_NULL => $value === null,
+ self::TYPE_IS_NOT_NULL => $value !== null,
+ default => false,
+ };
+ }
+
+ /**
+ * @param array $values
+ * @return array
+ */
+ private function normalizeValues(array $values): array
+ {
+ if (!$this->isLogical()) {
+ return $values;
+ }
+
+ return array_map(static function (mixed $value): self {
+ if ($value instanceof self) {
+ return $value;
+ }
+
+ if (!\is_array($value)) {
+ throw new ConditionException('Logical conditions require nested condition definitions.');
+ }
+
+ return self::fromArray($value);
+ }, $values);
+ }
+
+ /**
+ * @param array $attributes
+ */
+ private function matchesLogical(array $attributes): bool
+ {
+ if ($this->method === self::TYPE_AND) {
+ foreach ($this->values as $condition) {
+ if (!$condition->matches($attributes)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ foreach ($this->values as $condition) {
+ if ($condition->matches($attributes)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function matchesEqual(mixed $value): bool
+ {
+ foreach ($this->values as $expected) {
+ if ($expected === $value) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array $needles
+ */
+ private function matchesContains(mixed $value, array $needles): bool
+ {
+ if (\is_array($value)) {
+ return \count(array_intersect($value, $needles)) > 0;
+ }
+
+ if (\is_string($value)) {
+ foreach ($needles as $needle) {
+ if (\is_string($needle) && $needle !== '' && str_contains($value, $needle)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private function matchesRange(mixed $value, bool $inclusive): bool
+ {
+ if (\count($this->values) < 2) {
+ return false;
+ }
+
+ [$start, $end] = $this->values;
+
+ if ($value === null || $start === null || $end === null) {
+ return false;
+ }
+
+ $startComparison = $this->compare($value, $start);
+ $endComparison = $this->compare($value, $end);
+
+ if ($startComparison === null || $endComparison === null) {
+ return false;
+ }
+
+ return $inclusive
+ ? $startComparison >= 0 && $endComparison <= 0
+ : $startComparison > 0 && $endComparison < 0;
+ }
+
+ private function matchesPrefix(mixed $value): bool
+ {
+ $prefix = $this->values[0] ?? null;
+
+ if (!\is_string($value) || !\is_string($prefix)) {
+ return false;
+ }
+
+ return str_starts_with($value, $prefix);
+ }
+
+ private function matchesSuffix(mixed $value): bool
+ {
+ $suffix = $this->values[0] ?? null;
+
+ if (!\is_string($value) || !\is_string($suffix)) {
+ return false;
+ }
+
+ return str_ends_with($value, $suffix);
+ }
+
+ /**
+ * @param array $attributes
+ */
+ private function resolveValue(array $attributes): mixed
+ {
+ if ($this->attribute === '') {
+ return null;
+ }
+
+ if (array_key_exists($this->attribute, $attributes)) {
+ return $attributes[$this->attribute];
+ }
+
+ if (str_contains($this->attribute, '.')) {
+ $segments = explode('.', $this->attribute);
+ $current = $attributes;
+
+ foreach ($segments as $segment) {
+ if (\is_array($current) && array_key_exists($segment, $current)) {
+ $current = $current[$segment];
+ continue;
+ }
+
+ return null;
+ }
+
+ return $current;
+ }
+
+ return $attributes[$this->attribute] ?? null;
+ }
+
+ private function compare(mixed $left, mixed $right): ?int
+ {
+ if ($left === null && $right === null) {
+ return 0;
+ }
+
+ if ($left === null || $right === null) {
+ return null;
+ }
+
+ if (\is_numeric($left) && \is_numeric($right)) {
+ return $left <=> $right;
+ }
+
+ if (\is_string($left) && \is_string($right)) {
+ return $left <=> $right;
+ }
+
+ if (\is_bool($left) && \is_bool($right)) {
+ return $left <=> $right;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param callable(int):bool $verdict
+ */
+ private function matchesRelational(mixed $value, mixed $reference, callable $verdict): bool
+ {
+ if ($value === null || $reference === null) {
+ return false;
+ }
+
+ $result = $this->compare($value, $reference);
+
+ if ($result === null) {
+ return false;
+ }
+
+ return $verdict($result);
+ }
+}
diff --git a/src/Exception/Condition.php b/src/Exception/Condition.php
new file mode 100644
index 0000000..a31b76d
--- /dev/null
+++ b/src/Exception/Condition.php
@@ -0,0 +1,7 @@
+
+ */
+ private array $attributes = [];
+
+ /**
+ * @var array
+ */
+ private array $rules = [];
+
+ private ?Rule $lastMatchedRule = null;
+
+ public function setAttribute(string $name, mixed $value): self
+ {
+ foreach ($this->attributeAliases($name) as $key) {
+ $this->attributes[$key] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param array $attributes
+ */
+ public function setAttributes(array $attributes): self
+ {
+ foreach ($attributes as $name => $value) {
+ $this->setAttribute($name, $value);
+ }
+
+ return $this;
+ }
+
+ public function getAttribute(string $name, mixed $default = null): mixed
+ {
+ return $this->attributes[$name] ?? $default;
+ }
+
+ public function addRule(Rule $rule): self
+ {
+ $this->rules[] = $rule;
+
+ return $this;
+ }
+
+ /**
+ * @param array $rules
+ */
+ public function setRules(array $rules): self
+ {
+ $this->rules = $rules;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getRules(): array
+ {
+ return $this->rules;
+ }
+
+ public function clearRules(): self
+ {
+ $this->rules = [];
+
+ return $this;
+ }
+
+ public function getLastMatchedRule(): ?Rule
+ {
+ return $this->lastMatchedRule;
+ }
+
+ /**
+ * Evaluate the registered rules and return true when the request should be allowed.
+ */
+ public function verify(): bool
+ {
+ $this->lastMatchedRule = null;
+
+ foreach ($this->rules as $rule) {
+ if (!$rule->matches($this->attributes)) {
+ continue;
+ }
+
+ $this->lastMatchedRule = $rule;
+
+ return $this->applyRule($rule);
+ }
+
+ return false;
+ }
+
+ private function applyRule(Rule $rule): bool
+ {
+ return match ($rule->getAction()) {
+ Rule::ACTION_BYPASS => true,
+ Rule::ACTION_DENY => false,
+ Rule::ACTION_CHALLENGE => false,
+ Rule::ACTION_RATE_LIMIT => true,
+ Rule::ACTION_REDIRECT => false,
+ default => false,
+ };
+ }
+
+ /**
+ * @return array
+ */
+ private function attributeAliases(string $name): array
+ {
+ $aliases = [$name];
+
+ $normalized = $this->normalizeRequestKey($name);
+ if ($normalized !== $name) {
+ $aliases[] = $normalized;
+ }
+
+ $lower = strtolower($normalized);
+ if (!\in_array($lower, $aliases, true)) {
+ $aliases[] = $lower;
+ }
+
+ return array_unique($aliases);
+ }
+
+ private function normalizeRequestKey(string $name): string
+ {
+ if (stripos($name, 'request') === 0) {
+ $withoutPrefix = substr($name, 7);
+ if ($withoutPrefix !== false && $withoutPrefix !== '') {
+ return lcfirst($withoutPrefix);
+ }
+ }
+
+ return $name;
+ }
+}
diff --git a/src/Rule.php b/src/Rule.php
new file mode 100644
index 0000000..7df6f2b
--- /dev/null
+++ b/src/Rule.php
@@ -0,0 +1,67 @@
+
+ */
+ protected array $conditions = [];
+
+ /**
+ * @param array> $conditions
+ */
+ public function __construct(array $conditions = [])
+ {
+ $this->conditions = array_map(
+ static function (Condition|array $condition): Condition {
+ if ($condition instanceof Condition) {
+ return clone $condition;
+ }
+
+ return Condition::fromArray($condition);
+ },
+ $conditions
+ );
+ }
+
+ abstract public function getAction(): string;
+
+ /**
+ * @return array
+ */
+ public function getConditions(): array
+ {
+ return $this->conditions;
+ }
+
+ public function addCondition(Condition $condition): self
+ {
+ $this->conditions[] = $condition;
+
+ return $this;
+ }
+
+ /**
+ * Evaluate rule conditions against provided attributes.
+ *
+ * @param array $attributes
+ */
+ public function matches(array $attributes): bool
+ {
+ foreach ($this->conditions as $condition) {
+ if (!$condition->matches($attributes)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Rules/Bypass.php b/src/Rules/Bypass.php
new file mode 100644
index 0000000..97f1da3
--- /dev/null
+++ b/src/Rules/Bypass.php
@@ -0,0 +1,13 @@
+> $conditions
+ */
+ public function __construct(array $conditions = [], string $type = self::TYPE_CAPTCHA)
+ {
+ parent::__construct($conditions);
+ if (!in_array($type, [self::TYPE_CAPTCHA, self::TYPE_CUSTOM], true)) {
+ throw new \InvalidArgumentException('Invalid challenge type: ' . $type);
+ }
+ $this->type = $type;
+ }
+
+ public function getAction(): string
+ {
+ return self::ACTION_CHALLENGE;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+}
diff --git a/src/Rules/Deny.php b/src/Rules/Deny.php
new file mode 100644
index 0000000..7b2da01
--- /dev/null
+++ b/src/Rules/Deny.php
@@ -0,0 +1,13 @@
+> $conditions
+ */
+ public function __construct(
+ array $conditions = [],
+ int $limit = 100,
+ int $interval = 3600
+ ) {
+ parent::__construct($conditions);
+ if ($limit < 1 || $interval < 1) {
+ throw new \InvalidArgumentException('Limit and interval must be at least 1');
+ }
+ $this->limit = max(1, $limit);
+ $this->interval = max(1, $interval);
+ }
+
+ public function getAction(): string
+ {
+ return self::ACTION_RATE_LIMIT;
+ }
+
+ public function getLimit(): int
+ {
+ return $this->limit;
+ }
+
+ public function getInterval(): int
+ {
+ return $this->interval;
+ }
+}
diff --git a/src/Rules/Redirect.php b/src/Rules/Redirect.php
new file mode 100644
index 0000000..697359a
--- /dev/null
+++ b/src/Rules/Redirect.php
@@ -0,0 +1,37 @@
+> $conditions
+ */
+ public function __construct(array $conditions = [], string $location = '/', int $statusCode = 302)
+ {
+ parent::__construct($conditions);
+ $this->location = $location;
+ $this->statusCode = $statusCode;
+ }
+
+ public function getAction(): string
+ {
+ return self::ACTION_REDIRECT;
+ }
+
+ public function getLocation(): string
+ {
+ return $this->location;
+ }
+
+ public function getStatusCode(): int
+ {
+ return $this->statusCode;
+ }
+}
diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php
new file mode 100644
index 0000000..7930a62
--- /dev/null
+++ b/tests/ConditionTest.php
@@ -0,0 +1,190 @@
+assertTrue($equal->matches(['ip' => '127.0.0.1']));
+ $this->assertFalse($equal->matches(['ip' => '1.1.1.1']));
+
+ $this->assertTrue($notEqual->matches(['method' => 'GET']));
+ $this->assertFalse($notEqual->matches(['method' => 'POST']));
+ }
+
+ public function testComparisonOperators(): void
+ {
+ $lessThan = Condition::lessThan('count', 10);
+ $lessThanEqual = Condition::lessThanEqual('count', 10);
+ $greaterThan = Condition::greaterThan('count', 5);
+ $greaterThanEqual = Condition::greaterThanEqual('count', 5);
+
+ $this->assertTrue($lessThan->matches(['count' => 9]));
+ $this->assertFalse($lessThan->matches(['count' => 10]));
+
+ $this->assertTrue($lessThanEqual->matches(['count' => 10]));
+ $this->assertTrue($greaterThan->matches(['count' => 6]));
+ $this->assertFalse($greaterThan->matches(['count' => 5]));
+ $this->assertTrue($greaterThanEqual->matches(['count' => 5]));
+ }
+
+ public function testContainsOperators(): void
+ {
+ $stringContains = Condition::contains('path', ['admin', 'dashboard']);
+ $arrayContains = Condition::contains('tags', ['security']);
+ $notContains = Condition::notContains('path', ['forbidden']);
+
+ $this->assertTrue($stringContains->matches(['path' => '/admin/users']));
+ $this->assertFalse($stringContains->matches(['path' => '/public']));
+
+ $this->assertTrue($arrayContains->matches(['tags' => ['security', 'waf']]));
+ $this->assertFalse($arrayContains->matches(['tags' => ['network']]));
+
+ $this->assertTrue($notContains->matches(['path' => '/allowed']));
+ $this->assertFalse($notContains->matches(['path' => '/forbidden']));
+ }
+
+ public function testRangeOperators(): void
+ {
+ $between = Condition::between('latency', 100, 200);
+ $notBetween = Condition::notBetween('latency', 100, 200);
+
+ $this->assertTrue($between->matches(['latency' => 150]));
+ $this->assertTrue($notBetween->matches(['latency' => 50]));
+ $this->assertFalse($notBetween->matches(['latency' => 150]));
+ }
+
+ public function testRelationalOperatorsWithNullValues(): void
+ {
+ $lessThan = Condition::lessThan('count', 5);
+ $greaterThan = Condition::greaterThan('count', 5);
+ $between = Condition::between('count', 1, 10);
+
+ $this->assertFalse($lessThan->matches(['count' => null]));
+ $this->assertFalse($lessThan->matches([]));
+
+ $this->assertFalse($greaterThan->matches(['count' => null]));
+ $this->assertFalse($greaterThan->matches([]));
+
+ $this->assertFalse($between->matches(['count' => null]));
+ $this->assertFalse($between->matches([]));
+ }
+
+ public function testStartsAndEndsOperators(): void
+ {
+ $startsWith = Condition::startsWith('path', '/api');
+ $notStartsWith = Condition::notStartsWith('path', '/admin');
+ $endsWith = Condition::endsWith('path', '.json');
+ $notEndsWith = Condition::notEndsWith('path', '.php');
+
+ $this->assertTrue($startsWith->matches(['path' => '/api/v1']));
+ $this->assertFalse($startsWith->matches(['path' => '/web']));
+
+ $this->assertTrue($notStartsWith->matches(['path' => '/public']));
+ $this->assertFalse($notStartsWith->matches(['path' => '/admin']));
+
+ $this->assertTrue($endsWith->matches(['path' => '/status.json']));
+ $this->assertFalse($endsWith->matches(['path' => '/status.xml']));
+
+ $this->assertTrue($notEndsWith->matches(['path' => '/status']));
+ $this->assertFalse($notEndsWith->matches(['path' => '/index.php']));
+ }
+
+ public function testNullOperatorsAndAttributeResolution(): void
+ {
+ $isNull = Condition::isNull('payload.signature');
+ $isNotNull = Condition::isNotNull('payload.signature');
+
+ $attributes = [
+ 'payload' => [
+ 'signature' => 'abc',
+ ],
+ ];
+
+ $this->assertFalse($isNull->matches($attributes));
+ $this->assertTrue($isNull->matches(['payload' => []]));
+
+ $this->assertTrue($isNotNull->matches($attributes));
+ $this->assertFalse($isNotNull->matches(['payload' => []]));
+ }
+
+ public function testLogicalOperatorsNested(): void
+ {
+ $nested = Condition::and([
+ Condition::equal('method', ['POST']),
+ Condition::or([
+ Condition::equal('path', ['/admin']),
+ Condition::startsWith('path', '/internal'),
+ ]),
+ Condition::notContains('headers.user-agent', ['bot']),
+ ]);
+
+ $this->assertTrue($nested->matches([
+ 'method' => 'POST',
+ 'path' => '/internal/tools',
+ 'headers' => [
+ 'user-agent' => 'Mozilla',
+ ],
+ ]));
+
+ $this->assertFalse($nested->matches([
+ 'method' => 'POST',
+ 'path' => '/public',
+ 'headers' => [
+ 'user-agent' => 'Mozilla',
+ ],
+ ]));
+
+ $this->assertFalse($nested->matches([
+ 'method' => 'POST',
+ 'path' => '/internal/ops',
+ 'headers' => [
+ 'user-agent' => 'bot',
+ ],
+ ]));
+ }
+
+ public function testConditionSerializationRoundTrip(): void
+ {
+ $condition = Condition::and([
+ Condition::equal('ip', ['127.0.0.1']),
+ Condition::or([
+ Condition::startsWith('path', '/api'),
+ Condition::endsWith('path', '.json'),
+ ]),
+ ]);
+
+ $json = $condition->toString();
+ $parsed = Condition::parse($json);
+
+ $this->assertTrue($parsed->matches(['ip' => '127.0.0.1', 'path' => '/api/users']));
+ $this->assertTrue($parsed->matches(['ip' => '127.0.0.1', 'path' => '/status.json']));
+ $this->assertFalse($parsed->matches(['ip' => '127.0.0.1', 'path' => '/web']));
+ }
+
+ public function testInvalidMethodThrowsException(): void
+ {
+ $this->expectException(ConditionException::class);
+
+ Condition::fromArray([
+ 'method' => 'unknown',
+ 'attribute' => 'ip',
+ 'values' => [],
+ ]);
+ }
+
+ public function testParseRejectsInvalidJson(): void
+ {
+ $this->expectException(ConditionException::class);
+
+ Condition::parse('{"method":');
+ }
+}
diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php
new file mode 100644
index 0000000..d5a394c
--- /dev/null
+++ b/tests/FirewallTest.php
@@ -0,0 +1,66 @@
+setAttribute('requestIP', '127.0.0.1');
+ $firewall->setAttribute('requestPath', '/index');
+
+ $deny = new Deny([
+ Condition::equal('ip', ['127.0.0.1']),
+ Condition::notEqual('path', '/health'),
+ ]);
+
+ $bypass = new Bypass([
+ Condition::equal('ip', ['127.0.0.1']),
+ ]);
+
+ $firewall->addRule($deny);
+ $firewall->addRule($bypass);
+
+ $this->assertFalse($firewall->verify(), 'Deny should be executed first');
+
+ $firewall->clearRules();
+ $firewall->addRule($bypass);
+ $firewall->addRule($deny);
+
+ $this->assertTrue($firewall->verify(), 'Bypass should pass when it is the first matching rule');
+ }
+
+ public function testRateLimitMetadata(): void
+ {
+ $firewall = new Firewall();
+ $firewall->setAttributes([
+ 'requestIP' => '192.168.1.10',
+ 'requestPath' => '/api',
+ ]);
+
+ $rateLimit = new RateLimit([
+ Condition::equal('ip', ['192.168.1.10']),
+ ], limit: 2, interval: 60);
+
+ $firewall->addRule($rateLimit);
+
+ $this->assertTrue($firewall->verify());
+ $matched = $firewall->getLastMatchedRule();
+
+ $this->assertInstanceOf(RateLimit::class, $matched);
+ if (!$matched instanceof RateLimit) {
+ return;
+ }
+
+ $this->assertSame(2, $matched->getLimit());
+ $this->assertSame(60, $matched->getInterval());
+ }
+}
diff --git a/tests/RulesTest.php b/tests/RulesTest.php
new file mode 100644
index 0000000..4b848d5
--- /dev/null
+++ b/tests/RulesTest.php
@@ -0,0 +1,62 @@
+assertTrue($rule->matches(['ip' => '127.0.0.1']));
+ $this->assertSame('bypass', $rule->getAction());
+ }
+
+ public function testDenyRule(): void
+ {
+ $rule = new Deny([
+ Condition::equal('method', ['POST']),
+ ]);
+
+ $this->assertTrue($rule->matches(['method' => 'POST']));
+ $this->assertSame('deny', $rule->getAction());
+ }
+
+ public function testChallengeRuleTypeDefaults(): void
+ {
+ $defaultRule = new Challenge();
+ $customRule = new Challenge([], Challenge::TYPE_CUSTOM);
+
+ $this->assertSame('challenge', $defaultRule->getAction());
+ $this->assertSame(Challenge::TYPE_CAPTCHA, $defaultRule->getType());
+ $this->assertSame(Challenge::TYPE_CUSTOM, $customRule->getType());
+ }
+
+ public function testRateLimitMetadata(): void
+ {
+ $rule = new RateLimit([], limit: 10, interval: 600);
+
+ $this->assertSame('rateLimit', $rule->getAction());
+ $this->assertSame(10, $rule->getLimit());
+ $this->assertSame(600, $rule->getInterval());
+ }
+
+ public function testRedirectRule(): void
+ {
+ $rule = new Redirect([], location: '/new', statusCode: 301);
+
+ $this->assertSame('redirect', $rule->getAction());
+ $this->assertSame('/new', $rule->getLocation());
+ $this->assertSame(301, $rule->getStatusCode());
+ }
+}