composer/pcre
Type-safe wrappers around PHP's preg_* functions that fail loudly instead of returning unexpected nulls. Enforces PREG_UNMATCHED_AS_NULL, simplifies return types for PHPStan and Psalm, and includes a PHPStan extension for better regex analysis.
Install it explicitly, even though it usually shows up in vendor/ already as a transitive dependency of composer/composer (which is typically a require-dev or global install, so it will not be there in a trimmed production build):
composer require composer/pcre
Where to look first
src/Preg.php — a static, drop-in replacement for every preg_* call you already write. This is where 90% of your day-to-day usage lives.src/Regex.php + src/MatchResult.php, MatchAllResult.php, ReplaceResult.php — an alternative, result-object API for code where by-ref $matches arguments feel clunky.extension.neon — the PHPStan/Larastan extension. Read this early if you run static analysis; it's the single biggest reason to adopt the package in a Laravel app.First use case
Find one preg_match in your app — a custom validation rule, a route/slug parser, a log-line parser in an artisan command — and convert it:
// before
if (preg_match('/^(\d{4})-(\d{2})$/', $value, $m)) {
[$year, $month] = [$m[1], $m[2]];
}
// after
use Composer\Pcre\Preg;
if (Preg::isMatch('/^(\d{4})-(\d{2})$/', $value, $m)) {
[$year, $month] = [$m[1], $m[2]];
}
The two immediate wins: isMatch returns a real bool (no more === 1), and a malformed pattern throws Composer\Pcre\PcreException instead of returning false/null and silently falling through your if.
The mechanical swap. These are the one-to-one mappings you can apply across a codebase:
| Native | Preg:: |
|---|---|
preg_match |
match (int) / isMatch (bool) |
preg_match + PREG_OFFSET_CAPTURE |
matchWithOffsets |
preg_match_all |
matchAll / isMatchAll |
preg_replace |
replace |
preg_replace_callback |
replaceCallback |
preg_replace_callback_array |
replaceCallbackArray |
preg_grep |
grep |
preg_split |
split / splitWithOffsets |
Custom validation rules. This is the highest-value Laravel integration, because validation rules are exactly where a failed regex silently degrades into "input accepted":
class IsIsbn extends \Illuminate\Contracts\Validation\ValidationRule
{
public function validate(string $attribute, mixed $value, \Closure $fail): void
{
if (!is_string($value) || !Preg::isMatch('/^\d{13}$/', $value)) {
$fail("The {$attribute} is not a valid ISBN.");
}
}
}
Because the return is bool, there's no === 1 noise and PHPStan/Larastan can narrow $value correctly.
Result objects in services. When a method needs to return "did it match, and what did we get", Regex reads better than a by-ref out-parameter leaking through a method signature:
use Composer\Pcre\Regex;
$result = Regex::match('/^(?<name>[^<]+) <(?<email>[^>]+)>$/', $header);
if ($result->matched) {
return new Contact($result->matches['name'], $result->matches['email']);
}
Because of PREG_UNMATCHED_AS_NULL, $result->matches always contains every named and numbered group — null for unmatched, string for matched. Named groups make this a genuinely pleasant way to parse fixed-format strings.
Strict groups for parser-shaped code. When you know the pattern has no optional branches, use matchStrictGroups / matchAllStrictGroups and you get a plain array<string|int, string> with no nullable members:
// Guaranteed strings for group 1 and 2, or an exception
Preg::matchStrictGroups('/^(\w+)@(\w+\.\w+)$/', $email, $m);
Filtering arrays. Preg::grep is a clean replacement for array_filter + preg_match, and is the documented workaround for the unsupported preg_filter:
$errorLines = Preg::grep('/^\[error\]/', $logLines);
Note it preserves keys (see gotchas), so array_values() before handing to Inertia/JSON.
Templating and normalisation with callbacks. replaceCallback is ideal for placeholder substitution and slugging where preg_replace with backreferences gets unreadable:
$slug = Preg::replace('~[^a-z0-9]+~i', '-', trim(Str::ascii($title)));
$out = Preg::replaceCallback('/\{\{(\w+)\}\}/', fn ($m) => e($data[$m[1]] ?? ''), $template);
Handle exceptions at the boundary. PcreException is a runtime error, not a validation result. Catch it in your controller/middleware/form request and convert it into a 422 or a ValidationException, otherwise a user-supplied pattern (or a pattern from a DB/config value) becomes a 500:
try {
$count = Preg::matchAll($dynamicPattern, $subject, $matches);
} catch (\Composer\Pcre\PcreException $e) {
throw ValidationException::withMessages(['pattern' => 'Invalid pattern supplied.']);
}
Turn on the PHPStan extension with Larastan. If you don't use phpstan/extension-installer, add to phpstan.neon:
includes:
- vendor/composer/pcre/extension.neon
With Larastan this gives you inferred $matches array shapes and, importantly, static validation of literal regexes — a typo like /(foo/ becomes a build failure rather than a production PcreException.
PREG_UNMATCHED_AS_NULL is always on (2.0+, including replaceCallback in 3.0+). This is the one that breaks existing code during migration. Given /(a)(b)*(c)(d)*/ against "ac":
[0 => 'ac', 1 => 'a', 2 => '', 3 => 'c'] — group 2 is '' whether it matched empty or nothing, and group 4 is absent entirely.[0 => 'ac', 1 => 'a', 2 => null, 3 => 'c', 4 => null].So every isset($m[4]) check in your codebase is now wrong (the key always exists), and every $m[2] === '' check is now wrong too. Audit conversions for isset() on $matches and swap to !== null. The offset-based matchWithOffsets shape is [string, int]|null per group, not the flat native shape — destructuring code needs adjusting.
*StrictGroups throws on optional groups. It's safe when every subpattern is mandatory or can match empty ((.*) is fine — it yields ''). It will throw UnexpectedNullMatchException (a PcreException) the moment a (foo)? / (a|b) branch doesn't participate. Never use strict variants with user-supplied patterns; they're for patterns you own and have reasoned about.
Flags are gated by method, not accepted silently. match/matchAll reject PREG_OFFSET_CAPTURE — use matchWithOffsets/matchAllWithOffsets. split rejects PREG_SPLIT_OFFSET_CAPTURE — use splitWithOffsets. matchAll rejects PREG_SET_ORDER, and there is no replacement, so you must reshape the result yourself (transpose the matchAll output). PREG_SPLIT_NO_EMPTY and PREG_SPLIT_DELIM_CAPTURE are fine.
replace/replaceCallback refuse array subjects. Native preg_replace accepts string|array and returns the same shape, which is convenient for bulk-cleansing a collection of strings. composer/pcre deliberately narrows this to a single string. Use array_map with Preg::replace, or ->map(fn ($s) => Preg::replace(...)) on a collection.
preg_filter is not supported at all — its "return matches only, keep keys, null on no match" API isn't typeable. Compose Preg::grep + Preg::replace instead.
Preg::grep preserves keys. preg_grep semantics are retained. If the result goes into a JSON response, an Inertia prop, or implode, wrap in array_values() — otherwise you get a JSON object instead of an array.
Exceptions where you didn't expect them. Backtrack/recursion limit breaches and invalid UTF-8 subjects both throw now instead of returning false. On user-controlled input (search boxes, import files, uploaded CSVs) this is a real 500 risk. Either validate patterns at configuration time, or wrap the call site.
Pattern strings from config/database should be validated once at boot, e.g. in a service provider or a test asserting Preg::isMatch($pattern, '') doesn't throw. PcreException::getMessage() includes the PCRE error, but the pattern itself is the useful context — log it alongside.
Version pinning matters. 3.x requires PHP 7.4+ and applies PREG_UNMATCHED_AS_NULL to replaceCallback; 2.x does not. If you share regex helper code between a Laravel app and an older package, the callback $matches shape differs between majors. Also note 3.4.0 dropped PHPStan 1.x support — if a package in your graph pins PHPStan 1.x, pin composer/pcre below 3.4.
Don't force it for everything. It's a thin wrapper with no pattern caching, no named-group extraction sugar, and no "find and replace all with a builder" API. If you need readable composition, named-pattern extraction, or a fluent API, rawr/t-regx is the intended escape hatch per the README. For a handful of preg_match calls, the honest answer is often to leave them alone — adopt composer/pcre where the null return actually caused (or could cause) a bug, plus wherever you want the Larastan type narrowing.
How can I help you explore Laravel packages today?