CLI Guide

This guide covers PHPRegex’s command-line tool and the workflows it enables. The binary requires PHP 8.2 or later.

Installation

composer require --dev php-regex/php-regex:2.x-dev

Once installed, the binary is vendor/bin/regex:

vendor/bin/regex --help

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

Note: Replace vendor/bin/regex with regex in 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
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)
lint Lint entire codebase for regex issues
clear-cache Clear the regex parser cache
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
--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 -.

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 beginning of a line
    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 . "!" is str_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 and analysis_version next to the 1.x 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",
    "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:      "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" (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. 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 text diagram of pattern structure (default):

vendor/bin/regex diagram '/^[a-z]+@[a-z]+\.[a-z]+$/i'

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

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 ($)

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
        ^

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 or a helper of their own would otherwise be reported as having no patterns at all.

composer/pcre is recognised out of the box; the other libraries are opt-in:

# Add nette/utils on top of the default composer/pcre support
vendor/bin/regex lint src/ --interop=composer-pcre,nette-utils

# Native preg_* calls only
vendor/bin/regex lint src/ --no-interop
Preset Recognised calls
composer-pcre Composer\Pcre\Preg and Composer\Pcre\Regex (enabled by default)
nette-utils Nette\Utils\Strings::match, matchAll, split, replace
spatie-regex Spatie\Regex\Regex::match, matchAll, replace
laravel-str Illuminate\Support\Str::match, matchAll, isMatch, replaceMatches

Wrappers are matched on their fully qualified name, using the file’s use statements: a class of your own named Preg is left alone.

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'
{
  "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.

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. An instance call ($str->matches(...)) 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+)+$/', ...)

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 by Younes ENNAJI

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/2] Scanning files
  Scanned 1 files, found 1 patterns.

  [2/2] Analyzing patterns

  PASS No issues found, 0 optimizations available.
  Time: 0.02s  Memory: 8 MB  Cache: 3 hits, 1 misses  Processes: 10
  If PHPRegex helps, a GitHub star is appreciated: https://github.com/php-regex/php-regex

With Issues:

vendor/bin/regex lint src/ --redos
  [1/2] Scanning files
  Scanned 1 files, found 2 patterns.

  [2/2] Analyzing patterns
  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 Nested quantifiers can cause catastrophic backtracking.
         ↳ Consider atomic groups (?>...) or possessive quantifiers — verify the rewrite still matches everything you need.
    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 Exponential backtracking (proven). Severity: CRITICAL, confidence: MEDIUM.
         ↳ Attack: "a" x n . "!" Unbounded quantifier detected. May cause backtracking on non-matching input. ...

  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). Unbounded quantifier detected. ...

  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 ever...


  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.


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)
ide string IDE for clickable links
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"
extraction.interop array Wrapper libraries whose calls carry patterns (default ["composer-pcre"])
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; redos.search turns the search cost of the ReDoS check off

checks.redos, checks.optimizations and checks.lint 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.

Removed Keys

These 1.x keys are refused, with the message naming what replaced them:

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:

  1. --php-version and --pcre-version on the command line;
  2. phpVersion and pcreVersion in regex.json;
  3. composer.json in the working directory: config.platform.php if set, else the lowest version require.php allows (^8.2 || ^8.3 is PHP 8.2), and then the patterns are also validated on the later PHP versions the constraint allows (Several PHP versions); The COMPOSER environment variable names another file, as it does for Composer;
  4. 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. Without a PCRE2 release, the command uses the one the target PHP bundles (10.40 for PHP 8.2, 10.42 for 8.3, 10.44 for 8.4 and 8.5), or the PCRE2 of the running PHP when the target is the running PHP.

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. A floor below PHP 8.2 is judged as PHP 8.2, the oldest this library supports, and the command says so. A constraint the command cannot read (*, <9, a branch name) falls back to the running PHP, with a note naming it; reading composer.json 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 it bundles, or with the one --pcre-version or pcreVersion names. >=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.
  • 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 text a{,3} under PCRE2 before 10.43 (PHP 8.2 and 8.3) and a zero to three times from 10.43 (PHP 8.4). Write a{0,3} or a\{,3}, which every version reads alike; "compat.meaningChanges": false in checks.lint.rules turns the check off.

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.

Single-pattern commands, such as analyze or validate, judge for the running PHP unless --php-version or --pcre-version is given; their banners show Target PHP / Target PCRE2 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
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
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

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 missing pattern, a removed option such as --redos-no-jit, an --input-file that cannot be read, an --output file that cannot be written, and a regex.json that cannot be read (lint and debug) — 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:

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
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), 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

Ignoring Patterns

Inline Comments

preg_match('/pattern/', $input); // @regex-ignore-next-line

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
    },
    "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",
                    "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",
                    "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. 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.

Checkstyle (for CI)

vendor/bin/regex lint src/ --format=checkstyle --output=checkstyle.xml

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
--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
--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> Wrapper libraries to read patterns from (comma separated, none to disable)
--no-interop Read patterns from native preg_* calls only
--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 --redos or via configuration. The default threshold is high: a proven quadratic pattern is medium, and is reported with --redos-threshold=medium only.

--redos-mode=off and --redos-no-jit were removed from lint in 2.0: use --no-redos to skip the analysis; the confirmation always runs without JIT. Either one is now a usage error (exit code 2), as is an unknown --format. analyze and debug refuse --redos-no-jit too, for the same reason.

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.

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 its file, line, column, issue_id, message, severity, pattern and pattern_hash; the JSON output reference lists them. Bytes that are not valid UTF-8 are written \xHH, so the file is always valid JSON.
  • A 1.x baseline, a plain list, is still read, matched on file, line and message as 1.x did; the run prints a note suggesting to generate it again.
  • 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]

jobs:
  regex:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
      # php-regex/regex-cli is a dev dependency of composer.json
      # (composer require --dev php-regex/regex-cli)
      - 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>, 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

New to regex? Start with the tutorial, or read Regex in PHP for the PHP fundamentals first.


Self-Update (PHAR Only)

If using the PHAR, update to the latest version:

regex self-update

End of CLI guide.

Edit on GitHub