php-regex / regex-psalm
Psalm plugin that types $matches from the pattern of preg_match() and preg_match_all(), and reports the patterns your target PHP refuses.
Fund package maintenance!
Requires
- php: >=8.1
- ext-simplexml: *
- php-regex/regex-automata: 2.x-dev
- php-regex/regex-linter: 2.x-dev
- php-regex/regex-parser: 2.x-dev
- vimeo/psalm: ^6.19
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-31 23:28:44 UTC
README
PHPRegex Psalm
A Psalm plugin that reads your regex patterns: it types $matches from the
pattern of preg_match() and preg_match_all(), and reports the patterns the
PHP version you target refuses.
Requires PHP 8.1+ to run and Psalm 6.19+. MIT licensed.
Documentation: php-regex.com — the Psalm guide is the place to start.
Features
- Where
preg_match()returned 1,$matchesis the shape of the pattern: one key per group, named groups included, each holding what the group can capture (literals, as'a'|'aa'|'aaa'for(a{1,3}), a non-falsy string, a non-empty string), optional where PHP may leave the key out. Where it did not,$matchesis[]. - After
preg_match_all(),$matchesis the shape of the pattern, in either order, for a pattern without\Kand an offset absent or a constant ≤ 0; where it returned a count of 1 or more, no list in it is empty. - The callback of
preg_replace_callback()takes the shape of the pattern, aspreg_match()writes it on a match. preg_split()returns alist<non-empty-string>underPREG_SPLIT_NO_EMPTY, and[piece, offset]pairs typedlist{string, int<0, max>}underPREG_SPLIT_OFFSET_CAPTURE, in anon-empty-listunless empty pieces are left out.- Where
preg_match()returned 1, astringsubject is anon-empty-stringwhen the pattern never matches'', anon-falsy-stringwhen it matches neither''nor'0', on a call that does not type$matches. When the pattern matches no more than 16 strings, as/^(?:yes|no)$/D, the subject is one of them. PREG_OFFSET_CAPTUREandPREG_UNMATCHED_AS_NULLare read, as are named arguments in any order.preg_quote()of literals is the literal it returns, so a pattern concatenated from literals and such calls is read as any literal. Aroundpreg_quote()of a text not known, as'/^' . preg_quote($id, '/') . '-(\d+)$/',$matchesofpreg_match()is typed from the rest of the pattern.- An invalid pattern is reported as
InvalidRegexPattern, with the error and its offset in the pattern, in everypreg_*call:preg_match(),preg_match_all(),preg_replace(),preg_replace_callback(),preg_replace_callback_array()(its keys),preg_split(),preg_grep()andpreg_filter(). - Patterns are judged for the PHP version Psalm analyses for, with the PCRE2 release that PHP bundles: a pattern your PHP 8.4 accepts but PHP 8.2 refuses is reported on a project that targets 8.2.
- The plugin keeps Psalm's own description of the
preg_*functions: their purity, parameter checks and taint flows stay as they are, and a type it narrows keeps the data flow of Psalm's.
Installation
composer require --dev php-regex/regex-psalm
vendor/bin/psalm-plugin enable php-regex/regex-psalm
psalm-plugin enable adds the plugin to your psalm.xml; you can add it by
hand instead:
<plugins> <pluginClass class="PHPRegex\Psalm\Plugin"/> </plugins>
The plugin runs on every PHP Psalm 6 runs on, 8.1 or later. The code you analyse may target an older PHP (see "Configuration").
Usage
Run Psalm as usual. Below each example is the message of Psalm's Trace
issue for the @psalm-trace annotation, as Psalm printed it.
function release(string $tag): ?int { if (!preg_match('/^v(?<major>\d+)\.(?<minor>\d+)(?:-(rc|beta)(\d+))?$/', $tag, $m)) { return null; } /** @psalm-trace $m */ return (int) $m['major']; }
$m: array{0: non-falsy-string, 1: non-empty-string, 2: non-empty-string, 3?: ''|'beta'|'rc', 4?: non-empty-string, major: non-empty-string, minor: non-empty-string}
With flags:
if (preg_match('/(\w+)@(\w+)?/', $text, $m, PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL)) { /** @psalm-trace $m */ }
$m: array{0: list{non-falsy-string, int<0, max>}, 1: list{non-empty-string, int<0, max>}, 2: list{non-empty-string|null, int<-1, max>}}
After preg_match_all():
preg_match_all('/#([a-z]+)/', $text, $m); /** @psalm-trace $m */ preg_match_all('/#([a-z]+)(?::(\d+))?/', $text, $sets, PREG_SET_ORDER); /** @psalm-trace $sets */
$m: array{0: list<non-falsy-string>, 1: list<non-falsy-string>}
$sets: list<array{0: non-falsy-string, 1: non-falsy-string, 2?: non-empty-string}>
Where preg_match_all() found a match (Psalm reports the bare call as a
RiskyTruthyFalsyComparison; a cast to bool reads the same):
if ((bool) preg_match_all('/#([a-z]+)/', $text, $m)) { /** @psalm-trace $m */ }
$m: array{0: non-empty-list<non-falsy-string>, 1: non-empty-list<non-falsy-string>}
The callback of preg_replace_callback(), and preg_split():
preg_replace_callback('/#([a-z]+)(?::(\d+))?/', static function (array $m): string { /** @psalm-trace $m */ return $m[1]; }, $text); $words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY); $pieces = preg_split('/,/', $text, -1, PREG_SPLIT_OFFSET_CAPTURE); /** @psalm-trace $words */ /** @psalm-trace $pieces */
$m: array{0: non-falsy-string, 1: non-falsy-string, 2?: non-empty-string}
$words: false|list<non-empty-string>
$pieces: false|non-empty-list<list{string, int<0, max>}>
The subject, where preg_match() returned 1:
function order_id(string $input): string { if (!preg_match('/^\d+$/', $input)) { throw new InvalidArgumentException('Not an order id.'); } /** @psalm-trace $input */ return $input; }
$input: non-empty-string
function answer(string $input): bool { if (!preg_match('/^(?:yes|no)$/', $input)) { throw new InvalidArgumentException('Answer yes or no.'); } /** @psalm-trace $input */ return 'yes' === $input; }
$input: 'no'|'no\n'|'yes'|'yes\n'
$ matches before a final "\n" too: /^(?:yes|no)$/D or \z leaves it out.
An invalid pattern, in app/broken.php:
<?php function broken(string $text): ?string { return preg_replace('/(?<n>a)(?<n>b)/', '', $text); }
ERROR: InvalidRegexPattern - app/broken.php:5:25 - Regex pattern is invalid for PHP 8.4 with PCRE2 10.44: Duplicate group name "n" at position 12 (offset 12).
What is typed, and what is not
$matches of preg_match() takes the shape where Psalm reads the call as a
condition:
if (preg_match(…)), and the code that followsif (!preg_match(…)) { return; };- the condition of a ternary,
preg_match(…) ? $m[1] : null; &&and||,(bool) preg_match(…), andif ($found = preg_match(…)).
No condition is read from the other forms: there, and outside the checked
branch, $matches keeps the type Psalm gives it, array<array-key, string>:
- a comparison, with anything:
1 === preg_match(…),preg_match(…) !== false,false === preg_match(…),preg_match(…) > 0. Psalm reads a condition through a comparison withtrueorfalseas if the call returned a boolean; it returns0when nothing matches, so the plugin drops its condition from every comparison; - a silenced call,
@preg_match(…); - a result kept in a variable and tested later:
$ok = preg_match(…); if ($ok); - the condition of a
whileloop; $matchesthat is not a variable, as$this->matches.
The plugin reads a call only when it can tell what the pattern is:
- the pattern and the flags must each have one constant value: a literal, a constant, or a variable holding one literal. A pattern that may be one of two literals, or flags from a parameter, are left to Psalm.
- a pattern concatenated around
preg_quote()of a text not known types$matchesofpreg_match()where it returned 1, and nothing else: it is never reported, the subject is not narrowed, and where the call did not return 1$matcheskeeps Psalm's type (a text holding the delimiter makes the pattern fail to compile, which leaves$matchesas it was). Where the text would not be read as literal characters (in a class, in braces, after\,(?or\Q, before a quantifier), the call is left to Psalm. - Psalm keeps a literal string only below
maxStringLengthbytes (1000 by default, an attribute of<psalm>): a longer pattern is not constant for Psalm, and is not read. - a call that unpacks its arguments,
preg_match(...$args), is left alone. - a function of the same name in your namespace is yours: an unqualified
preg_match()that reaches it is left alone. - a pattern the PHP running Psalm does not compile, or the target PHP refuses, is not typed (the second is reported).
- a pattern past the library's length limit
(
RegexParser::DEFAULT_MAX_PATTERN_LENGTH) is neither typed nor reported: PCRE may accept it. - a shape of more keys than Psalm's
maxShapedArraySizeis left to Psalm. preg_match_all()returnsfalseand leaves[]for an offset past the subject, and for a match that ends before it starts (\Kin a lookahead): a call whose pattern holds\K, or whose offset is not a constant ≤ 0, is left to Psalm.- Psalm gets no
numeric-string: Psalm 6.19's type combiner collapsesnumeric-string|'a'tonumeric-string, which would reject'none'inpreg_match(…) ? $m[1] : 'none'. A group of digits is anon-falsy-stringwhere it cannot be'0', else anon-empty-string; so is the subject of/^\d+$/. - a subject is narrowed to the strings its pattern matches only from offset
0 (no offset argument), up to 16 strings each below
maxStringLengthbytes, and not when it holds literals already: where the call is false, Psalm would take those strings out of it, and a match error returnsfalseon them too. Without/u, a caseless part, a shorthand class (\d,\w,\s…), a POSIX class or\breads the character tables of the localesetlocale()sets: such a subject keeps the facts of its pattern. - the subject of a call that types
$matcheskeeps its type: Psalm reads a false condition as the negation of what holds where it is true, and on two variables that negation would take the[]from$matches. A subject that may be another type thanstring, or that is read from the$matchesvariable, keeps its type too.
The callback of preg_replace_callback() takes the shape of a constant
pattern, or of a pattern built around preg_quote(), and the flags must be
constant too; a callback the plugin cannot type keeps Psalm's
callable(string[]): string. preg_match_all() reads its success from the
forms preg_match() reads its own from: compared with anything, as
preg_match_all(…) > 0, it says nothing of $matches. preg_split() reads
constant flags only. The callbacks of preg_replace_callback_array(), and the
results of preg_grep(), preg_replace() and preg_filter(), keep Psalm's
types, which already follow the subject's: the plugin only checks their
patterns.
Compared with the PHPStan extension
The plugin reads the same facts of a pattern as the PHPStan extension, and says less in two places:
- A group of digits is never a
numeric-string. For/^(\d+)-(?<y>\d+)?$/, PHPStan types$matchesasarray{0: non-falsy-string, 1: numeric-string, y?: numeric-string, 2?: numeric-string}, the plugin asarray{0: non-falsy-string, 1: non-empty-string, 2?: non-empty-string, y?: non-empty-string}. Psalm's type combiner readsnumeric-string|'none'asnumeric-string, so with anumeric-stringgroup Psalm would typepreg_match(…) ? $m[1] : 'none'as anumeric-stringand reject'none' === $v. - A replacement of
preg_replace()orpreg_filter()that refers to a group the pattern does not have is not reported.preg_replace('/(a)/', '[$2]', 'xay')returns'x[]y': PHP replaces$2with nothing. The PHPStan extension reports it asregex.replacement.undefinedGroup.
Configuration
Patterns are judged for the PHP version Psalm analyses for (phpVersion in
psalm.xml, --php-version, or your composer.json), 8.1 at least. That
version, and each version a guard or a marker keeps, gets its PCRE2 in this
order:
- the release
<pcreVersion>names, else the one thecomposer.jsonof Psalm's base directory pins inconfig.platform.lib-pcre("10.42", or"10.42.0"as Composer spells it), used as is, older or newer than the one that PHP bundles; - the PCRE2 of the PHP running Psalm, when the version judged is its minor version: on a PHP 8.4.27 that links 10.49, PHP 8.4 is judged with 10.49;
- the PCRE2 that PHP bundles: 10.39 for 8.1, 10.40 for 8.2, 10.42 for 8.3, 10.44 for 8.4 and 8.5.
The plugin's options override the version and the release:
<plugins> <pluginClass class="PHPRegex\Psalm\Plugin"> <phpVersion>8.2</phpVersion> <pcreVersion>10.40</pcreVersion> <locale>tr_TR.UTF-8</locale> </pluginClass> </plugins>
<phpVersion>: a version (8.2), aPHP_VERSION_ID(80200), orruntime, the PHP running Psalm with the PCRE2 it links.<pcreVersion>: a PCRE2 release (10.40), for a PHP built against another PCRE2 than the one it bundles, as some Linux distributions do.<locale>: theLC_CTYPElocale the patterns run under (tr_TR.UTF-8), the one of the PHP running Psalm by default.
A value the plugin cannot read, or a locale this machine cannot set, stops Psalm before any file is analysed.
With <phpVersion>8.2</phpVersion>, on Psalm running with PHP 8.4, the
(?a…) options of PCRE2 10.43 are reported in app/numbers.php:
<?php function is_ascii_number(string $input): bool { return 1 === preg_match('/^(?aD)\d+$/u', $input); }
ERROR: InvalidRegexPattern - app/numbers.php:5:29 - Regex pattern is invalid for PHP 8.2 with PCRE2 10.40: Invalid group modifier syntax at position 3 (offset 3).
On a project analysed below PHP 8.2, a pattern using the n modifier, which
PHP reads from 8.2 only, is not typed, and is reported as InvalidRegexPattern.
A call under if (PHP_VERSION_ID >= 80400), or after an early return below
it, is judged for the versions the guard keeps of the series Psalm analyses
for, the guard read from the file as regex lint reads it; one that keeps
none leaves the call unjudged. Psalm's own narrowing of the constant is not
read: Psalm narrows it under function_exists() of a newer function too,
polyfilled or not. A call under // @regex-target php>=8.4 (or
pcre>=10.43) is judged for the versions the marker keeps, and for those the
guard keeps too; a marker that keeps none, or cannot be read, is reported as
InvalidRegexTarget.
The InvalidRegexPattern issue
It is a plugin issue: set its level, or suppress it, as any other.
<issueHandlers> <PluginIssue name="InvalidRegexPattern" errorLevel="info"/> </issueHandlers>
/** @psalm-suppress InvalidRegexPattern */
Adopting on an existing project
The plugin may find invalid patterns, and the narrower $matches types may
show code that reads a key the pattern never writes. In app/release.php:
<?php function patch(string $tag): string { if (preg_match('/^v(\d+)\.(\d+)$/', $tag, $m)) { return $m[3]; } return ''; }
ERROR: InvalidArrayOffset - app/release.php:6:16 - Cannot access value on variable $m using offset value of '3', expecting 0, 1 or 2 (see https://psalm.dev/115)
ERROR: MixedReturnStatement - app/release.php:6:16 - Could not infer a return type (see https://psalm.dev/138)
Record what the plugin finds in a baseline, and fix the entries over time:
vendor/bin/psalm --set-baseline=psalm-baseline.xml
A later release may type a pattern more narrowly than the one before, which
may require regenerating the baseline with --set-baseline; the CHANGELOG
says when.
Backward compatibility
The Plugin class, the InvalidRegexPattern issue name and the
<phpVersion> and <pcreVersion> options stay for all of 2.x. See
the backward compatibility promise.
Documentation
- Psalm guide — the shape of
$matches, the invalid patterns, the PHP version judged, adopting the plugin - Diagnostics — how findings are reported and how to read them
Resources
- Upgrading from 1.x — moving from
yoeunes/regex-parser1.3 to 2.0, every change that needs one on your side - Documentation
- Report issues and send pull requests in the main repository