php-regex / regex-infection
Infection mutators for the regex literals of preg_*() calls: no mutant the PHPRegex automata prove equivalent to the original, and each with the subject that kills it when one is found.
Fund package maintenance!
Requires
- php: >=8.1
- infection/mutator: ^0.4
- nikic/php-parser: ^5.0
- php-regex/regex-automata: 2.x-dev
- php-regex/regex-generator: 2.x-dev
- php-regex/regex-parser: 2.x-dev
- php-regex/regex-redos: 2.x-dev
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
- infection/infection: <0.29
Replaces
None
This package is auto-updated.
Last update: 2026-11-10 08:24:41 UTC
README
PHPRegex Infection
Mutators for Infection that mutate the regex literals your code passes to preg_*(): a + turns into *, an anchor goes, a class loses a member. The PHPRegex automata drop every mutant they prove behaves as the original for the call it sits in, so no run of your suite is spent on a mutant no test could kill — and each mutant that survives carries the subject that kills it, written into the pattern as (?#kill: "…"), ready to paste into a test.
Requires Infection 0.29 or later and nikic/php-parser 5. Works with Infection installed through Composer or as its PHAR.
Documentation: php-regex.com — the Infection guide walks the mutators end to end.
Features
- Six mutators, one per family of regex fault:
RegexQuantifier,RegexAnchor,RegexCharClass,RegexAlternation,RegexFlag,RegexEscape - A mutant the automata prove equivalent for the call is never produced:
/a+/→/a/is equivalent forpreg_match($re, $s), which only says whether a match exists, and kept forpreg_match($re, $s, $m), which writes the match - Each mutant that survives says what kills it:
(?#kill: "0\x0a")before the closing delimiter, a PHP double-quoted literal, checked against the running engine for the call as written — its offset, flags, limit and replacement included - Mutants the engine refuses, mutants whose backtracking the ReDoS analysis proves exponential where the original's is not, and mutants alike another one already produced are left out
- Deterministic: the work per pattern is counted in steps, never in time, so the same code gets the same mutants on every machine
- No configuration: the mutators take Infection's own
ignoreandignoreSourceCodeByRegexsettings
Installation
composer require --dev php-regex/regex-infection
The package brings php-regex/regex-parser, regex-automata, regex-redos and regex-generator with it: the parser, the automata that prove two patterns alike, the ReDoS analysis, and the sample generator that looks for a kill string where the automata cannot.
Usage
Enable the mutators in infection.json5, and turn off Infection's own regex mutators, which this package replaces:
{ "mutators": { "@default": true, "PregMatchRemoveCaret": false, "PregMatchRemoveDollar": false, "PregMatchRemoveFlags": false, "PHPRegex\\Infection\\Mutator\\RegexQuantifier": true, "PHPRegex\\Infection\\Mutator\\RegexAnchor": true, "PHPRegex\\Infection\\Mutator\\RegexCharClass": true, "PHPRegex\\Infection\\Mutator\\RegexAlternation": true, "PHPRegex\\Infection\\Mutator\\RegexFlag": true, "PHPRegex\\Infection\\Mutator\\RegexEscape": true } }
PregMatchRemoveFlags removes a modifier the pattern may not use — /^\d+$/i without i matches the same strings — which leaves a mutant no test can kill.
An escaped mutant then reads:
- if (1 === preg_match('/^\d+$/', $id)) { + if (1 === preg_match('/^\d+\z(?#kill: "0\x0a")/', $id)) {
"0\x0a" is "0\n": $ matches before a final newline, \z does not. A test that passes it, and asserts the answer, kills the mutant.
The kill string
- It is a subject for the
preg_*()call as written — what reaches the call, not what reaches your function: a function that trims its input first needs a test that passes something the trim leaves. - It is a PHP double-quoted literal:
$,\,", the parentheses, the braces,#and the delimiters are written\xNN, as is every byte outside printable ASCII. It pastes into a test as is. preg_match_all()without$matchesgets the subject on which the count differs;preg_replace_callback()the one on which the callback receives other matches, as the call's flags shape them, or is called at other places;preg_grep()the element one pattern keeps and the other does not.- It is left out when the literal would be longer than 120 bytes, when the call's offset, flags, limit or replacement is only known at run time, or a number is written as a string, when the pattern's delimiter is
?,:or"or the pattern ends inside an open\Q, when neither the automata nor the sampled subjects tell the two patterns apart, and once the checks a pattern gets are spent. - With the
#delimiter, which(?#would end, it is written(?(DEFINE)kill: "…"), every byte but letters, digits and spaces as\xNN.
What is read
A string literal written as the pattern of a native preg_match(), preg_match_all(), preg_replace(), preg_replace_callback(), preg_split(), preg_grep() or preg_filter() call, by position or as pattern:, inside a function — Infection mutates nothing else. A pattern in a constant, a variable, a concatenation or an array, Safe\preg_match(), Composer\Pcre\Preg, Laravel's regex: rule and the #[Assert\Regex] constraint are not read; a pattern PHPRegex cannot read is skipped without a word — regex lint shows which.
Two mutators of another package with the same short name would replace one another: Infection keys mutators by getName().
Documentation
- The Infection guide — each mutator, the kill string, what is dropped and why
- Choose a package — the other packages of the family
- Backward compatibility promise — what stays stable across releases
Resources
- Upgrading from 1.x — moving from
yoeunes/regex-parser1.3 to 2.0, every change that needs one on your side - Documentation
- The analysis layer behind the mutators: regex-parser, regex-automata and regex-redos
- Changelog
- Report issues and send pull requests in the main PHPRegex repository
Sponsors
If PHPRegex saves you time, consider sponsoring its maintenance.
License
MIT. See LICENSE.