Symfony Bundle Guide

The bundle registers a Regex service for your application and the bin/console regex:* commands. Its configuration lives under the php_regex key.

Requires PHP 8.2 or later and Symfony 7.4 or 8.x — 6.4 LTS is not supported. The bundle ships no Symfony Flex recipe: register it by hand in config/bundles.php.

Installation

composer require --dev php-regex/php-regex:2.x-dev
// config/bundles.php
return [
    // ...
    PHPRegex\Symfony\PHPRegexBundle::class => ['dev' => true, 'test' => true],
];

Configuration

Every key is optional; the values below are the defaults.

# config/packages/php_regex.yaml
php_regex:
    max_pattern_length: 100000
    max_lookbehind_length: 255
    # Whether the php_regex.regex service also compiles every pattern
    # with the running PHP. regex:lint never does.
    runtime_pcre_validation: false
    # The PHP version and PCRE2 release regex:lint judges patterns for.
    php_version: null      # "8.2", "8.2.4" or 80200
    pcre_version: null     # "10.42"
    cache:
        pool: null         # a PSR-6 pool service id, used before "directory"
        directory: '%kernel.cache_dir%/php_regex'
        prefix: regex_
    extractor_service: null
    redos:
        enabled: false
        threshold: high    # low, medium, high or critical, in any case
        ignored_patterns: []
    analysis:
        warning_threshold: 50
    automata:
        minimization_algorithm: hopcroft         # hopcroft or moore
        determinization_algorithm: subset-indexed # subset or subset-indexed
    optimizations:
        digits: true
        word: true
        ranges: true
        canonicalize_char_classes: true
        possessive: false
        factorize: false
        min_quantifier_count: 4
    paths: [src]
    exclude: [vendor]
    ide: '%env(default::SYMFONY_IDE)%'

A value the bundle cannot read stops the container compile with the key named: an unknown ReDoS threshold, a php_version that is no version, a pcre_version that is no release. safe and unknown are not thresholds: they are the verdicts a pattern gets.

redos.ignored_patterns lists patterns, fragments of patterns or whole regexes the risk analysis skips, such as the requirement constants of Symfony routes.

Using the service

The container registers php_regex.regex, autowirable as PHPRegex\Toolkit\Regex — inject it like any other service:

use PHPRegex\Toolkit\Regex;

final class PatternSupport
{
    public function __construct(private readonly Regex $regex) {}

    public function check(string $pattern): bool
    {
        return $this->regex->validate($pattern)->isValid;
    }
}

validate() returns the object the Laravel facade returns: $result->isValid, $result->error, $result->caretSnippet. The API reference lists every method of the service — parse, explain, redos, optimize, transpile and the rest.

How route requirements and security patterns are read

Each one is read as the pattern Symfony runs:

  • A route requirement is a fragment of the compiled route. Its leading ^ or \A and its trailing $ or \z are stripped as Route::sanitizeRequirement() does, and it is matched with {^...$}sD, plus u when the route sets its utf8 option: \d+ is linted as {^\d+$}sD. A requirement that starts with / or # is no delimited regex there either.
  • A security path (in access_control or as a firewall pattern) is matched with {...}s, and a host with {...}i, without anchors: path: /api matches /v1/api too.

The service and the lint judge for different targets

The php_regex.regex service (autowired as PHPRegex\Toolkit\Regex) runs in your application: it judges patterns for the PHP running it, and php_version / pcre_version do not change that.

regex:lint judges the patterns of your code for the PHP your project supports. It picks the target in this order:

  1. php_regex.php_version and php_regex.pcre_version;
  2. composer.json in %kernel.project_dir%: config.platform.php if set, else the lowest version require.php allows, and then every pattern is also validated on the later PHP versions the constraint allows, as the standalone command does;
  3. the PHP running the command.

Each version is chosen on its own: without pcre_version, the lint uses the PCRE2 release the target PHP bundles (10.40 for PHP 8.2, 10.42 for 8.3, 10.44 for 8.4 and 8.5). A regex.json in the project is the configuration of the standalone vendor/bin/regex command, not of the bundle.

The console report shows the target in its header. The JSON report carries it as a top-level target object:

{
    "target": {"php": "8.2", "pcre": "10.40", "source": "php_regex.php_version", "range": [{"php": "8.2", "pcre": "10.40"}]},
    "stats": {"errors": 0, "warnings": 0, "optimizations": 0, "redos_errors": 0, "infos": 0, "lint_errors": 0, "parser_fallbacks": 0},
    "results": []
}

source names where each version came from: php_regex.php_version, composer.json require.php, composer.json config.platform.php or running PHP, followed by php_regex.pcre_version when the PCRE2 release came from there.

The report is the one vendor/bin/regex lint --format=json prints, and regex:transpile --format=json prints the same document as vendor/bin/regex transpile; the JSON output reference lists every key.

runtime_pcre_validation compiles with the PHP that runs, which cannot tell whether an older PHP accepts a pattern: the lint never uses it, whatever the bundle says.

Commands

Command Description
regex:lint Lint the regex patterns of your PHP code
regex:compare Compare two patterns with automata
regex:routes Detect route conflicts and overlaps
regex:security Analyze access control ordering and firewall regexes
regex:analyze Run the routes and security analyzers
regex:transpile Translate a pattern for another regex engine
bin/console regex:lint
bin/console regex:lint src/ --format=json
bin/console regex:analyze --redos-threshold=medium

--redos-threshold takes the same values as redos.threshold, in any case.

regex:lint reads the functions marked #[RegexPattern] (or PhpStorm’s #[Language('RegExp')]) in the configured paths, with exclude where they are linted, and in %kernel.project_dir%/vendor, whatever exclude says; a project declaration wins over a copy in vendor/. A call to one is linted as a preg_*() call (see the CLI guide).

ReDoS findings

With redos.enabled: true, regex:lint adds the ReDoS issue, regex.lint.redos, to the lint findings of each pattern at or above redos.threshold, as vendor/bin/regex lint --redos does (see the CLI guide): the verdict’s headline, such as Exponential backtracking (proven), then the severity, the confidence and the attack.

regex:security and regex:analyze run the same analysis on the pattern of each firewall. A finding names the verdict and, when it was proven, the attack:

# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api/(\w+/?)+$
            stateless: true
bin/console regex:security
Firewalls : 2
ReDoS >=  : high
Flagged   : 1

   FAIL  1 firewall regex patterns exceed the ReDoS threshold.

Firewall Regex ReDoS
--------------------

   CRIT  api (config/packages/security.yaml:4) CRITICAL score 10
      ↳ Verdict: Exponential backtracking (proven)
      ↳ Pattern: ^/api/(\w+/?)+$
      ↳ Attack: "/api/" . "0" x n . "!"

"/api/" . "0" x n . "!" reads as PHP: "/api/" . str_repeat("0", $n) . "!", a request path that makes preg_match() give up from 19 repetitions under PHP’s default limits. regex:analyze prints the same finding as Verdict and Attack rows, and its JSON report carries them as details:

{
    "kind": "redos",
    "severity": "critical",
    "title": "api (config/packages/security.yaml:4)",
    "details": [
        {"label": "CheckOutcome", "value": "CRITICAL", "kind": "text"},
        {"label": "Verdict", "value": "Exponential backtracking (proven)", "kind": "text"},
        {"label": "Score", "value": "10", "kind": "text"},
        {"label": "Pattern", "value": "^/api/(\\w+/?)+$", "kind": "pattern"},
        {"label": "Attack", "value": "\"/api/\" . \"0\" x n . \"!\"", "kind": "text"}
    ],
    "notes": []
}

The ReDoS guide explains the verdicts and the attack.

Each command exits with 0 when it found nothing wrong, 1 when the patterns or the files it judged have a problem, and 2 when an option or the configuration cannot be used (see the CLI guide).

Upgrading from 1.x

See UPGRADE-2.0.md: exclude_paths is now exclude, analysis.ignore_patterns is merged into redos.ignored_patterns, and analysis.redos_threshold is gone. A 1.x key stops the container compile with the key that replaces it.

redos.enabled: true now makes regex:lint report ReDoS findings: 1.x read the setting and never ran the analysis. Expect new warnings on the first run.

Edit on GitHub