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.2
- 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-11 17:10:09 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.2+ 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 (a literal, 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. PREG_OFFSET_CAPTUREandPREG_UNMATCHED_AS_NULLare read, as are named arguments in any order.- 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 never replaces Psalm's own description of the
preg_*functions: their purity, return types, parameter checks and taint flows stay as they are.
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 PHP 8.2 or later, while Psalm 6 itself runs on PHP 8.1: Psalm must run with PHP 8.2 or later to load it. 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}>
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.
- 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.
The callbacks of preg_replace_callback() and preg_replace_callback_array(),
and the results of preg_split(), preg_grep(), preg_replace() and
preg_filter(), keep Psalm's types: the plugin only checks their patterns.
Configuration
Patterns are judged for the PHP version Psalm analyses for (phpVersion in
psalm.xml, --php-version, or your composer.json), 8.2 at least, with the
PCRE2 release that PHP bundles: 10.40 for 8.2, 10.42 for 8.3, 10.44 for 8.4 and
8.5. The plugin's options override it:
<plugins> <pluginClass class="PHPRegex\Psalm\Plugin"> <phpVersion>8.2</phpVersion> <pcreVersion>10.40</pcreVersion> </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.
A value the plugin cannot read 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.
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