PHPStan extension that reports the regex patterns your target PHP refuses, and, opt-in, lint, ReDoS and optimization findings.
Requires PHP 8.2+ and PHPStan 2.x. MIT licensed.
- Reports the patterns your target PHP refuses while the engine running PHPStan compiles them; what that engine refuses stays with PHPStan core, never reported twice.
- Reads the eight
preg_*functions, patterns held in constants and constant expressions, and the array keys ofpreg_replace_callback_array. - Reads the constant pattern passed to a parameter marked
#[PHPRegex\Parser\Attribute\RegexPattern]or PhpStorm's#[Language('RegExp')], in calls to functions, static and instance methods and constructors: a pattern the running PHP refuses is reported there under PHPStan'sregexp.pattern, as PHPStan core reads onlypreg_*calls, and every other check applies as to apreg_*pattern. PHPStan offers parameter attributes from 2.1.31 at least; on an older 2.x release that lacks them, no parameter is read. - Reports, always, a constant replacement of
preg_replace()orpreg_filter()that refers to a group the pattern does not have ($2,${2},\2, and$10, which PHP reads as group 10, never group 1 then0), or that names a group (${name}, which PHP never substitutes). The replacement is read after PHP's string escapes, an array of patterns paired with the replacement of the same position. When both the pattern and the replacement may take several values, which may vary together (a ternary on one condition, two maps read with one key), a reference is reported only if no possible pattern defines its group. - Opt-in lint: the 40 lint rules of php-regex/regex-linter, each finding carrying its rule identifier and a tip.
- Opt-in ReDoS analysis, theoretical only — the pattern is read, never run inside PHPStan — with four severity thresholds: a proven exponential or polynomial verdict, or a heuristic one, with the attack input in the tip, and, under
regex.redos.search, the quadratic cost of an unanchored search whose every attempt is linear. A call whose subject PHPStan knows to be constant is not reported: no input can reach it. - Opt-in optimization suggestions behind a minimum-savings setting; every rewrite is proven equivalent by the automata solver before it is reported.
- With optimizations on, a
preg_match($pattern, $subject)a string function answers alike is reported with the function:/^https:/isstr_starts_with($subject, 'https:'),/^(?:GET|POST)\z/anin_array(). Each is proven by the automata;/^foo$/is no===, as$also takes"foo\n". - Stable identifiers for
ignoreErrorsand baselines:regex.invalidForTarget,regexp.pattern(a marked parameter),regex.replacement.undefinedGroup,regex.redos,regex.redos.search,regex.optimization,regex.trivialMatch,regex.lint.<rule>. - The
phpRegexparameter is validated by a Neon schema before analysis starts; a version or threshold that names no real value stops the run there.
composer require --dev php-regex/regex-phpstanWith phpstan/extension-installer this is everything: the extension registers itself.
Without it, include the extension in your phpstan.neon:
includes:
- vendor/php-regex/regex-phpstan/extension.neonrules.neon turns the lint rules and the ReDoS analysis on — optimizations
stay off, enable them with your own parameters:
includes:
- vendor/php-regex/regex-phpstan/extension.neon
- vendor/php-regex/regex-phpstan/rules.neonEverything lives under the phpRegex parameter; suppress findings by identifier
in ignoreErrors, as with any PHPStan rule. The defaults as shipped:
| Option | Default | Meaning |
|---|---|---|
phpVersion |
null |
PHP the patterns are judged for: null for PHPStan's phpVersion (a {min, max} range there is validated at each later PHP up to max where a rule changes), 'runtime' for the PHP running the analysis, '8.2' or 80200 for a release |
pcreVersion |
null |
PCRE2 release the patterns are judged for, '10.42'; null for the one the PHP version bundles |
checks.lint.enabled |
false |
lint rules |
checks.redos.enabled |
false |
ReDoS analysis |
checks.redos.threshold |
critical |
lowest severity reported: low, medium, high or critical |
checks.optimizations.enabled |
false |
optimization suggestions |
checks.optimizations.minSavings |
1 |
characters saved before a suggestion is reported |
checks.optimizations.options |
as shipped | which rewrites the optimizer may apply: digits, word, ranges, canonicalizeCharClasses and verifyWithAutomata on, possessive and factorize off, minQuantifierCount at 4 |
The default check needs no configuration beyond the include — run
vendor/bin/phpstan analyse; the target is PHPStan's phpVersion, here PHP 8.2:
// (*scs:...) arrived in PCRE2 10.45; PHP 8.2 bundles 10.40.
preg_match('/(a)(*scs:(1)a)/', $value);Regex pattern is invalid for PHP 8.2 with PCRE2 10.40: Invalid or unsupported PCRE verb: "scs".
🪪 regex.invalidForTarget
A replacement that refers to a missing group is reported with no configuration either:
preg_replace('/(a)/', '[$10]', $value); // "[]": there is no group 10Replacement reference $10 names group 10, but /(a)/ has 1 capturing group: preg_replace() substitutes an empty string.
🪪 regex.replacement.undefinedGroup
💡 Two digits are read after "$": write ${1}0 for group 1 followed by "0".
With rules.neon, lint and ReDoS findings appear:
preg_match('/no_dot/s', $value); // flag 's' with no dot to match
preg_match('/(a+)+$/', $value); // nested unbounded quantifiersFlag 's' is useless: the pattern contains no unescaped dot outside a character class.
🪪 regex.lint.flag.useless.s
Nested quantifiers can cause catastrophic backtracking.
🪪 regex.lint.quantifier.nested
💡 Consider atomic groups (?>...) or possessive quantifiers — verify the rewrite still matches everything you need.
Exponential backtracking (ReDoS): /(a+)+$/
🪪 regex.redos
💡 Severity: critical, exponential (proven).
💡 Attack: "a" x n . "!"
A ReDoS message is Exponential backtracking (ReDoS), Polynomial backtracking (ReDoS) or Potential backtracking (ReDoS), then the pattern as the console shows it (escaped, on one line, cut after 50 characters); the text of each stays the same for all of 2.x, and the severity, how the verdict was reached and the attack (str_repeat("a", $n) . "!") are in the tip. When the analysis improves, an error may appear, disappear or change class: regenerate the baseline after such an upgrade, and once after moving from 1.x.
Every lint issue is a PHPStan error, whatever the rule's severity: an issue the regex lint console prints as INFO, such as regex.lint.group.quantifiedCapture on an unnamed group, is reported too, under its own identifier, so you can ignore it by identifier. The rules the linter leaves off by default, such as regex.lint.unicode.shorthandWithoutU or the style rule regex.lint.charclass.single, do not run here. Lint messages and the set of reported issues moved in 2.0.0: after upgrading, regenerate the baseline once with vendor/bin/phpstan analyse --generate-baseline.
Optimizations, once enabled, suggest the shorter equivalent as a tip:
preg_match('/[0-9]+/', $value);Regex pattern can be optimized: "/[0-9]+/"
🪪 regex.optimization
💡 Consider using: /\d+/
- PHPStan guide — the target model, each check, every identifier
- Diagnostics — how findings are reported and how to read them
- ReDoS guide — risky shapes, severities, mitigations
- Quick start — the PHPRegex packages in five commands
This package is part of PHPRegex, released with its siblings under one version number. Read the backward compatibility promise.
- Documentation
- The linter behind the opt-in checks: regex-linter
- Changelog
- Report issues and send pull requests in the main PHPRegex repository
If PHPRegex saves you time, consider sponsoring its maintenance.
MIT. See LICENSE.