CLI Guide
This guide covers PHPRegex’s command-line tool and the workflows it enables. The binary requires PHP 8.1 or later.
Nothing matches that filter — try a shorter fragment, or press Escape to see the whole page again.
Installation
composer config minimum-stability dev
composer config prefer-stable true
composer require --dev php-regex/regex-cli:2.x-devOnce installed, the binary is vendor/bin/regex:
vendor/bin/regex --help
The overview lists the commands and the options they all take;
vendor/bin/regex help <command> shows the options, notes and examples of one
command.
Via PHAR (standalone):
The PHAR needs no PHP project and no Composer:
# Download the PHAR
curl -Ls https://github.com/php-regex/php-regex/releases/latest/download/regex.phar \
-o ~/.local/bin/regex
chmod +x ~/.local/bin/regex
# Use it
regex --help
Every release’s PHAR is built from its tag by a GitHub Actions workflow, which attests where it was built. With the GitHub CLI, check that the file you downloaded is that build:
gh attestation verify ~/.local/bin/regex --repo php-regex/php-regex
The release also carries regex.phar.sha256, which regex self-update
checks before it replaces the PHAR.
Note: Replace
vendor/bin/regexwithregexin all examples below if using the PHAR.
Command Overview
PHPRegex CLI provides these commands:
| Command | Description |
|---|---|
parse |
Parse and recompile a pattern |
analyze |
Pattern analysis (validation + ReDoS + explanation) |
compare |
Compare two regex patterns using automata logic |
explain |
Explain a regex pattern in plain language |
debug |
Detailed ReDoS analysis with heatmap |
redos |
Benchmark regex patterns for ReDoS behavior |
diagram |
Render AST diagram |
graph |
Generate a graph diagram (DOT/Mermaid) of the NFA (nondeterministic finite automaton) |
highlight |
Syntax highlighting (console or HTML) |
validate |
Validate pattern syntax |
transpile |
Transpile a PCRE regex to JavaScript (js), an HTML pattern attribute (html) or Python (py); --python-version=3.11 names the Python it runs on |
format |
Lay a pattern out over lines under x, or --compact it to one line |
lint |
Lint entire codebase for regex issues |
lsp |
Run the language server over stdio |
mcp |
Run the MCP server over stdio, for a coding agent |
version |
Display version information |
self-update |
Update PHAR to latest version |
help |
Show help message |
Global Options
| Option | Description |
|---|---|
--ansi |
Force ANSI colors |
--no-ansi |
Disable ANSI colors |
-q, --quiet |
Suppress output; a JSON, GitHub, Checkstyle or JUnit report is still printed |
--silent |
Same as --quiet |
--php-version <ver> |
Target PHP version for validation |
--pcre-version <ver> |
Target PCRE2 release for validation, as 10.42 |
--locale <name> |
Target LC_CTYPE locale for validation, as tr_TR.UTF-8 (see The locale) |
--cache-dir <dir> |
Keep the Unicode class scans in <dir> for the next run (see Unicode classes between runs) |
--no-visuals |
Disable banner and section visuals (aliases: --no-art, --no-splash) |
--help |
Show help |
Options may come before or after the pattern; -- ends them, so that what
follows is read as the pattern even when it starts with -.
parse, validate and compare print one rendering and take no
--format: giving them one is a usage error. The other commands take it and,
but for graph, call the terminal rendering console; the other values
depend on the command: json for analyze, debug, redos, transpile and
lint (with github, checkstyle and junit for lint), html for
explain and highlight (auto, the default of highlight, picks console
in a terminal), svg for diagram, and dot (the default) or mermaid for
graph. An unknown value is a usage error that names the values the command
takes.
Exit Codes
Every command exits with one of three codes:
| Code | Meaning |
|---|---|
0 |
The command did what it was asked and found nothing wrong |
1 |
The patterns or the files it judged have a problem |
2 |
The command line or the configuration cannot be used; nothing was judged |
What each command counts as a problem, and everything code 2 covers, is detailed in Errors and Exit Codes.
Command Examples
1. Parse a Pattern
Parse and show the recompiled pattern:
# Basic parse
vendor/bin/regex parse '/^[a-z]+@[a-z]+\.[a-z]+$/i'
# Parse with validation
vendor/bin/regex parse '/^hello/' --validate --no-visuals
Output:
Pattern: /^hello/
Parse: OK
Recompiled: /^hello/
Status: OK
2. Analyze a Pattern
Detailed analysis including validation, ReDoS verdict, and explanation:
# Analyze email pattern
vendor/bin/regex analyze '/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i'
Output:
[1/4] Parsing pattern
Pattern
→ /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i
Parse : OK
[2/4] Validation
Status : OK
[3/4] ReDoS analysis
Status : safe (proven)
Severity : SAFE (score 0)
Mode : THEORETICAL
Confidence : HIGH
[4/4] Explanation
Regex matches (with flags: i)
Anchor: the start of the subject
Character Class: any character in [ Range: from 'a' to 'z', Range: from '0' to '9', '.', '_', '%', '+', '-' ] (one or more times)
'@'
...
Status is the verdict’s headline: safe (proven), Exponential
backtracking (proven), Polynomial backtracking, degree N (proven),
Potential backtracking (heuristic), no risk found (heuristic), not
analyzed (budget exceeded) or not analyzed (analysis error) (see
the ReDoS guide). Up to three lines
follow it:
Model:what the analysis read differently from the pattern as written, as a bounded repeat above 16 analysed as unbounded; the proof is about that model;Note: analysis budget exceeded, the heuristics decided;Attack:the input that drives the worst case, as PHP:"a" x n . "!"isstr_repeat("a", $n) . "!".
vendor/bin/regex analyze '/(a{1,20})+$/'
[3/4] ReDoS analysis
Status : Exponential backtracking (proven)
Severity : CRITICAL (score 10)
Mode : THEORETICAL
Confidence : MEDIUM
Model: {1,20} at offset 1 analysed as {1,}
Attack: "a" x n . "!"
Hotspot: 0-10
--redos-mode=confirmed replays an exponential attack on the running PCRE,
without the JIT, and says at which length preg_match() gives up:
vendor/bin/regex analyze '/(a+)+$/' --redos-mode=confirmed # exits 1
[3/5] ReDoS analysis
Status : Exponential backtracking (proven)
Severity : CRITICAL (score 10)
Mode : CONFIRMED
Confidence : HIGH
Attack: "a" x n . "!"
Replayed on PCRE2 10.49: preg_match fails from length 17 (backtrack_limit 100000, JIT off).
Hotspot: 1-3
[4/5] Confirmation
Status: CONFIRMED
Evidence: backtrack_limit
Samples: len=17 avg=1.25ms
JIT: 0
Backtrack: 100000
Recursion: 10000
The confirmation adds a step, so the steps are numbered out of five. The
evidence line names the limit PCRE hit and the JIT setting it ran under; only
an exhausted backtrack limit counts as reproduced. When the engine does not
fail, the line reads Not reproduced on PCRE2 10.49 (PCRE's optimisations
defuse it)., the confidence stays MEDIUM, and the command exits with 0. A
polynomial verdict is never replayed.
--format=json prints the same verdict under redos, with complexity,
degree, proof, witness, replayed, abstractions, pcre_version,
php_version and analysis_version next to severity, score and the other keys (every key is in the
JSON output reference):
vendor/bin/regex analyze '/(a+)+$/' --format=json
"redos": {
"severity": "critical",
"score": 10,
"mode": "theoretical",
"confirmed": false,
"confidence": "medium",
...
"confirmation": null,
"complexity": "exponential",
"degree": null,
"proof": "proven",
"witness": {
"prefix": "",
"pump": "a",
"suffix": "!"
},
"replayed": null,
"abstractions": [],
"pcre_version": "10.49",
"php_version": "8.4.26",
"analysis_version": "1"
},
3. Debug (Deep ReDoS Analysis)
Show detailed ReDoS analysis with heatmap. debug reads the ReDoS mode and
threshold of regex.json when there is one; the options win:
# Analyze dangerous pattern
vendor/bin/regex debug '/(a+)+$/'
Output:
[1/2] Heatmap
Pattern: /(a+)+$/
^^
Status: Exponential backtracking (proven)
Severity: CRITICAL (score 10)
Mode: THEORETICAL
Confidence: MEDIUM
Culprit: a+
Trigger: quantifier +
Hotspots: 2
Attack: "a" x n . "!"
Input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" (auto)
[2/2] Findings
- [MEDIUM] Unbounded quantifier detected. May cause backtracking on non-matching input. Consider making it possessive (*+) or using atomic groups (?>...).
Suggested (verify behavior): Consider using possessive quantifiers or atomic groups to limit backtracking.
- [CRITICAL] Nested unbounded quantifiers detected. This allows exponential backtracking. Consider using atomic groups (?>...) or possessive quantifiers (*+, ++).
Suggested (verify behavior): Replace inner quantifiers with possessive variants or wrap them in (?>...).
The carets under the pattern mark the hotspot. A pattern proven safe, as
/a+/, gets no carets, no Hotspots:, Culprit: or Trigger: line, no
Findings step and no generated Input: (one given with --input is still
shown): the proof settles the verdict, and the analysis carries nothing of
the heuristics. With
--redos-mode=confirmed, the Replayed on … or Not reproduced on … line
follows the attack, a [2/3] Confirmation step follows the heatmap, and the
exit code follows the same rule as analyze.
4. Diagram (AST Visualization)
Render a text diagram of the pattern structure (--format=console, the default):
vendor/bin/regex diagram '/^[a-z]+@[a-z]+\.[a-z]+$/i' --no-visuals
Output:
Regex (flags: i)
\-- Sequence
|-- Anchor (^)
|-- Quantifier (+, greedy)
| \-- CharClass
| \-- Range
| |-- Literal ('a')
| \-- Literal ('z')
|-- Literal ('@')
|-- Quantifier (+, greedy)
| \-- CharClass
| \-- Range
| |-- Literal ('a')
| \-- Literal ('z')
|-- Literal ('.')
|-- Quantifier (+, greedy)
| \-- CharClass
| \-- Range
| |-- Literal ('a')
| \-- Literal ('z')
\-- Anchor ($)
Render SVG (prints XML to stdout):
vendor/bin/regex diagram '/^[a-z]+@[a-z]+\.[a-z]+$/i' --format=svg
Write SVG to a file:
vendor/bin/regex diagram '/^[a-z]+@[a-z]+\.[a-z]+$/i' --format=svg --output=graph.svg
Nothing is printed: the SVG goes to the file.
5. Highlight (Syntax Coloring)
Console output:
vendor/bin/regex highlight '/^[a-z]+@[a-z]+\.[a-z]+$/i'
HTML output:
vendor/bin/regex highlight '/^hello$/' --format=html --no-visuals
Output (HTML):
<span class="regex-token regex-anchor">^</span><span class="regex-token regex-literal">h</span><span class="regex-token regex-literal">e</span><span class="regex-token regex-literal">l</span><span class="regex-token regex-literal">l</span><span class="regex-token regex-literal">o</span><span class="regex-token regex-anchor">$</span>
6. Validate Pattern
Check pattern syntax:
# Valid pattern
vendor/bin/regex validate '/^[a-z]+$/' --no-visuals
# Invalid pattern (unbounded lookbehind)
vendor/bin/regex validate '/(?<=a+)b/' --no-visuals
Valid Output:
Pattern: /^[a-z]+$/
Status: OK
Invalid Output:
Pattern: /(?<=a+)b/
Status: INVALID
Lookbehind is unbounded. PCRE requires a bounded maximum length.
Line 1: (?<=a+)b
^
Not analyzed (a pattern past one of the library’s own limits, which PCRE may compile: no verdict, exit code 1):
Status: NOT ANALYZED
Regex pattern exceeds the maximum length of 100000 bytes, delimiters and modifiers included.
A limit of this library, not of PCRE: raise the "max_pattern_length" option to analyze the pattern.
As JSON (--json or --format=json): the pattern and its validation
object, the same document for a valid and an invalid pattern, told apart by
the exit code; see the JSON output reference.
vendor/bin/regex validate '/(a/' --json
7. Lint Your Codebase
Scan PHP files for regex patterns and issues:
# Lint src directory
vendor/bin/regex lint src/
# Lint with verbose output
vendor/bin/regex lint src/ -v
# Lint with JSON output (CI/CD)
vendor/bin/regex lint src/ --format=json
# Lint with GitHub Actions format
vendor/bin/regex lint src/ --format=github
# Exclude directories
vendor/bin/regex lint src/ --exclude=vendor --exclude=tests
Patterns Behind a Wrapper
Not every pattern reaches PCRE through preg_match(). Codebases that route
them through composer/pcre, nette/utils, an assertion library or a helper of
their own would otherwise be reported as having fewer patterns than they hold,
or none at all.
Besides the preg_* functions, the lint always reads the other native calls
that compile a delimited pattern: new RegexIterator($iterator, $pattern) and
new RecursiveRegexIterator(...) (the pattern second), and filter_var() or
filter_input() with FILTER_VALIDATE_REGEXP, whose regexp option is read
when the filter is that constant and the options are an array written in the
call.
The libraries are presets, named after their Composer package. Every preset is read by default:
| Preset | Recognised calls |
|---|---|
composer-pcre |
the static methods of Composer\Pcre\Preg and Composer\Pcre\Regex |
nette-utils |
Nette\Utils\Strings::match, matchAll, split, replace (the pattern second; a utf8 of true, by name or in its place, adds u) |
spatie-regex |
Spatie\Regex\Regex::match, matchAll, replace |
illuminate-support |
Illuminate\Support\Str::match, matchAll, isMatch, replaceMatches |
phpunit |
assertMatchesRegularExpression, assertDoesNotMatchRegularExpression, matchesRegularExpression, expectOutputRegex, expectExceptionMessageMatches, expectUserDeprecationMessageMatches, and PHPUnit 9’s assertRegExp, assertNotRegExp |
webmozart-assert |
Webmozart\Assert\Assert::regex, notRegex, and their nullOr and all forms (the pattern second) |
beberlei-assert |
Assert\Assertion::regex, notRegex, and their nullOr and all forms (the pattern second) |
symfony-validator |
new Regex(...) and #[Regex(...)] of Symfony\Component\Validator\Constraints: pattern, never htmlPattern |
# Every preset, the default
vendor/bin/regex lint src/
# Exactly these presets
vendor/bin/regex lint src/ --interop=composer-pcre,phpunit
# Native calls only (preg_*, RegexIterator, filter_var())
vendor/bin/regex lint src/ --no-interop
A list, on the command line or in extraction.interop, means exactly that
list: a preset a later release adds stays off for a project that lists its
presets, and there is no way to say “every preset but one” other than listing
the others. [] (or --no-interop) reads the native calls only.
A name that is no preset is a configuration error, exit code 2, that names the preset it most likely means: left out, it would leave a list that reads fewer calls than meant, the native ones only when it was the only one.
Error: Unknown extraction preset "laravel-str" in --interop: did you mean "illuminate-support"?
Error: Invalid "extraction.interop" in /app/regex.json: unknown preset "laravel-str"; did you mean "illuminate-support"?
regex debug reads regex.json too, and stops on the same error.
Wrappers are matched on their fully qualified name, using the file’s use
statements: a class of your own named Preg is left alone, and so is a
subclass of an assertion class (class Assert extends \Webmozart\Assert\Assert
called as App\Assert::regex()). A class is never looked up in the global
namespace as a fallback: in namespace App, new RegexIterator(...) without a
use or a leading \ is not read, as PHP would not find the class either.
PHPUnit’s methods are the exception: their names are PHPUnit’s own, so they
are read by name, on $this->, self::, static:: and parent::, on
PHPUnit\Framework\Assert:: and PHPUnit\Framework\TestCase::, and as the
functions of PHPUnit\Framework, in any class, a test case or not. A named
argument is read under the name the library gives the parameter, case
included, as PHP compares it: expectOutputRegex(expectedRegex: '/.../'),
Assert::regex(pattern: '/.../', value: $v); preg_match(PATTERN: '/.../')
is not read. nette/utils’ utf8 is read by name or in its place (sixth for
match() and split(), seventh for matchAll() and replace()): true
adds u, false adds nothing, and anything else, a spread included, leaves
the pattern’s flags unknown, so the pattern is not read.
Patterns held in a constant, a variable or a property
A pattern argument that names a value the same file proves is read:
final class Slug
{
private const PATTERN = '/^[a-z0-9-]+$/i';
public function isValid(string $slug): bool
{
return 1 === preg_match(self::PATTERN, $slug);
}
public function split(string $text): array
{
$separator = '/(\s+)+$/';
return preg_split($separator, $text);
}
}
- Constants:
self::X, andClassName::Xof a class declared in the same file or in another file of the project (see below), its name resolved through theuseimports;static::Xonly when the class or the constant isfinal, as a subclass may redeclare it; a namespaceconst X, or adefine('X', value)with a literal name and these two arguments only (adefine()written otherwise, with named arguments, a third argument or a name that is no quoted literal, is not read); an unqualified name tried in the namespace, then globally; the backing value of an enum case through->value(Kind::Re->value,self::Re->valuein the enum’s own methods), never the case itself or its->name. A constant built from other constants ('/'.self::PART.'/',self::Re->value) is folded, up to 32 constants deep; a cycle reads nothing, and neither does a value folded past 64 KiB. A constant array feedspreg_replace()its values andpreg_replace_callback_array()its keys, and Symfony’sRegexitspatternoption alone. Adefine()or a globalconstof a name PHP already defines,PHP_EOLsay, is not read: PHP keeps its own value. - Local variables: a variable of the function body whose every assignment
that may reach the call is a literal, a concatenation of literals or a
constant the file proves; each of those literals is a pattern the call may
receive. An assignment written after the variable’s last read as a pattern
never reaches it, unless a loop (
for,foreach,while,do) holds both or the body has agoto:$type = 'int'afterpreg_match($type, ...)is not read. Afor,foreachorwhilewhose body is a control structure without braces counts as holding the whole function body; adoends at itswhile, whatever its body is. The variable is not read when it may hold anything else: a parameter, a reference (=&), a compound assignment (.=,??=),global,static, a destructuring, aforeachorcatchtarget,unset(),++or--, ayieldin a generator that returns by reference (function &gen()), or the variable passed to any other call, aspreg_match()’s$matches. A function body that writes$$name, or callsextract(),compact(),eval()orinclude, has none of its variables read. A variable does not cross into a closure, and one at file scope is not read. - Properties:
$this->pwith a literal default,private(in afinalclass, or afinalproperty) orprotected(in afinalclass that extends none), in a class using no trait the file does not declare, neither static nor promoted nor hooked, and whose name is never written in the file, on any object; a by-referenceforeachover$this->p, or$x->p, writes it too. Nor is any property of a class whose body may write them all at once: a by-referenceforeachover$this, a variable or any other expression but a literal array or a named property, or$thispassed to a call; and no property of any class of the file whenarray_walk()orarray_walk_recursive()walks anything but a literal array. A public property can be given another value where this file cannot see it, a protected one by a subclass or a parent class, and any one by a trait of another file; a private one of a class that may be extended is left out as well, a rule deliberately stricter than PHP, which lets no subclass write it. In a closure or an arrow function, whichClosure::bind()may give another class, neither$this->pnorself::Xnorstatic::Xis read. Writes the file does not show are not seen: those of another file, of Reflection, ofClosure::bind(), or ofarray_walk()on the object from another scope. The default is still linted, as one of the values the property holds.
A pattern read through a name is reported once, where its literal is written,
whatever the number of calls that use it: regex lint points at the line of
private const PATTERN, and the JSON report gives the first call as source
and the way it reached the literal as location, via self::PATTERN (see the
JSON output reference). The
console shows it under the pattern:
app/Slug.php:14:22
→ /(\s+)+$/
↳ via $separator
A pattern assembled at the call, preg_match('/'.self::PART.'/', $s), is
reported at the call. To silence a pattern read through a name,
put @regex-ignore on the line of its declaration: a marker on the call does
not reach it.
Constants of another file
A class constant, or a backed enum case, of another file of the project is
read as one of the same file is: preg_match(Patterns::EMAIL, $s) reads the
EMAIL of App\Patterns wherever the project declares it. The constants
of every file under the configured paths (and under the paths named on the
command line) are read once before the patterns, in the same pass as the
#[RegexPattern] declarations, and the libraries of vendor/ give theirs
for the classes the project does not declare. The pattern is reported once,
at its literal, in the file that writes it, with the first call that reads
it as its source and via Patterns::EMAIL as its location; a marker on
the line of the literal silences it, and a version guard around its class
narrows it, as the guards of the reading file do not. The rules of the file apply across
files: a value holding a variable, a property or an expression the file
cannot fold is not read, static::X needs a final class or constant, a
constant built from constants of other files is folded, 32 constants deep
at most. A class the project declares in two files, in a fixture or a stub
say, is ambiguous: nothing is read through it. A constant inherited from a
parent class is read through the parent’s own name, never through
self::, static:: or parent::. A const or a define() outside a
class is read in its own file only. The language server reads the file it
analyses on its own: it does not reach the constants of another file.
Not read: self:: inside a trait, a ternary or a
match over literals, a variable captured by a closure’s use or an arrow
function, an array of patterns held in a local variable, a concatenation with
preg_quote() (its text is not known), a method called on an object
($str->matches(): Laravel’s Stringable, Symfony String, Finder’s
->name(), Pest’s ->toMatch(), beberlei’s Assert::that($v)->regex()), and
mb_ereg() and mb_split(), whose patterns are not PCRE.
Extraction coverage lists what each
tool reads.
Your own helpers
A project’s own helpers are declared as function or Some\Class::method.
Append #<index> when the pattern is not the first argument, and
#<index>:keys when that argument is an array whose keys hold the patterns:
vendor/bin/regex lint src/ --pattern-function='App\Support\Str::matches#1'
A call that passes the pattern by name is read under pattern:, patterns:
or regex:. When the helper names its parameter otherwise, append
$<name> to the index: App\grep#1$expression reads grep($list,
expression: '/.../'), and that name alone. A spec the lint cannot read,
x#a or x#1$ say, is left out with a warning on stderr, once per spec,
which -q silences: Malformed pattern function "x#a", ignored.
{
"extraction": {
"interop": ["composer-pcre", "nette-utils"],
"functions": ["App\\Support\\Str::matches#1"]
}
}
Or mark the parameter at the source, with the attribute
PHPRegex\Parser\Attribute\RegexPattern, or with PhpStorm’s
#[Language('RegExp')] (jetbrains/phpstorm-attributes) if the code already
carries it: the functions and static methods that declare one are read first,
and their calls are then read as if they were configured. The declarations
are read project-wide, whatever paths are linted: in the linted paths, in the
configured paths and in vendor/ of the working directory; with no paths
configured, in the linted paths and vendor/. Below a linted path they are
read as the lint reads the files: what exclude and --exclude keep out of
the lint there is not read, so a plain regex lint, which lints . with
"exclude": ["var", "vendor"], reads nothing under var/cache/. A configured
path the run does not lint, and vendor/, are read whatever exclude says:
a helper declared there, or in a library, is still known. With paths
configured, regex lint $(git diff --name-only)
still knows a helper declared in a project file it does not lint. The
vendor/ and the regex.json used are those of the directory the lint runs
from. A declaration file or directory that cannot be read, or a file too
large for memory_limit, is skipped silently; a package Composer links into
vendor/ from a path repository is read through its symlink.
When several declarations name one function, a configured spec
(--pattern-function, extraction.functions) always wins over a declaration
the lint finds; a project declaration (in the linted or configured paths)
wins over a copy in vendor/; two project declarations marking different
parameters are both read, whatever the order of the paths or the number of
jobs; two that mark one position under different parameter names read a
named argument under either name.
A call written unqualified in the function’s own namespace, grep() in
namespace App, is read, as PHP calls App\grep() first; and when the
namespace declares its own grep(), marked or not, a global grep() marked
in a library or named with --pattern-function or extraction.functions does
not capture the call. A static method is read when the call names its class,
Str::matches(...), not through self::, static:: or parent::. An
instance call ($str->matches(...), $this->matches(...)), a method marked
#[RegexPattern] included, names no class the linter can know, and is not
read; the PHPStan extension,
which knows the type of $str, reads it (see
the PHPStan guide).
use PHPRegex\Parser\Attribute\RegexPattern;
final class Str
{
public static function matches(string $subject, #[RegexPattern] string $regex): bool
{
return 1 === preg_match($regex, $subject);
}
}
Str::matches($input, '/(a+)+$/'); // linted as preg_match('/(a+)+$/', ...)
A function or method that marks several parameters is read at each of them,
by position and by name: in between(#[RegexPattern] string $from, string
$subject, #[RegexPattern] string $to), both $from and $to are linted.
Functions republished under another namespace with the same signature — as
thecodingmachine/safe does with Safe\preg_match() — are recognised
without any configuration.
Console Output:
PHPRegex 2.0.0-DEV
Runtime : PHP 8.4.26, PCRE2 10.49
Target : PHP 8.2, PCRE2 10.40 (composer.json require.php)
Processes : 10
PCRE JIT : 1
Backtrack : 1000000
Recursion : 100000
Configuration : regex.dist.json
[1/3] Scanning files
Scanned 1 files, found 1 patterns.
[2/3] Analyzing patterns
[3/3] Suggesting optimizations
PASS No issues found, 0 optimizations available.
Time: 0.02s Memory: 8 MB Processes: 1
Under --verbose the title names the author, and the report ends on a
request for a GitHub star, as Symfony’s and Laravel’s regex:lint -v end.
With Issues:
vendor/bin/regex lint src/ --redos
[1/3] Scanning files
Scanned 1 files, found 2 patterns.
[2/3] Analyzing patterns
[3/3] Suggesting optimizations
src/Example.php:3:12
→ /(?<=a+)b/
FAIL Lookbehind is unbounded. PCRE requires a bounded maximum length.
↳ (?<=a+)b
^
src/Example.php:4:12
→ /^(a+)+$/
WARN Exponential backtracking (proven). Severity: CRITICAL, confidence: MEDIUM.
↳ Attack: "a" x n . "!"
↳ The risky part is: a+
↳ Hotspot offsets: 2-4
↳ Unbounded quantifier detected. May cause backtracking on non-matching input. Consider making it possessive
(*+) or using atomic groups (?>...).
↳ Nested unbounded quantifiers detected. This allows exponential backtracking. Consider using atomic groups
(?>...) or possessive quantifiers (*+, ++).
↳ Suggested (verify behavior):
- Consider using possessive quantifiers or atomic groups to limit backtracking.
- Replace inner quantifiers with possessive variants or wrap them in (?>...).
- replace nested quantifiers like (a+)+ with atomic groups (?>a+) or possessive quantifiers a++.
↳ Test with adversarial inputs like repeated strings followed by a non-matching character.
INFO Quantified capturing group "(...)" with "+": only the last iteration's capture is retained.
↳ Use a non-capturing group (?:...) for the repetition and capture the whole match, or restructure the pattern.
WARN Nested quantifiers can cause catastrophic backtracking.
↳ Consider atomic groups (?>...) or possessive quantifiers — verify the rewrite still matches everything you
need.
FAIL 1 invalid patterns, 2 warnings, 1 infos found, 0 optimizations.
The summary counts infos only when there are some: a run with infos and
nothing worse ends with PASS 0 warnings, 2 infos found, 0 optimizations
available., and exits with 0.
The ReDoS issue, regex.lint.redos, carries the verdict’s headline, then
Severity: …, confidence: …, and in its hint the attack. It is a warning in
theoretical mode. With --redos-mode=confirmed, a verdict the running PCRE
reproduced at high or above, or a proven one it cannot replay because ini_set() is disabled, is an error, and makes the command exit with 1;
its hint adds the replay, and the summary counts it apart from the invalid
patterns:
vendor/bin/regex lint src/ --redos --redos-mode=confirmed
src/Example.php:4:12
→ /^(a+)+$/
...
FAIL Exponential backtracking (proven). Severity: CRITICAL, confidence: HIGH.
↳ Attack: "a" x n . "!"
↳ Replayed on PCRE2 10.49: preg_match fails from length 17 (backtrack_limit 100000, JIT off).
↳ The risky part is: a+
...
FAIL 1 invalid patterns, 1 ReDoS errors, 1 warnings, 1 infos found, 0 optimizations.
An exponential verdict the engine did not reproduce is dropped, and a polynomial one, never replayed, stays a warning:
vendor/bin/regex lint src/ --redos --redos-mode=confirmed --format=github
::error file=src/Validator.php,line=7,col=33,title=Security (regex.lint.redos)::Exponential backtracking (proven). Severity: CRITICAL, confidence: HIGH.%0AAttack: "0" x n . "!"%0AReplayed on PCRE2 10.49: preg_match fails from length 17 (backtrack_limit 100000, JIT off).%0ASuggestion: ...
::warning file=src/Validator.php,line=12,col=33,title=Security (regex.lint.redos)::Polynomial backtracking, degree 3 (proven). Severity: HIGH, confidence: MEDIUM.%0AAttack: "0" x n . "!"%0ASuggestion: ...
Here /^(\d+)+$/ sits on line 7 and /^\d*\d*\d*$/ on line 12, each in a
preg_match() call whose opening quote is at column 33. The lint issues of the
same lines (nested quantifiers, a quantified capturing group) are left out.
In JSON, the issue carries the whole analysis under analysis, with the keys
analyze --format=json prints (see the
JSON output reference).
When one attempt is proven linear but the unanchored search retries it along a
run, as /\s+$/ on a run of spaces, the issue is regex.lint.redos.search: the
search is quadratic in PCRE2’s interpreter, pcre.backtrack_limit does not stop
it, and the JIT may avoid it for some patterns (see the
ReDoS guide). It runs
under --redos, is medium like a proven quadratic attempt, so the default
high threshold hides it, and is a warning in every mode:
vendor/bin/regex lint app --redos --redos-threshold=medium
app/Whitespace.php:3:12
→ /\s+$/
WARN Quadratic search: one attempt is linear (proven); an unanchored search is quadratic in PCRE2's interpreter
(pcre.jit=0, a build without JIT, or (*NO_JIT)); the JIT may avoid it for some patterns. Severity: MEDIUM.
↳ Attack: " " x n . "!".
↳ pcre.backtrack_limit does not stop it: the limit counts each attempt apart.
↳ preg_match_all(), preg_replace() and preg_split() retry the same way.
↳ Anchor the pattern when every match starts at a known place (^, \A, \G or the A modifier), or bound the
length of the run.
PASS 1 warnings found, 0 optimizations available.
Its attack is in the analysis’ search_cost; --disable-rule=regex.lint.redos.search,
or "redos.search": false in checks.lint.rules, turns it off.
Twig templates
Twig’s matches operator is a preg_match(): {% if slug matches '/^[a-z]+$/' %}
compiles to a call of the engine on that literal. The lint reads it from the
*.twig files under the linted paths (list templates in paths, or name
the directory on the command line), and reports it at the template, the line
and the column of its opening quote, with twig:matches as its source, under
the same rules and the same ReDoS verdict as a pattern of the PHP files.
--no-twig leaves the templates out.
The pattern is the one Twig hands to the engine, not the text between the
quotes: Twig folds the backslash escapes of its strings as C does, so
'/\d+/' is the pattern /d+/ and '/\\d+/' the pattern /\d+/. A
template that writes '/\d+/' matches a d; the lint reports what the
engine runs.
The operator is read inside {{ }} and {% %} only, never in a {# #}
comment, a {% verbatim %} block or the text around the tags, and only when
its right operand is one string literal: a variable, a concatenation
('/' ~ sep ~ '/') or an interpolated string ("/#{sep}/") is not read. A
{# @regex-ignore #} comment silences the line as a PHP comment does, and
{# @regex-ignore-next-line #} the line after; a marker that silences
nothing is reported the same way.
8. Graph (Automaton)
Print the automaton a pattern compiles to — Dot by default (--format=dot, or graphviz), Mermaid with --format=mermaid, the format read in any case:
vendor/bin/regex graph '/ab?c/'
Output:
digraph NFA {
rankdir=LR;
node [shape=circle, fontname="Helvetica", fontsize=10];
edge [fontname="Helvetica", fontsize=10];
start [shape=point, width=0];
start -> 0;
...
The Mermaid form pastes straight into GitHub issues and pull requests:
vendor/bin/regex graph '/\d{3}-\d{4}/' --format=mermaid
9. Redos (Measure the Cost)
analyze proves the risk; redos measures it. It builds the attack input, runs the
pattern on it, and reports the wall time and the failure mode — here, the backtrack
limit giving out:
vendor/bin/regex redos '/(a+)+$/'
Output:
[1/3] Patterns
Vulnerable
→ /(a+)+$/
[2/3] Input
Source : auto
Base length : 33
Final length : 33
Iterations : 1 (warmup 0)
Preview : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
[3/3] Benchmark
case | result | wall_ms | avg_ms | cpu_ms | mem_kb | peak_kb | err
--------------------------------------------------------------------------------------------
vuln | error | 2.57 | 2.57 | 2.39 | +0.0 | +0.0 | Backtrack limit exhausted
The input is the witness of a proven verdict, 32 pumps of an exponential one and 1,000 of a polynomial one, so it holds
what every match needs: /(a+)+b/ runs on "a" x 32 . "!b", where "a" x 50 . "!" would be rejected before any
backtracking for want of a b. Without a proof, the input is built from the culprit the heuristics name.
--safe '<pattern>' measures a fixed variant alongside, --input pins the subject,
--jit 0 forces the interpreter. See the ReDoS guide for
what a replay does and does not prove.
10. Format (Lay Out or Compact)
format lays a long pattern out over lines under x, keeping its comments and
its spelling; --compact writes it back on one line, without x, layout or
comments. A pattern that already fits comes back unchanged:
vendor/bin/regex format '/^(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(?<path>\/[^\s?]*)(?:\?(?<query>\S*))?\s+HTTP\/(?<version>1\.[01]|2(?:\.0)?)$/'
Output:
/
^
(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)
\s+
(?<path>\/[^\s?]*)
(?:\?(?<query>\S*))?
\s+HTTP\/
(?<version>1\.[01]|2(?:\.0)?)
$
/x
--width=N sets the width a line may take (80 by default); --json prints
{"pattern", "formatted", "changed", "refusal"}. Where a layout under x would
change what the pattern matches, the pattern is printed unchanged with a note on
stderr, and the exit code stays 0. See Formatting patterns for
the layout rules, the guarantees and the refusals.
Symfony and Laravel Commands
The Symfony bundle and the Laravel package expose the same engine as console
commands — bin/console regex:lint, regex:routes, regex:security and
regex:analyze, or php artisan regex:lint — and exit with the same codes as
the binary. See the Symfony guide and
the Laravel guide for their full list and configuration.
Configuration File
Create regex.json or regex.dist.json in your project root:
{
"$schema": "./vendor/php-regex/regex-linter/regex.schema.json",
"format": "console",
"jobs": 4,
"exclude": ["vendor", "var", "tests"],
"ide": "phpstorm",
"phpVersion": "8.2",
"checks": {
"validation": true,
"redos": {
"enabled": true,
"mode": "theoretical",
"threshold": "high"
},
"optimizations": {
"minSavings": 2,
"options": {
"digits": true,
"word": true,
"ranges": true,
"canonicalizeCharClasses": true,
"minQuantifierCount": 4,
"verifyWithAutomata": true
}
}
}
}
regex.dist.json is the file you commit; regex.json is read after it and
wins. Keys merge the way you would expect from a settings file: an object is
merged key by key, and anything else, a list included, is replaced whole. So
{"exclude": ["build"]} in regex.json replaces the whole exclude list of
regex.dist.json, and {"exclude": []} clears it.
Configuration Options
| Option | Type | Description |
|---|---|---|
paths |
array or string | Paths to scan (default: the working directory) |
exclude |
array or string | Paths to exclude (default: vendor) |
format |
string | Output format (console, json, github, checkstyle, junit) |
jobs |
int | Number of parallel workers, at least 1 (default: the logical CPUs the process may use, see Parallel workers) |
ide |
string | IDE for clickable links |
reportUnmatchedIgnores |
boolean | Report the inline markers and baseline entries that silence nothing, as regex.ignore.unmatched (default true, see Markers that silence nothing) |
phpVersion |
string or int | PHP version the patterns are judged for: "8.3", 80300, or "runtime" for the PHP running the command |
pcreVersion |
string | PCRE2 release the patterns are judged for: "10.44" |
locale |
string | LC_CTYPE locale the patterns run under: "tr_TR.UTF-8" (default: the one of the PHP running the command) |
extraction.interop |
array | Libraries whose calls carry patterns (default: every preset; a list reads exactly those, [] the native calls only) |
extraction.functions |
array | Project helpers carrying patterns |
checks.validation |
boolean | Report patterns PCRE2 refuses (default true) |
checks.redos |
object | enabled (default false), mode (theoretical or confirmed), threshold (low, medium, high, critical) |
checks.optimizations |
object | enabled (default true), minSavings, options |
checks.optimizations.options |
object | digits, word, ranges, canonicalizeCharClasses, possessive, factorize, minQuantifierCount, verifyWithAutomata |
checks.lint |
object | enabled (default true) and rules, a map of rule id to true or false, the id written without regex.lint. (quantifier.useless) or in full (regex.lint.quantifier.useless); redos.search turns the search cost of the ReDoS check off |
checks.complexity |
object | enabled (default false) and threshold, an integer of at least 1 (default 50): a warning, regex.lint.complexity, for each pattern whose complexity score reaches it |
checks.redos, checks.optimizations, checks.lint and checks.complexity
are objects, and only their enabled key switches a check on or off: setting
threshold, mode, minSavings, options or rules alone never enables it. mode and
threshold are read whatever their case.
The lint command refuses a file it cannot fully use. Every unknown key, every
unknown lint rule id and every value of the wrong kind is an error, and the
command lists all of them at once, each with the key it is about, then exits
with code 2 before scanning anything. A misspelt key or rule id is told the
one it most likely means, in the spelling it was written in
(Did you mean "regex.lint.quantifier.nested"?), and a rule named in both
spellings in one file is an error too. A rule regex.json names in one
spelling overrides what regex.dist.json says of it in the other.
Removed Keys
These 1.x keys no longer exist: each is refused like any unknown key, with a plain unknown-key error naming it (a “Did you mean” hint appears when a known key is close). The table says where each setting went:
| 1.x key | Use instead |
|---|---|
rules |
checks (rules.optimization is checks.optimizations.enabled) |
redosMode |
checks.redos.mode |
redosThreshold |
checks.redos.threshold |
redosNoJit |
nothing: the ReDoS confirmation always runs without JIT |
optimizations |
checks.optimizations.options |
minSavings |
checks.optimizations.minSavings |
checks.redos.noJit |
nothing: the ReDoS confirmation always runs without JIT |
checks.redos.mode: "off" |
checks.redos.enabled: false |
checks.redos: true (and the other boolean forms) |
checks.redos: {"enabled": true} |
Schema
regex.schema.json, shipped by php-regex/regex-linter, describes every key, so an
editor can complete and check the file: point $schema at it, as in the
example above. The lint command validates against the same definition, so
the editor and the command accept exactly the same files; the command is
only more lenient about the case of mode and threshold.
The file is generated. When a key changes, it is written again from the definition with:
php tests/Tools/write_config_schema.php
Target PHP and PCRE2
Whether a pattern compiles, and how it behaves, depends on the PCRE2 release and, for a few rules, on the PHP version. The lint command judges every pattern for one target, chosen in this order:
--php-versionand--pcre-versionon the command line;phpVersionandpcreVersioninregex.json;composer.jsonin the working directory:config.platform.phpif set (one below 7.3 gives way torequire.php, see below), else the lowest versionrequire.phpallows (^8.2|^8.3is PHP 8.2), and then the patterns are also validated on the later PHP versions the constraint allows (Several PHP versions); TheCOMPOSERenvironment variable names another file, as it does for Composer;- the PHP running the command.
runtime, given to --php-version or as phpVersion, names the PHP running
the command and the PCRE2 it links, as PHPStan’s phpVersion does: a project
that lints for its floor can still ask what the engine at hand says.
Each version is chosen on its own. A PHP version does not decide the PCRE2 a build links, so every version judged (the target, each later version of the range, each version a guard or a marker keeps) gets its PCRE2 in this order:
--pcre-version, elsepcreVersioninregex.json, else the releasecomposer.jsonpins inconfig.platform.lib-pcre("10.42", or"10.42.0"as Composer spells it). A pin is used as is, older or newer than the one that PHP bundles;- the PCRE2 of the running PHP, when the version judged is the running PHP’s
minor version: on a PHP 8.4.27 that links 10.49,
--php-version=8.4is judged with 10.49; - the PCRE2 that PHP bundles: 10.32 for PHP 7.3, 10.33 for 7.4, 10.35 for 8.0, 10.39 for 8.1, 10.40 for 8.2, 10.42 for 8.3, 10.44 for 8.4 and 8.5.
A config.platform.lib-pcre that names no PCRE2 release is passed over with a
notice. The language server, the Symfony and Laravel bridges, the PHPStan
extension and the Psalm plugin follow the same order.
The locale
PHP hands PCRE2 the character tables of the LC_CTYPE locale a call of
setlocale() names, and they decide the size of a few classes (see
Which locale judges a pattern).
The locale is chosen in this order, the same for every version of the range:
--localeon the command line;localeinregex.json;- the locale of the PHP running the command,
Cunless it calledsetlocale().
The target line names it when it is not C, and the JSON report always, as
target.locale:
Target: PHP 8.2, PCRE2 10.40, locale fr_FR.ISO8859-1 (composer.json require.php; --locale)
A locale this machine cannot set is an error, exit code 2.
The lowest PHP is the one that matters because a library, or an application
deployed on several servers, runs on every version its constraint allows: a
pattern that only compiles on a newer PCRE2 fails on the oldest one. PHP 7.3
is the oldest PHP the command judges, the first on PCRE2. A PCRE2 release
older than 10.39, the oldest whose rules the library knows in full, is judged
with the 10.39 rules but for the few older changes it names, and the command
says so with a note such as PCRE2 10.33 is judged with the PCRE2 10.39
rules, the oldest this library knows in full. (see
PHP 7.3 to 8.0, and PCRE1).
PHP 7.2 and earlier are never judged: they ship PCRE1, another engine than PCRE2, and no verdict of this library holds for it. PHP 7.3 is the first PHP on PCRE2 (10.32), with no release in between. What happens to a project that allows such a PHP depends on where the version comes from:
-
A
require.phpthat starts below 7.3 but also allows PHP 7.3 or later,^7.1|^8.0or>=5.4for instance, is judged from the lowest PHP of 7.3 or later it allows, usually 7.3, as any other floor. The versions below 7.3 are left out, and a note says so; the note does not change the exit code:Note: composer.json require.php allows PHP 7.1, and PHP 7.2 and earlier ship PCRE1, which this library does not judge: judging from PHP 7.3, the lowest version it allows on PCRE2. -
A
config.platform.phpbelow 7.3 pins the PHP Composer resolves the dependencies for, not the only PHP the code runs on: it gives way to arequire.phpthat also allows 7.3 or later, judged from the lowest such PHP the same way, with the note naming the platform:Note: composer.json config.platform.php pins PHP 7.2, and PHP 7.2 and earlier ship PCRE1, which this library does not judge: judging from PHP 7.3, the lowest version require.php allows on PCRE2. -
A
require.phpthat allows no PHP of 7.3 or later (7.2.*,~7.1.0,>=5.6 <7.3) is refused, and so is aconfig.platform.phpbelow 7.3 with no suchrequire.phpbeside it. So is a version below 7.3 named by--php-version,phpVersioninregex.jsonor a bridge setting: that names a PHP on PCRE1 outright. The run stops until a PHP version of 7.3 or later is named:Error: composer.json require.php allows PHP 5.6: PHP 7.2 and earlier ship PCRE1, which this library does not judge. Name a PHP version of 7.3 or later to judge for.
A refused run exits with 2, and with --format=json prints the
error envelope with stage
target, so that a pipeline never reads a project the command refused to
judge as a clean one. The Symfony and Laravel regex:lint commands follow the
same rule; the language server logs the note, or shows the refusal and judges
no pattern. validate, analyze and the other commands that read one pattern
refuse a --php-version below 7.3 the same way, with exit code 2 and, under
--json where the command takes it, the envelope with stage target; the
PHPStan extension and the Psalm plugin refuse one too. A constraint the command cannot read (*, <9, a branch name)
falls back to the running PHP, with a note naming it: a composer.json the
command cannot read never stops a run.
Where the target is recorded depends on the format. The console format shows
it as a Target row in the banner, right under Runtime, with any Note:
lines from the resolution below the table — so it lives wherever the banner
lives: on the terminal, and in a shell redirect such as
regex lint src/ > report.txt, but never in the file named by --output,
which holds the report alone. The GitHub, Checkstyle and JUnit formats keep
stdout report-only and print a stable stderr line instead:
Target: PHP 8.2, PCRE2 10.40 (composer.json require.php)
Machine consumers that need the target programmatically should read the JSON
report’s target key (see JSON below) rather than parse that line;
with --format=json the notes stay on stderr, so a JSON pipeline that
discards stderr loses the why behind target.source. Two neighbouring
surfaces keep their own spelling: the language server logs its own
Target: ... line on initialization, and the Symfony and Laravel commands
print the stderr line for the JSON format too.
Several PHP versions
A pattern valid on the lowest PHP may be refused by a later one the project
installs on: PHP 8.5 refuses \K in a lookaround, which PHP 8.4 compiles.
When the target comes from require.php, the command therefore also
validates every pattern at each PHP version above the floor where a rule of
the library changes, as long as the constraint allows it: today 8.3, 8.4,
8.4.25, 8.5 and 8.5.10. Each is judged with the PCRE2 release the order above
gives it. >=8.2 <8.5 stops at
8.4.25, ~8.3.0 is 8.3 alone, and an open constraint such as >=8.2 runs to
the newest version the library knows. Each branch of an OR constraint is also
validated at its own lowest version, which no rule change stands for:
~8.3.0 || >=8.5.3 is judged at 8.3, 8.5.3 and 8.5.10, and a pattern PHP 8.5
refuses is reported “On PHP 8.5.3 and later”.
What runs where:
- At the floor, everything: validation, the lint rules, the ReDoS analysis and the optimizations. A pattern the floor refuses is reported there, once, and not validated elsewhere.
- At every later version, validation only: the pattern is parsed and checked as that PHP and PCRE2 would compile it. A pattern the floor accepts and a later version refuses is reported as an error naming the versions that refuse it, and fails the run:
app/Example.php:3:12
→ /(?<=a\Kb)c/
FAIL On PHP 8.5 and later: \K is not allowed in a lookaround from PHP 8.5, which compiles without PCRE2_EXTRA_ALLOW_LOOKAROUND_BSK.
↳ Up to PHP 8.4 a \K in a lookaround resets the start of the match from inside it: "(?=\Ka)" matches an empty string in "a", and "(?<=\Ka)b" matches "ab" in "ab". No spelling keeps that in general: rewrite the pattern without it.
The lint changes no code. A \K alone at the start of a plain lookahead is
the one case a spelling keeps: (?=\Kab)a resets the start of the match
where \K(?=ab)a does, on every PHP, and the hint says
This \K opens the lookahead: write "\K(?=" for "(?=\K", which every PHP reads alike.
- A pattern every version accepts but one reads otherwise is a warning,
regex.lint.compat.meaningChanges, naming the first version that parses it into another meaning:/a{,3}/is the texta{,3}under PCRE2 before 10.43 (PHP 8.2 and 8.3) andazero to three times from 10.43 (PHP 8.4). Writea{0,3}ora\{,3}, which every version reads alike;"compat.meaningChanges": falseinchecks.lint.rulesturns the check off. The same warning names a change of meaning that keeps the tree, from the release table: a possessive{1}+on a group (PCRE2 10.34), a group that refers to itself, a repeated assertion holding a group and(*UCP)casing without UTF (10.35), a bare script name (10.40); see the rules reference.
The console banner still names the floor. The JSON report lists every PHP and
PCRE2 it validated at under target.range, and the issue carries the lowest
one that refuses the pattern under target
(JSON output).
Every other source names one version, and only that version is judged:
--php-version, phpVersion in regex.json, config.platform.php, and the
running PHP. To judge one version of a project whose require.php spans
several, name it:
vendor/bin/regex lint src/ --php-version=8.2
Symfony’s and Laravel’s regex:lint judge the same range when they read
composer.json; their php_version setting names one version, as
--php-version does.
Code that runs on some PHP versions only
A pattern the code only runs on some of the versions judged is judged for those alone: the lint narrows the versions, it never silences the pattern. The guard around it says which:
if (PHP_VERSION_ID >= 80400) {
preg_match('/a{,3}/', $s); // judged for 8.4 and later: a quantifier everywhere
}
preg_match('/a{,3}/', $s); // regex.lint.compat.meaningChanges: text on 8.2 and 8.3
Under require.php ^8.2, the first a{,3} is judged for 8.4, 8.4.25, 8.5
and 8.5.10, which all read a quantifier there, and is not reported; the
second is judged for the whole range, and PCRE2 before 10.43 (PHP 8.2 and
8.3) reads it as text.
The guards the lint reads:
PHP_VERSION_IDor\PHP_VERSION_IDcompared to an integer literal, on either side, with>=,>,<,<=,===,!==,==or!=;version_compare(PHP_VERSION, '8.4', '>='), with a literal version (x.yorx.y.z) and one of>=,ge,>,gt,<,lt,<=,le;&&of those, in parentheses or not;
on the branches of an if, an elseif and an else (each where its
condition holds and the ones before it fail), and on the rest of a block
after if (<guard>) { return; }, throw, continue, break or exit alone
in the body, up to the next case of a switch. A function or a class
declared after such an exit is judged for the whole range: PHP declares those
of a file before running it, whatever the file returns. A file holding a
goto reads no early exit. With nikic/php-parser installed, the lint also reads them on
the branches of a ternary, on the arms of match (true) and in the
alternative syntax (if (...): ... endif;); the tokenizer, which reads the
files otherwise, does not, and judges those for the whole range. Anything else is no guard, and the pattern is judged for the whole
range: a negation (!(PHP_VERSION_ID >= 80400)), an ||, a comparison with
anything but a literal, an && one side of which is no guard,
version_compare() with == or !=, a guard in the calling function. In a
namespace, an unqualified PHP_VERSION_ID is read only when the file
declares no constant of that name. A pattern held in a constant or a property
is judged where its literal is written, as for @regex-ignore: a guard
around the call does not narrow it, and a constant of another file is
judged by the guards of that file. A guard that keeps no version of the
range leaves the pattern unjudged: the code never runs there.
Where no guard says it, @regex-target names the versions, as a Composer
constraint after php, pcre, or both:
preg_match('/(?a)\d+/', $s); // @regex-target php>=8.4
// @regex-target-next-line pcre>=10.43
preg_match('/(?a)\d+/', $s);
/**
* @regex-target php>=8.4 pcre>=10.43
*/
final class AsciiMatcher
{
// every pattern of the class is judged for 8.4 and later
}
Under require.php ^8.2, (?a) (an option PCRE2 10.43 added) is judged
for 8.4, 8.4.25, 8.5 and 8.5.10, and not reported; without the marker, PHP
8.2 refuses it. The versions judged are the lowest one the marker keeps, with
the PCRE2 the target order gives it, then each later version of the
range it keeps. pcre>=10.43 keeps the versions whose PCRE2 is 10.43 or
later.
- Placement. As for
@regex-ignore:@regex-targetcovers the last line of its comment,@regex-target-next-linethe line after it. In a doc comment that opens its line,@regex-targetcovers the statement or the declaration after it, whole: a class, an interface, a trait, an enum, a function, a method, a constant. - Several markers. Markers that cover one line all apply, and so does the guard around them: the versions judged are those every one keeps.
- A marker that keeps no version of the range,
php>=9.0under^8.2, is an error,regex.target.empty, on the marker’s line, and the patterns it covers are not judged. Under one version named,--php-version=8.2, the range is the 8.2 series. - A marker that cannot be read,
php>=eight, is an error,regex.target.invalid, and the patterns it covers are judged for the whole range. - In the JSON report, an issue of a pattern a guard or a marker narrows names the version that
reported it in
targetand the constraint intarget.guard(>=8.4), the PCRE2 part intarget.guard_pcre(JSON output).
The PHPStan extension, the Psalm plugin and the language server read the same markers.
Single-pattern commands, such as analyze or validate, judge for the
running PHP unless --php-version, --pcre-version or --locale is given;
their banners show Target PHP / Target PCRE2 / Target locale rows only
when those flags are passed.
Errors and Exit Codes
lint exits with the codes every command uses (see
Exit Codes). What each command counts as a problem (code 1):
| Command | Exits with 1 when |
|---|---|
lint |
A pattern does not compile, a ReDoS verdict reproduced at high or above, or a lint rule of error severity fires (warnings and infos alone leave 0); or the files cannot be read; or a worker fails or exits |
validate, parse --validate, analyze |
The pattern is invalid |
parse, explain, diagram, highlight |
The pattern does not parse |
graph |
The pattern does not parse, or cannot be drawn as an automaton |
transpile |
The pattern is invalid, or cannot be written for the target |
format |
The pattern does not parse, or the formatter could not prove its output the same pattern; a refusal leaves 0 |
debug |
The pattern is invalid, a semantic error such as /(?<=a+)b/ included, in every format |
analyze, debug |
--redos-mode=confirmed reproduces a ReDoS verdict of high severity or more, at or above --redos-threshold, on the running PCRE, or proves one it cannot replay because ini_set() is disabled |
compare |
The answer is no: the patterns intersect, the first is not a subset of the second, or they differ; or they cannot be compared |
redos |
PHP refuses to compile the pattern or the --safe one; a slow run alone leaves 0 |
self-update |
The update fails |
Every command also exits with 1, Error: A regex of the library gave up: … on
stderr, when one of the library’s own regexes gives up: pcre.backtrack_limit
or pcre.recursion_limit stands below PHP’s default and ini_set() cannot
raise it. No verdict is given on the pattern; raise the limit, or allow
ini_set().
The lint rules of error severity target patterns that compile but do not do
what they say without /u: a multibyte character in a class, a quantified
multibyte character, a Unicode property. The lint summary counts them as lint
errors (FAIL 1 lint errors, 2 warnings, 0 optimizations.), apart from the
invalid patterns, which PCRE refuses to compile. Every other rule is a warning or
an info, printed without changing the code.
A theoretical ReDoS verdict is a warning, as it is for lint: it is printed
and leaves the code at 0, proven or not. A verdict the confirmed mode did not
reproduce (Not reproduced on PCRE2 …), and a polynomial verdict, which is
never replayed, leave 0 too.
Code 2 covers an unknown command or option, an option without its value or
with a value the command does not accept (an unknown --format, --target,
--method or --redos-mode, an invalid --php-version or --pcre-version,
a --locale this machine cannot set),
a missing pattern, an argument the command does not take (one pattern more
than it reads, or anything after version, self-update, lsp or mcp),
a target given to lsp (the server reads it from the editor), an
--input-file that cannot be read, an --output file that cannot be written,
a regex.json that cannot be read (lint and debug), and, for lint, a
target PHP of 7.2 or earlier, which ships PCRE1: one named outright, or a
composer.json that allows no PHP of 7.3 or later (see
Target PHP and PCRE2) — in which case nothing was
scanned. regex run without a command prints the help and exits
with 2, as regex help with an unknown command does.
For lint, code 2 also covers the paths it is given, as it does for
Symfony’s and Laravel’s regex:lint:
| Case | Exit code | JSON stage |
|---|---|---|
| A path on the command line that does not exist | 2 |
usage |
A path listed in regex.json that does not exist |
2 |
config |
A --baseline file that is missing, cannot be read, or is not a baseline |
2 |
usage |
A --generate-baseline file that cannot be written |
2 |
usage |
An --output file that cannot be written |
2 |
usage |
| A path that exists but holds no pattern | 0 |
none |
With --format=json, a configuration or command-line error is printed on
stdout as the error envelope, {"error": "...", "stage": "config"} (stage
usage for the command line, a path argument that does not exist included,
config for a path in regex.json that does not exist, collect when the
files cannot be read, analyze when a worker fails or exits), so that stdout always holds one JSON document; with
the other formats it is printed on stderr. See
the error envelope.
IDE Integration
Enable clickable file links in lint output:
{
"ide": "phpstorm"
}
Supported IDEs:
"phpstorm"- phpstorm://open?file=%f&line=%l"vscode"- vscode://file/%f:%l"textmate"- txmt://open?url=file://%f&line=%l"sublime"- subl://open?url=file://%f&line=%l"emacs"- emacs://open?url=file://%f&line=%l"atom"- atom://core/open/file?filename=%f&line=%l"macvim"- mvim://open?url=file://%f&line=%l""- Disable clickable links
Any other value is a URL template of your own, which holds at least one of these placeholders, filled in for each link:
| Placeholder | Filled in with |
|---|---|
%f or %file% |
The absolute path of the file, URL-encoded |
%relFile% |
The path relative to the working directory, URL-encoded |
%l or %line% |
The line |
%c or %column% |
The column |
{
"ide": "myeditor://open?file=%f&line=%l&column=%c"
}
A value that is no IDE name and holds no placeholder is refused.
Ignoring Patterns
Inline Comments
// @regex-ignore at the end of a line silences the issues of that line;
// @regex-ignore-next-line silences those of the line after the comment.
A marker starts its comment (after //, #, /* or /**) or a line of a
doc comment: one quoted or met in the middle of a sentence is prose about the
markers, and does nothing.
// @regex-ignore-next-line
preg_match('/(a+)+$/', $input);
A pattern read through a constant, a variable or a property is silenced on the line of its literal, where it is reported, not on the call:
private const LEGACY = '/(a+)+$/'; // @regex-ignore
In a Twig template, the marker is a Twig comment, on the line or the line
before: {# @regex-ignore #}, {# @regex-ignore-next-line #}, with the
same words after it.
A marker may name the issues it silences, comma-separated, right after it. The other issues of the line are still reported:
preg_match('/\d+[11]/i', $input); // @regex-ignore regex.lint.flag.useless.i
The identifiers are the issue_id of the JSON report (the error code, such
as regex.group.unclosed, for a pattern that does not compile). An issue of
the lint may be named without regex.lint., as regex.json names it:
// @regex-ignore flag.useless.i, redos names regex.lint.flag.useless.i
and regex.lint.redos. The list ends at the first word that is neither an
identifier starting with regex. nor the short id of an issue of the lint;
that word and the rest of the comment, or all of it after a marker that names
nothing, is a note for the reader: // @regex-ignore legacy input, see #12
silences the whole line.
The grammar, after the marker and one space or tab:
list = identifier { [ " " | "\t" ] "," [ " " | "\t" ] identifier }
identifier = "regex." word { "." word } (as written)
| short-id (read as "regex.lint." short-id)
word = one or more of A-Z a-z 0-9 _
short-id = the id of a lint rule or analysis issue, without "regex.lint.",
not followed by "-"
Markers that silence nothing
A marker left behind once its issue is fixed would silence the next issue of
its line unseen. The lint reports each marker that silences nothing, and each
identifier of a marker that silences no issue of that name, as an error,
regex.ignore.unmatched, on the line of the marker:
src/Legacy.php:14 The @regex-ignore marker silences nothing: no issue is reported on line 14. Remove it.
src/Legacy.php:20 The @regex-ignore marker names regex.lint.redos, which no issue reports on line 20. Remove the identifier.
An identifier naming a check the run leaves off is passed over, as CI often
runs the lint and the ReDoS analysis apart on the same code: regex.lint.redos
in a run without --redos, a lint rule checks.lint.rules turns off (or one
off by default and not turned on), regex.optimization under --no-optimize,
regex.lint.complexity without checks.complexity, an error code under
--no-validate, regex.lint.compat.meaningChanges when one PHP version is
judged. A marker whose every identifier is passed over is not reported. An
identifier that names no check the library knows is reported all the same,
by name. --no-report-unmatched-ignores turns the report off for one run,
and "reportUnmatchedIgnores": false in regex.json for every run. The
report itself is never silenced by a marker.
Config Exclude
In regex.json:
{
"exclude": ["src/Legacy", "src/Deprecated"]
}
Output Formats
Console (Default)
Human-readable colored output for terminal.
JSON
vendor/bin/regex lint src/ --format=json
# The sample below: one invalid pattern, one replayed ReDoS verdict
vendor/bin/regex lint src/ --redos --redos-mode=confirmed --no-lint --no-optimize --format=json
Output (trimmed):
{
"target": {
"php": "8.4.26",
"pcre": "10.49",
"source": "running PHP",
"range": [
{
"php": "8.4.26",
"pcre": "10.49"
}
]
},
"stats": {
"errors": 2,
"warnings": 0,
"optimizations": 0,
"redos_errors": 1,
"infos": 0,
"lint_errors": 0,
"parser_fallbacks": 0,
"not_analyzed": 0
},
"results": [
{
"file": "src/Example.php",
"line": 3,
"column": 12,
"file_offset": 18,
"source": "preg_match()",
"pattern": "/(?<=a+)b/",
"location": null,
"issues": [
{
"severity": "error",
"file": "src/Example.php",
"line": 3,
"column": 12,
"file_offset": 18,
"position": 0,
"issue_id": "regex.lookbehind.unbounded",
"docs_url": null,
"message": "Lookbehind is unbounded. PCRE requires a bounded maximum length.",
...
"validation": { ... },
"analysis": null,
"target": null
}
],
"optimizations": []
},
{
"file": "src/Example.php",
"line": 4,
...
"pattern": "/^(a+)+$/",
"issues": [
{
"severity": "error",
...
"issue_id": "regex.lint.redos",
"docs_url": "https://php-regex.com/reference/rules/#catastrophic-backtracking",
"message": "Exponential backtracking (proven). Severity: CRITICAL, confidence: HIGH.",
"hint": "Attack: \"a\" x n . \"!\" Replayed on PCRE2 10.49: preg_match fails from length 17 (backtrack_limit 100000, JIT off). ...",
"source": "preg_match()",
"validation": null,
"analysis": { ... },
"target": null
}
],
"optimizations": []
}
]
}
stats.errors counts every error; stats.redos_errors counts the ReDoS errors
among them and stats.lint_errors the lint rules of error severity that fired;
stats.infos counts the issues of severity info; stats.parser_fallbacks counts
the files the PHP parser could not read, whose patterns the tokenizer read instead;
stats.not_analyzed counts the patterns past one of the library’s own limits,
among the warnings: the run has no verdict on them, and the summary says so,
PASS 1 warnings (1 not analyzed) found. Such a run still exits as its warnings
leave it, 0 when nothing else fails.
Every key is always present,
0 when there is none, and every issue carries every key, null when it does
not apply. An issue’s severity is error, warning or info, from the
severity of the rule that reported it (see
Severity in Each Format).
Results are sorted by file, line and column, the same whatever --jobs says.
The JSON output reference lists every key of
this report and of the other commands’ JSON (analyze, debug, redos,
transpile), with the units of each position, the error envelope and what a
minor release may add.
GitHub Actions
vendor/bin/regex lint src/ --format=github
Output:
::error file=src/Example.php,line=3,col=16,title=Semantic (regex.lookbehind.unbounded)::Lookbehind is unbounded. PCRE requires a bounded maximum length.%0ALine 1: (?<=a+)b%0A ^%0ASuggestion: Use a bounded quantifier instead of "+".
This is if (preg_match('/(?<=a+)b/', $input)) { on line 3. col is the
column of the pattern in the file, its opening quote, the column of the JSON
report; it is left out when the column is unknown, as for a pattern not read
from a PHP string. The message is escaped as GitHub’s format requires: a
newline is %0A, a carriage return %0D and % is %25. In the file and
title properties, : and , are escaped too, as %3A and %2C. A lint
rule, a ReDoS verdict or another issue the rule reference
explains ends its message with Read more: and the address of its section,
as https://php-regex.com/reference/rules/#useless-quantifier.
Checkstyle (for CI)
vendor/bin/regex lint src/ --format=checkstyle --output=checkstyle.xml
Each <error> names the issue in source, as the JSON report’s issue_id
(regex.lint.quantifier.nested, regex.group.unclosed), and php-regex for
an issue that has none. A lint rule, a ReDoS verdict or another issue the
rule reference explains ends its message with
Read more: and the address of its section.
JUnit
vendor/bin/regex lint src/ --format=junit --output=junit.xml
Lint Options
| Option | Description |
|---|---|
--exclude <path> |
Exclude path (repeatable) |
--min-savings <n> |
Minimum optimization savings |
-j, --jobs <n> |
Parallel workers (default: the logical CPUs, see Parallel workers) |
--format <format> |
Output format (console, json, github, checkstyle, junit) |
--json |
Same as --format=json |
--output <file> |
Also write the report to a file |
--baseline <file> |
Leave out the issues a baseline file lists (see Baseline); --baseline=<file> works too |
--generate-baseline <file> |
Write every issue of this run to a baseline file; --generate-baseline=<file> works too |
--no-report-unmatched-ignores |
Do not report the inline markers and baseline entries that silence nothing |
--report-unmatched-ignores |
Report them when regex.json turns the report off (the default) |
--redos |
Run the ReDoS analysis, off by default |
--no-redos |
Skip it when regex.json turns it on |
--redos-mode <mode> |
theoretical or confirmed |
--redos-threshold <sev> |
Lowest severity reported: low, medium, high, critical |
--no-validate |
Skip validation |
--no-optimize |
Disable optimization suggestions |
--lint |
Run the lint rules (the default) |
--no-lint |
Skip the lint rules |
--enable-rule=<id> |
Turn a lint rule on (repeatable) |
--disable-rule=<id> |
Turn a lint rule off (repeatable) |
--interop <presets> |
Libraries to read patterns from, every preset by default (comma separated, none for the native calls only) |
--no-interop |
Read patterns from the native calls only (preg_*, RegexIterator, filter_var()) |
--no-twig |
Skip the matches patterns of the Twig templates under the paths |
--pattern-function <spec> |
Extra call carrying a pattern (repeatable) |
-v, --verbose |
Detailed output |
--debug |
Debug information |
Note: ReDoS analysis is disabled by default for performance. Enable it with
--redosor via configuration. The default threshold ishigh: a proven quadratic pattern ismedium, and is reported with--redos-threshold=mediumonly.
--redos-mode takes theoretical or confirmed, and --no-redos skips the
analysis; the confirmation always runs without JIT. Any other mode is a usage
error (exit code 2), as is an unknown --format, and an --enable-rule or
--disable-rule id that checks.lint.rules in regex.json would refuse
(Unknown lint rule "quantifer.nested" in --disable-rule. Did you mean "quantifier.nested"?).
Both options take the id without regex.lint. or in full.
When nikic/php-parser is installed, and the lint reads files with it, a
file the PHP parser cannot read (a syntax error, for one) is read again
with PHP’s tokenizer, so its patterns are still linted. The run counts such
files in stats.parser_fallbacks of the JSON report, never as an error, and
--verbose names each one with the parser’s message:
Parsed with the tokenizer: src/Broken.php (Syntax error, unexpected EOF on line 12)
Outside the console format, that line goes to stderr, so that stdout holds the report alone.
Parallel workers
Without --jobs (or "jobs" in regex.json), the lint runs one worker per
logical CPU the process may use: the CPUs it is pinned to on Linux, else
nproc or sysctl hw.logicalcpu, else /proc/cpuinfo. Inside a container,
the cgroup CPU quota (docker run --cpus=2) and KUBERNETES_CPU_LIMIT cap
that number, a fraction rounding up, and so does the cgroup memory limit
(docker run --memory=1g): each worker is a copy of the main process, so the
limit holds the main process and as many workers as fit beside it, at least
one. regex:lint in Symfony and Laravel counts the same way. Without the
pcntl extension, on Windows for one, the lint runs in one process and the
banner says so beside the count asked for. Elsewhere the banner shows the
number planned; the Processes: line under the report shows the number of
workers the run started, 1 when it had a single file or pattern to share out.
The workers take the files, then the patterns, a few at a time, as each one
finishes the last, and report as they go: the progress moves with the work
and its clock keeps running while the run waits. In a terminal the console
format shows each step and its progress on stderr, so that
vendor/bin/regex lint src/ > report.txt keeps the report alone:
[1/3] Scanning files
Patterns
1240/4510 [▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░] 27% 00:04
Step 1 names what it reads on a line above its bar, so that its bar lines up
with those of the next steps: Finding files, then Declarations (the files
read for the #[RegexPattern] functions they declare, see Patterns Behind a Wrapper),
Functions when namespaced functions are read too, then Patterns. Step 2
analyses the patterns, step 3 suggests the optimizations; with
--no-optimize the run has two steps. Without a terminal, in a CI log, the
bar is not drawn: a line every 30 seconds says where a long step stands,
such as ` 7449/24824 (30%) 01:30 under [2/3] Analyzing patterns.
When no worker has reported for 10 seconds, the run names the file it is
held on, the first file of the oldest share still being worked on
(Still on src/web/Request.php): above the bar in a terminal until the next
file is done, on a line of its own in a log. regex:lint` in Symfony and
Laravel does the same.
A worker that fails or exits, a PHP fatal error or the system killing it for memory, stops the run with exit code 1 and a message naming the files it was reading:
Error: Parallel analysis failed: a worker exited unexpectedly while reading src/Huge.php and 41 more
With --format=json the error envelope says "stage": "analyze" (see
JSON output). --jobs=1 runs everything in
one process.
Unicode classes between runs
The ReDoS analysis, the automata and the lint rules that ask them read the
characters a Unicode class matches (\p{Han}, \w under /u, [[:alpha:]])
from the running PCRE2, by matching it over every code point: a few
milliseconds per class, once per process. The binary keeps nothing between
runs, unless --cache-dir <dir> names a directory to keep those answers in:
vendor/bin/regex lint src/ --redos --cache-dir=var/cache/regex
The next run reads them back instead of asking the engine again; the
workers of --jobs share them. Each answer is one small text file under
<dir>/class-sets-<hash>/, <hash> naming the library release, the PHP
and the PCRE2 that gave it, so an upgrade of any of them reads the classes
again. A directory other users may write into is not read; one that cannot
be written keeps nothing, and the run goes on. Removing the directory
empties it. On about 1,800 Unicode patterns gathered from open-source
projects, --redos on, a second run takes about 2.3 s where the first takes
2.7 s.
Baseline
A baseline records the issues a codebase has today, so that a CI run fails only on new ones:
vendor/bin/regex lint src/ --generate-baseline regex-baseline.json
vendor/bin/regex lint src/ --baseline regex-baseline.json
Both options take their file after a space or an =.
- What an entry matches. An issue is left out when an entry has the same issue identifier, the same file and the same pattern (compared by a hash of its exact bytes). The line only decides between entries that share all three, so moving a pattern down a file, or a message reworded in a newer release, does not bring the issue back. One entry leaves out one issue: a second, identical pattern in the same file is reported. When copies of a pattern are added, the entries are aligned with the issues in line order, as a diff aligns two versions of a file, so the copy reported is the one inserted, not one that moved.
- Every format. A baselined issue is gone from the console, JSON, GitHub, Checkstyle and JUnit reports alike, and the exit code is computed without it.
- Paths. Files are recorded relative to the directory the command runs from: generate and apply the baseline from the same directory, usually the project root.
- The file.
{"version": 1, "issues": [...]}, each issue with itsfile,line,column,issue_id,message,severity,patternandpattern_hash; the JSON output reference lists them. Bytes that are not valid UTF-8 are written\xHH, so the file is always valid JSON. - Entries that match nothing. An entry no issue takes any more, the
issue fixed or its pattern removed, is reported as
regex.ignore.unmatchedon the entry’s file and line: remove it, or generate the baseline again. The entries of a file gone since, under a path the run lints and out of those it excludes, are stale and reported too. A run oversrc/Controllersays nothing of the entries ofsrc/Legacy, read or gone.--no-report-unmatched-ignoresturns the report off, as it does for the inline markers. - A 1.x baseline, a plain list, is refused like any non-baseline file:
exit code 2,
stage: "usage"in JSON, before anything is scanned — even on a run that also passes--generate-baseline. Regenerate it once. - Exit code 2 when the baseline file is missing, empty or not a baseline, or when the generated file cannot be written.
The column of a JSON result is a 1-based byte column in its line, and
file_offset a 0-based byte offset in the file.
CI/CD Integration
GitHub Actions
name: regex-lint
on: [pull_request]
permissions:
contents: read
jobs:
regex:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
# The monorepo is a dev dependency of composer.json
# (see the install block at the top of this guide)
- run: composer install --no-interaction --no-progress
- run: vendor/bin/regex lint src/ --format=github
GitLab CI
regex-lint:
image: php:8.2
script:
- composer install
- vendor/bin/regex lint src/ --format=json > report.json
artifacts:
reports:
json: report.json
Jenkins
vendor/bin/regex lint src/ --format=checkstyle --output=regex-checkstyle.xml
Tips and Tricks
Quick Pattern Test
# Test a pattern inline
vendor/bin/regex explain '/^[a-z]+$/'
# Test multiple patterns
for pattern in '/^test$/' '/^hello$/i' '/\d+/'; do
echo "Pattern: $pattern"
vendor/bin/regex validate "$pattern"
done
Debug ReDoS Issues
# Find all ReDoS issues in your code
vendor/bin/regex lint src/ --redos --no-lint --no-validate --no-optimize
# Get detailed analysis
vendor/bin/regex debug '/your-pattern/'
Generate HTML for Documentation
vendor/bin/regex highlight '/^your-pattern$/' --format=html
Common Issues
“Unknown command”
Every command name is listed in the command table. An
unknown name prints Unknown command: <name>, followed by
Did you mean "<command>"? when a command is one or two letters away
(anlyze), then the help, and exits with 2:
# Wrong
vendor/bin/regex check '/test/'
# Correct
vendor/bin/regex analyze '/test/'
“Pattern not found”
The CLI expects a pattern in a specific format:
# Wrong (missing delimiters)
vendor/bin/regex validate 'test'
# Correct
vendor/bin/regex validate '/test/'
vendor/bin/regex validate '#test#'
Colors Not Showing
Force ANSI output:
vendor/bin/regex highlight '/test/' --ansi
Learn More
- LSP Integration - IDE integration via Language Server Protocol
- MCP Server - The same judgement for a coding agent, over the Model Context Protocol
- ReDoS Guide - Preventing catastrophic backtracking
- Cookbook - Ready-to-use patterns
New to regex? Start with the tutorial, or read Regex in PHP for the PHP fundamentals first.
Language Server
regex lsp runs the language server on stdin and stdout, as
vendor/bin/regex-lsp does: the phar carries it, so one download serves the
editor too. Point the editor’s server command at it:
php regex.phar lsp
The CLI package installed alone does not carry the server: there, the
command exits with code 2 and names the package to install,
php-regex/regex-language-server.
MCP Server
regex mcp runs the MCP server on stdin and stdout, as
vendor/bin/regex-mcp does: the phar carries it, so one download serves a
coding agent too. Name it in the client’s configuration:
php regex.phar mcp --php-version=8.2
--php-version, --pcre-version and --locale set the target every tool
judges for; a call may name its own php_version and pcre_version. The
CLI package installed alone does not carry the server: there, the command
exits with code 2 and names what to install, php-regex/regex-mcp for the
server, mcp/sdk when the package is present without its SDK.
Self-Update (PHAR Only)
If using the PHAR, update to the latest version:
regex self-update
End of CLI guide.