php-regex / regex-phpunit
PHPUnit assertions backed by the PHPRegex parser: match with exact capture semantics, compilation against the running engine, and proven-linear ReDoS verdicts.
Fund package maintenance!
Requires
- php: >=8.1
- php-regex/regex-parser: 2.x-dev
- php-regex/regex-redos: 2.x-dev
- phpunit/phpunit: ^9.6|^10.0|^11.0|^12.0|^13.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-21 05:48:31 UTC
README
PHPRegex PHPUnit
PHPUnit assertions backed by the PHPRegex parser: assert what a pattern captures with exact capture semantics, assert the running engine compiles it, and assert a proven-linear ReDoS verdict — with group names validated against the pattern itself, so a typo'd name is a failure that names the real groups, never a silent pass.
Requires PHP 8.1+ and PHPUnit 9.6 or later — 10, 11, 12 and 13 (10 runs on PHP 8.1, 11 on 8.2, 12 on 8.3, 13 on 8.4).
Documentation: php-regex.com — the PHPUnit guide walks the assertions end to end.
Features
assertPatternMatches()runs one guarded match attempt and judges a capture expectation: which groups participated, what each captured, and at which byte offset- The expectation's keys are validated against the pattern's own groups before any value is compared: an unknown name or number fails listing the pattern's real groups, a name that owns several group numbers (a
(?J)pattern) fails listing them, and a number with no group behind it under thenmodifier — where only named groups still capture, renumbered — fails saying what the modifier did assertPatternDoesNotMatch()asserts no match — a pattern that does not parse or an engine refusal fails the assertion instead of passing it as "not a match"assertPatternCompiles()asks the running engine, carrying the PCRE message and the offset it names on a refusalassertPatternIsLinear()asserts a proven-linear ReDoS verdict, and fails with a stable first line —proven unsafe:for a held witness or a quadratic unanchored search,could not analyze:for a budget, a model or a parse refusal- The four constraints behind the assertions —
MatchesPattern,DoesNotMatchPattern,CompilesPattern,IsLinearPattern— compose withassertThat()and boolean composition; assert no match withDoesNotMatchPattern, sinceLogicalNotaroundMatchesPatternreads a pattern that does not parse, or an engine refusal, as "not a match" - A parse failure carries the library's message, byte position and caret snippet; an engine refusal carries
preg_last_error_msg()
Installation
composer require --dev php-regex/regex-phpunit
The package brings php-regex/regex-parser and php-regex/regex-redos with it: the parser, the pattern facts and the ReDoS analysis the assertions read.
Usage
Add the trait to your test case and assert:
use PHPRegex\PHPUnit\RegexAssertions; final class SlugTest extends TestCase { use RegexAssertions; public function test_a_slug_matches(): void { self::assertPatternMatches('/^[a-z0-9-]{3,}$/', 'php-regex'); self::assertPatternDoesNotMatch('/^[a-z0-9-]{3,}$/', 'PHP Regex!'); } }
The methods are static, so the trait also works in a bare Assert subclass — arrange-only helpers included.
The capture expectation
assertPatternMatches() takes an expectation keyed by group number or name. An empty expectation asks only whether the pattern matched.
| expectation | asks |
|---|---|
true |
the group participated, any value |
false |
the group did not participate |
'value' |
exactly that captured string |
['value', byteOffset] |
that string, at that byte offset |
self::assertPatternMatches('/(?<alpha>a+)(?<beta>b+)?/', 'aa', [ 'alpha' => 'aa', // exactly 'aa' 'beta' => false, // present in the pattern, did not participate ]); self::assertPatternMatches('/(\d{4})-(\d{2})-(\d{2})/', '2026-10-17', [ 1 => '2026', 2 => ['10', 5], // '10', starting at byte 5 ]);
When only the captures differ, the failure renders the expectation against the engine's answer, both in the expectation's own spelling:
Failed asserting that '2026-10-17' matches the pattern '/(\\d{4})-(\\d{2})/'.
--- Expected
+++ Actual
@@ @@
-[1 => '2026', 2 => '01']
+[1 => '2026', 2 => '10']
The output above is pinned to PHPUnit 11; the wording of the surrounding frame moves with the PHPUnit release, the diff lines do not.
Offsets are bytes
Offsets are byte offsets everywhere, PREG_OFFSET_CAPTURE semantics, under /u as everywhere else — é is two bytes:
self::assertPatternMatches('/é(l+)/u', 'héllo', [1 => ['ll', 3]]);
One match attempt
Each assertPatternMatches() call runs one preg_match() attempt, never preg_match_all(): repeated captures are not collected, and a group that could match twice is judged on the attempt the engine made — the leftmost one.
Validation failures
Every expectation key is resolved against the pattern's own groups before any value is compared:
Failed asserting that 'ab' matches the pattern '/(?<alpha>a)(?<beta>b)/'.
the pattern has no group named 'alpa'; its groups are 0 (the whole match), 1 'alpha', 2 'beta'.
A name that owns several group numbers — a (?J) pattern — fails rather than trusting one of them:
the group name 'dup' owns several group numbers (1, 2); assert by number instead.
Group 0 is the whole match: it participates whenever the pattern matches, so 0 => false is a contradiction and fails as one. A shape the vocabulary does not spell fails naming what it expected. Under the n modifier the engine keeps only the NAMED groups capturing, renumbered from 1 — in /(a)(?<x>b)/n the group x is group 1 — so a name, or the number the renumbering gave it, asserts as usual, and a number with no group behind it fails saying what the modifier did:
the pattern has no group 2 under the n modifier; only named groups capture under it, renumbered from 1. Its groups are 0 (the whole match), 1 'x'.
A pattern that does not parse fails with the library's message, byte position and caret snippet; preg_match() answering false fails with preg_last_error_msg(), never as "no match".
Compilation and linearity
self::assertPatternCompiles('/^\d{4}-\d{2}-\d{2}$/'); self::assertPatternIsLinear('/^[a-z0-9-]{3,}$/');
assertPatternCompiles() leaves the truth to the engine alone: a pattern beyond the library's own parse limits still passes here when the engine takes it — while assertPatternMatches(), which parses to validate the expectation, fails with the limit message. The asymmetry is deliberate: each assertion asks its own question.
assertPatternIsLinear() fails unless one match attempt is proven linear and the unanchored search carries no quadratic-cost witness. The failure's first line is a stable prefix, so a quarantine can tell the two apart:
| first line | verdict |
|---|---|
proven unsafe: |
an unsafe attempt (with the witness input), or a quadratic unanchored search |
could not analyze: |
the analysis budget was exceeded, or the pattern sits outside the analyzer's model |
proven unsafe: Exponential backtracking (proven). A witness input is "a" x n . "!".
Verdicts are memoized per pattern for the process — per worker under paratest — and the analysis is deterministic per release, so the memoization cannot be observed.
Documentation
- The PHPUnit guide — every assertion, the expectation vocabulary, the failure taxonomy
- The ReDoS guide — how the linearity verdict is reached and what a witness means
- Choose a package — the other sixteen packages of the family
- Backward compatibility promise — what stays stable across releases
Resources
- Upgrading from 1.x — moving from
yoeunes/regex-parser1.3 to 2.0, every change that needs one on your side - Documentation
- The analysis layer behind the assertions: regex-parser and regex-redos
- Changelog
- Report issues and send pull requests in the main PHPRegex repository
Sponsors
If PHPRegex saves you time, consider sponsoring its maintenance.
License
MIT. See LICENSE.