Lookahead & Lookbehind
debt(d7/e3/b3/t7)
Closest to 'only careful code review or runtime testing' (d7), regex101 helps interactively but phpstan won't catch logic errors in lookarounds; misuse surfaces only through testing.
Closest to 'simple parameterised fix' (e3), per quick_fix the remedy is rewriting the regex to use (?=...) or (?<=...) — contained to the pattern but more than a one-line swap when restructuring validation.
Closest to 'localised tax' (b3), regex patterns are isolated to validation points; applies_to spans contexts but each usage is self-contained.
Closest to 'serious trap' (t7), the misconception is exactly that lookarounds behave like normal patterns — but they're zero-width and don't appear in $matches, contradicting how capturing groups work; negation direction (?! vs ?<!) compounds the surprise.
Also Known As
TL;DR
Explanation
Lookahead (?=pattern): matches position where pattern follows. Negative lookahead (?!pattern): matches where pattern does NOT follow. Lookbehind (?<=pattern): matches position where pattern precedes. Negative lookbehind (?<!pattern): matches where pattern does NOT precede. All are zero-width — they do not consume characters and appear in no capture groups. Useful for: password validation (has digit, no spaces), splitting on boundaries, and extracting values without delimiters.
Common Misconception
Why It Matters
Common Mistakes
- Using lookbehind with variable-length patterns in older PCRE — PHP 7.3+ supports variable-length lookbehinds; earlier versions do not.
- Confusing lookahead as capturing — lookaheads/lookbehinds match positions, not characters; they add nothing to $matches.
- Negating the wrong assertion — (?!foo) means 'not followed by foo'; (?<!foo) means 'not preceded by foo'.
- Complex lookaheads causing ReDoS — nested quantifiers in lookaheads cause catastrophic backtracking.
Code Examples
// Multiple passes — inefficient:
$hasDigit = preg_match('/\d/', $password);
$hasUpper = preg_match('/[A-Z]/', $password);
$hasMinLength = strlen($password) >= 8;
if ($hasDigit && $hasUpper && $hasMinLength) { /* valid */ }
// Lookaheads — single pass, all conditions:
$valid = preg_match(
'/^(?=.*\d)(?=.*[A-Z])(?=.*[^a-zA-Z\d]).{8,}$/',
$password
);
// (?=.*\d) — lookahead: must contain digit (anywhere)
// (?=.*[A-Z]) — lookahead: must contain uppercase
// (?=.*[^a-zA-Z\d]) — lookahead: must contain special char
// .{8,}$ — at least 8 characters