Hi all,
The discussion period for PREG_THROW_ON_ERROR has passed with no open
issues, so I'm opening the vote.
Recap: the RFC adds an opt-in PREG_THROW_ON_ERROR flag.
Pass it to any preg_* matching function and any PCRE error the call
records is additionally thrown as a \PregException, so you can catch it
instead of checking the return value.
The exception's code and message match preg_last_error() and
preg_last_error_msg() exactly, and a call without the flag behaves
exactly as it does today.
RFC: https://wiki.php.net/rfc/preg_throw_on_error
PR: https://github.com/php/php-src/pull/22797
Voting is open now and closes on 2026-09-18 17:00:00 UTC.
Regards,
Osama
Hi
The discussion period for
PREG_THROW_ON_ERRORhas passed with no open
issues, so I'm opening the vote.Recap: the RFC adds an opt-in
PREG_THROW_ON_ERRORflag.
Pass it to anypreg_*matching function and any PCRE error the call
records is additionally thrown as a\PregException, so you can catch it
instead of checking the return value.
The exception's code and message matchpreg_last_error()and
preg_last_error_msg()exactly, and a call without the flag behaves
exactly as it does today.RFC: https://wiki.php.net/rfc/preg_throw_on_error
PR: https://github.com/php/php-src/pull/22797Voting is open now and closes on 2026-09-18 17:00:00 UTC.
I regretfully were not able to work through the list backlog after my
summer vacation and thus also missed the intent to vote. I have just
read through the RFC and voted against it, despite being in agreement of
the general concept.
Specifically:
-
I disagree with keeping the Warning on compilation errors. This
feature is entirely new and opt-in, thus there are no backwards
compatibility expectations or considerations. The$e->getMessage() ===preg_last_error_msg()`` guarantee makes the feature much worse than it
could be for compilation errors. Including all necessary information in
the Exception is a must for me. -
I disagree with the behavior of not wrapping Exceptions thrown in
user callbacks: I believe the correct choice is to throw a
\PregException with the Exception thrown in the callback as the
->previousexception. Not wrapping the user callback exception means
that one needs acatch(Exception)with a try just around the preg_
call to reliably handle all errors during regular expression execution,
which nullifies much of the benefit of having a dedicated exception
class in the first place.
It also violates the exception policy in
https://github.com/php/policies/blob/main/coding-standards-and-naming.rst#throwables,
which states:
If an extension uses external functionality that may throw an exception it MUST wrap any exception thrown by that functionality into an appropriate exception of its own. It MUST set the $previous property to the original exception when doing so.
Best regards
Tim Düsterhus
Hi Tim,
I regretfully were not able to work through the list backlog after my
summer vacation and thus also missed the intent to vote. I have just
read through the RFC and voted against it, despite being in agreement of
the general concept.
No worries at all. I'd already read the quiet on the thread as people being
busy rather than as sign-off, so I'm glad it came when it did.
I'd rather get this right than get it fast.
Specifically:
- I disagree with keeping the Warning on compilation errors. This
feature is entirely new and opt-in, thus there are no backwards
compatibility expectations or considerations. The$e->getMessage() ===preg_last_error_msg()`` guarantee makes the feature much worse than it
could be for compilation errors. Including all necessary information in
the Exception is a must for me.
On your first point, I think I can safely claim that I understand exactly
where you're coming from, because what you're describing was what I
actually chose first.
What moved me off it was a single decision that I had to make later when I
faced the array case^1 about whether this flag should add error semantics
of its own, or only deliver the error the call already recorded via an
exception.
I chose the second, and the reasons for the warning/error messages case
were:
- The detailed compile message isn't in the error state to begin with.
When a pattern fails to compile, the error from
pcre2_get_error_message()only ever goes into theE_WARNINGand is then
thrown away. To put that detail in the exception, we must store it
somewhere first. - It would make the flag the one place in ext/pcre that knows more
about the error thanpreg_last_error_msg()does. - The better fix, which resolves the two issues above, is to repair the
anemic message at its source, inpreg_last_error_msg()itself, store the
real reason in the error state for compile errors, and the exception just
inherits it, with no new flag behavior at all.
But that would widen the scope of the RFC from just a flag that throws
an exception, to also changing the message returned by
preg_last_error_msg().
For those reasons, I chose the $e->getMessage() === preg_last_error_msg()``
guarantee to keep things consistent, and I chose to push changing the
preg_last_error_msg() error message into future work.
To me that's also the better separation of concerns.
Letting the exception report exactly what preg_last_error_msg() reports
buys two things:
- Consistency: one error, one message, whether you read it from the
exception or from the function. - The anemic message gets fixed where it actually originates.
Because if$e->getMessage()returning"Internal error"is a problem,
thenpreg_last_error_msg()returning"Internal error"is the same
problem, and it's worth fixing there rather than papering over it on the
exception alone.
So while I agree all the necessary information should typically be in the
exception, I don't want to get there by breaking the $e->getMessage() === preg_last_error_msg()`` guarantee.
Now what I would suggest instead of breaking that guarantee, is to pull
enriching the anemic preg_last_error_msg() error message forward into
this RFC instead of leaving it for later, store the real reason in the
error state, and the exception inherits it through the very same channel,
with the guarantee intact.
I know this is arguably its own debate, but I am more ok with that than
introducing what I think is an inconsistency.
- I disagree with the behavior of not wrapping Exceptions thrown in
user callbacks: I believe the correct choice is to throw a
\PregException with the Exception thrown in the callback as the
->previousexception. Not wrapping the user callback exception means
that one needs acatch(Exception)with a try just around the preg_
call to reliably handle all errors during regular expression execution,
which nullifies much of the benefit of having a dedicated exception
class in the first place.It also violates the exception policy in
https://github.com/php/policies/blob/main/coding-standards-and-naming.rst#throwables
,
which states:
If an extension uses external functionality that may throw an exception
it MUST wrap any exception thrown by that functionality into an appropriate
exception of its own. It MUST set the $previous property to the original
exception when doing so.
On your second point, if this is a violation of a policy, then there isn't
much to argue. I will just retract the vote and fix that.
But I think I got confused here, and I would appreciate you explaining how
that violates the policy.
To make sure we're on the same ground, this is what I understood from your
statement about wrapping exceptions thrown in user callbacks:
preg_replace_callback(
$pattern,
fn () => throw new CustomException(), // <- You want this wrapped in
PregException?
$subject,
flags: PREG_THROW_ON_ERROR,
);
If I got it right (and I suspect I did), then how does that violate the
policy?
A user callback isn't external functionality, is it? Because as far as I
understand, external functionality is something the extension itself
depends on as part of its own implementation.
I am also unaware of any functions that behave like that (wraps exceptions
thrown in user callbacks in its own exception).
In fact, the opposite is the case for one of the precedents this RFC
follows (json_encode() with JSON_THROW_ON_ERROR - although it doesn't
accept a user callback): https://3v4l.org/CtHYH#v8.5.10
Thanks,
Osama
Hi
Now what I would suggest instead of breaking that guarantee, is to pull
enriching the anemicpreg_last_error_msg()error message forward into
this RFC instead of leaving it for later, store the real reason in the
error state, and the exception inherits it through the very same channel,
with the guarantee intact.
That would also work for me. But the E_WARNING should remain when the
PREG_THROW_ON_ERROR flag is not set, because some users might rely on
the warning being emitted to turn it into an Exception themselves by
means of an error handler.
What is important to me is that the new flag cleanly results in an
Exception and only an Exception for all possible errors, because this is
what users will expect from it.
On your second point, if this is a violation of a policy, then there isn't
much to argue. I will just retract the vote and fix that.But I think I got confused here, and I would appreciate you explaining how
that violates the policy.To make sure we're on the same ground, this is what I understood from your
statement about wrapping exceptions thrown in user callbacks:preg_replace_callback( $pattern, fn () => throw new CustomException(), // <- You want this wrapped in PregException? $subject, flags: PREG_THROW_ON_ERROR, );
Yes. I expect a PregException where $e->getPrevious() instanceof
CustomException().
If I got it right (and I suspect I did), then how does that violate the
policy?
A user callback isn't external functionality, is it? Because as far as I
understand, external functionality is something the extension itself
depends on as part of its own implementation.
Arguably this specific case is a bit debatable, but as the author of the
throwable policy RFC, I believe that it is at least violated in spirit.
The goal of the throwable policy generally, and also with regard to that
specific paragraph is to allow reliably handling groups of errors
without needing to wrap every individual statement into its own
try-catch block. Consider this:
try {
$contents = get_from_api('http://example.com');
// sanitize credit card numbers
$contents = preg_replace_callback(
'/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/',
function ($matches) {
return mask_credit_card($matches[0]);
},
$contents,
falgs: PREG_THROW_ON_ERROR,
);
echo $contents;
} catch (PregException $e) {
echo "Sanitization failed\n";
} catch (HttpException $e) {
echo "Download failed\n";
}
I am catching the PregException to handle failures during the credit
card sanitization step. If mask_credit_card() throws its own exception
that is not wrapped, my catch blocks are insufficient and I would
instead need to write it something like this:
try {
$contents = get_from_api('http://example.com');
} catch (HttpException $e) {
echo "Download failed\n";
return;
}
try {
// sanitize credit card numbers
$contents = preg_replace_callback(
'/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/',
function ($matches) {
return mask_credit_card($matches[0]);
},
$contents,
falgs: PREG_THROW_ON_ERROR,
);
} catch (Exception $e) {
echo "Sanitization failed\n";
return;
}
echo $contents;
To reliably handle just the exceptions that happen during sanitization
and nothing else. This is a lot of extra boilerplate code and noise.
Now if I am still interested in the inner exception for the callback
failure, something like this would work:
} catch (PregException $e) {
if ($e->getCode() === PregException::CALLBACK_FAILURE) {
echo "Sanitization callback failed: ",
$e->getPrevious()->getMessage();
} else {
echo "Sanitization failed\n";
}
}
Because if the error code is callback failure, I know that there is a
previous Exception. So I don't lose any functionality / information.
I am also unaware of any functions that behave like that (wraps exceptions
thrown in user callbacks in its own exception).
There are a few cases where the CSPRNG (which throws RandomException on
failure) is used internally and the exception on CSPRNG failure is
wrapped. However much of the standard library predates the throwable
policy (which was accepted in May 2025;
https://wiki.php.net/rfc/extension_exceptions), that's why it doesn't
follow it.
In fact, the opposite is the case for one of the precedents this RFC
follows (json_encode()withJSON_THROW_ON_ERROR- although it doesn't
accept a user callback): https://3v4l.org/CtHYH#v8.5.10
Yes, that flag and JsonSerializable itself is much older than the policy.
Best regards
Tim Düsterhus
Hi
Now what I would suggest instead of breaking that guarantee, is to pull
enriching the anemicpreg_last_error_msg()error message forward into
this RFC instead of leaving it for later, store the real reason in the
error state, and the exception inherits it through the very same channel,
with the guarantee intact.That would also work for me. But the
E_WARNINGshould remain when the
PREG_THROW_ON_ERROR flag is not set, because some users might rely on
the warning being emitted to turn it into an Exception themselves by
means of an error handler.What is important to me is that the new flag cleanly results in an
Exception and only an Exception for all possible errors, because this is
what users will expect from it.On your second point, if this is a violation of a policy, then there isn't
much to argue. I will just retract the vote and fix that.But I think I got confused here, and I would appreciate you explaining how
that violates the policy.To make sure we're on the same ground, this is what I understood from your
statement about wrapping exceptions thrown in user callbacks:preg_replace_callback( $pattern, fn () => throw new CustomException(), // <- You want this wrapped in PregException? $subject, flags: PREG_THROW_ON_ERROR, );Yes. I expect a PregException where $e->getPrevious() instanceof
CustomException().If I got it right (and I suspect I did), then how does that violate the
policy?
A user callback isn't external functionality, is it? Because as far as I
understand, external functionality is something the extension itself
depends on as part of its own implementation.Arguably this specific case is a bit debatable, but as the author of the
throwable policy RFC, I believe that it is at least violated in spirit.The goal of the throwable policy generally, and also with regard to that
specific paragraph is to allow reliably handling groups of errors
without needing to wrap every individual statement into its own
try-catch block. Consider this:try { $contents = get_from_api('http://example.com'); // sanitize credit card numbers $contents = preg_replace_callback( '/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/', function ($matches) { return mask_credit_card($matches[0]); }, $contents, falgs: PREG_THROW_ON_ERROR, ); echo $contents; } catch (PregException $e) { echo "Sanitization failed\n"; } catch (HttpException $e) { echo "Download failed\n"; }I am catching the PregException to handle failures during the credit
card sanitization step. If mask_credit_card() throws its own exception
that is not wrapped, my catch blocks are insufficient and I would
instead need to write it something like this:try { $contents = get_from_api('http://example.com'); } catch (HttpException $e) { echo "Download failed\n"; return; } try { // sanitize credit card numbers $contents = preg_replace_callback( '/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/', function ($matches) { return mask_credit_card($matches[0]); }, $contents, falgs: PREG_THROW_ON_ERROR, ); } catch (Exception $e) { echo "Sanitization failed\n"; return; } echo $contents;To reliably handle just the exceptions that happen during sanitization
and nothing else. This is a lot of extra boilerplate code and noise.Now if I am still interested in the inner exception for the callback
failure, something like this would work:} catch (PregException $e) { if ($e->getCode() === PregException::CALLBACK_FAILURE) { echo "Sanitization callback failed: ",$e->getPrevious()->getMessage();
} else {
echo "Sanitization failed\n";
}
}Because if the error code is callback failure, I know that there is a
previous Exception. So I don't lose any functionality / information.I am also unaware of any functions that behave like that (wraps exceptions
thrown in user callbacks in its own exception).There are a few cases where the CSPRNG (which throws RandomException on
failure) is used internally and the exception on CSPRNG failure is
wrapped. However much of the standard library predates the throwable
policy (which was accepted in May 2025;
https://wiki.php.net/rfc/extension_exceptions), that's why it doesn't
follow it.In fact, the opposite is the case for one of the precedents this RFC
follows (json_encode()withJSON_THROW_ON_ERROR- although it doesn't
accept a user callback): https://3v4l.org/CtHYH#v8.5.10Yes, that flag and JsonSerializable itself is much older than the policy.
Best regards
Tim Düsterhus
Hi Tim,
Thank you for the elaboration.
The first thing that came to my mind reading your example is that
without wrapping, an extra catch block would be enough:
try {
$contents = get_from_api('http://example.com');
$contents = preg_replace_callback(
'/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/',
function ($matches) {
return mask_credit_card($matches[0]); // throws MaskException
},
$contents,
flags: PREG_THROW_ON_ERROR,
);
echo $contents;
} catch (MaskException $e) {
echo "Sanitization failed\n";
} catch (PregException $e) {
echo "Regex failed\n";
} catch (HttpException $e) {
echo "Download failed\n";
}
The caveat of course is that this only works when you know the
exception a callback can throw...which you don't always control.
But since you've made clear this is a violation of the throwable
policy, I've retracted the vote until we get it resolved.
Following the idea of wrapping the callback's exception, I see two problems...
One is a footgun I'd want explicitly addressed.
The other, I'm afraid, forces an exception hierarchy in place of a
single PregException.
First...wrapping couples the exception you catch to the flag.
Without it, preg_replace_callback() throws whatever the callback throws.
With it, the same call always throws a PregException.
So the flag silently changes which exception a caller has to handle,
and the two have to move together:
try {
preg_replace_callback(
'/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/',
function ($matches) {
return mask_credit_card($matches[0]); // throws MaskException
},
$contents,
);
} catch (MaskException $e) {
// becomes dead the moment the flag is added, and comes back the
moment it's removed
}
Second...wrapping a callback's exception in a PregException produces
a PregException that maps to no preg error.
You can be holding a PregException while preg_last_error() and
preg_last_error_msg() report no error at all. That is an exception
whose type says a regex error happened when, by preg's own state, none
did.
This is separate from the $e->getMessage() === preg_last_error_msg()``
guarantee I raised before. Even setting that aside, it's incoherent on
its own terms, because the flag is PREG_THROW_ON_ERROR and
preg_last_error() is what an error is.
So keeping that honest means a bare PregException can no longer
stand for two different things at once.
So it has to split into a PregPcreException (or a better name)
carrying the real preg error and mirroring preg_last_error()...and
PregCallbackException wrapping the callback's exception. And then to
satisfy the single catch both would need to extend a shared
PregException parent.
That's a whole hierarchy to carry a case an extra catch would have handled.
So the cost of wrapping comes out as a silent footgun plus a
three-class hierarchy, for what one more catch could do.
Both look intrinsic to wrapping, though. The footgun is the flip side
of the single catch you want, and the hierarchy is what keeps that
catch honest, so neither can really be designed away.
That is if I didn't miss anything along the way...
So my question now is: is that price worth paying over the extra
catch...or is the extra catch acceptable after all?
That's where I'd hope your "Arguably this specific case is a bit
debatable" leaves some room.
In any case...I'll be working on how the preg_last_error_msg()
change impacts the RFC, and meanwhile looking for opinions on the
wrapping points I've raised above.
Regards,
Osama
Hi
First...wrapping couples the exception you catch to the flag.
Without it,preg_replace_callback()throws whatever the callback
throws.
With it, the same call always throws aPregException.
So the flag silently changes which exception a caller has to handle,
and the two have to move together:try { preg_replace_callback( '/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/', function ($matches) { return mask_credit_card($matches[0]); // throws MaskException }, $contents, ); } catch (MaskException $e) { // becomes dead the moment the flag is added, and comes back the moment it's removed }
That is correct, but as I mentioned before, the new flag is an entirely
new feature that requires an explicit opt-in. Adopting a new feature
without reading the associated documentation to find out how it works
will generally result in sadness, and I believe this case is no
different. Also adding and removing the flag would not just affect
MaskException, but would of course also affect whether or not a
PregException is thrown and whether or not the code proceeds after an
error was encountered. Any existing error handling would need to adapted
as well. So the changes required to adopt the flag are much more
far-reaching than whether or not a catch block for a custom exception
would need to be adjusted.
Second...wrapping a callback's exception in a
PregExceptionproduces
aPregExceptionthat maps to no preg error.
I think that is fine: Just add a new PREG_CALLBACK_ERROR that is only
emitted when PREG_THROW_ON_ERROR is set.
You can be holding a
PregExceptionwhilepreg_last_error()and
preg_last_error_msg()report no error at all. That is an exception
whose type says a regex error happened when, by preg's own state, none
did.
Ah, good that you mention this, because it's not mentioned in the RFC
and I didn't check the implementation: The preg_last_error() value
should not be touched when the PREG_THROW_ON_ERROR flag is set. Once
you opt into exception-based error handling, the other error handling
path should be bypassed entirely. This is consistent with how
JSON_THROW_ON_ERROR already works: https://3v4l.org/Ijt3R#veol
<?php
echo "Start\n";
var_dump(json_last_error());
echo "\n";
echo "Error without flag\n";
json_decode('{');
var_dump(json_last_error());
echo "\n";
echo "Clear error\n";
json_decode('true'); // clear error
var_dump(json_last_error());
echo "\n";
echo "Error with flag\n";
try { json_decode('{', flags: JSON_THROW_ON_ERROR); } catch
(\JsonException $e) { echo $e->getMessage(), "\n"; }
var_dump(json_last_error());
echo "\n";
echo "Set different error\n";
json_decode(str_repeat('[', 1000));
var_dump(json_last_error());
echo "and check that it is not overwritten when `JSON_THROW_ON_ERROR`
is set\n";
try { json_decode('{', flags: JSON_THROW_ON_ERROR); } catch
(\JsonException $e) { echo $e->getMessage(), "\n"; }
var_dump(json_last_error());
This is separate from the
$e->getMessage() ===preg_last_error_msg()``
guarantee I raised before. Even setting that aside, it's incoherent on
its own terms, because the flag isPREG_THROW_ON_ERRORand
preg_last_error()is what an error is.So keeping that honest means a bare
PregExceptioncan no longer
stand for two different things at once.
So with the above note that preg_last_error() should remain untouched,
I believe having a single PregException for everything is fine (or
PregError + PregException, as pointed out by Robert).
Best regards
Tim Düsterhus
Arguably this specific case is a bit debatable, but as the author of the
throwable policy RFC, I believe that it is at least violated in spirit.
The goal of the throwable policy generally, and also with regard to that
specific paragraph is to allow reliably handling groups of errors
without needing to wrap every individual statement into its own
try-catch block.
Obviously you wrote the policy and so are best placed to interpret it
(and I am not a core developer / person with voting rights); however I
agree with the angle Osama is coming from here - I wouldn't say this
is an error that is (always) part of the same group. There wasn't any
error in the call to preg_replace_callback itself (or any of its
functionality) - the error was in a way during the processing of the
result of the function. Taking your example - what if instead of
CustomException you had InvalidLengthException,
IncorrectFormatException, NotKnownBIN, etc. Instead of being able
to catch say InvalidLengthException & IncorrectFormatException to
return a validation error asking the user to check their input;
NotKnownBIN to return that the user can not use that particular card
with you, and PregException to note a system failure occurred then
you have to catch PregException (or Exception) and then use
switch / match on $previous.
If I have understood the other example correctly, this contradicts
quite significantly with the CSPRNG throwing an Exception that
RandomException contains - as the failure is a core issue within the
function call itself as opposed to logic that occurs in userland.
If anything, I would argue that under the policy this should go the
other way and become PregError:
The Error hierarchy MUST NOT be used for errors that are expected to be thrown (and caught) during normal operation of a PHP program.
In terms of the possible errors that could occur, I would expect at
leastPREG_INTERNAL_ERROR,PREG_BAD_UTF8_ERROR&
PREG_JIT_STACKLIMIT_ERRORto be code errors that require a developer
to need to correct their code (as my understanding of these would be
that the pattern is invalid, or not quoted correctly, etc. Although
PREG_BACKTRACK_LIMIT_ERROR&PREG_RECURSION_LIMIT_ERRORare more
likely to occur based on user input, then the limit for both is
controlled by an ini setting - so again, this likely isn't something I
would say is expected to be thrown and caught during normal operation
of a PHP program. The final error (PREG_BAD_UTF8_OFFSET_ERROR) I
think would still likely need a code change to fix it occurring -
although I have only done a quick Google to see when it may occur.
I do admit that overall - my only real experience with the preg_*
functions erroring is where the pattern itself is not valid in some
way; so there may be more common use-cases that fit the exception path
- but hopefully explaining why my viewpoint is to treat it as an
ErrorhierarchyThrowableas opposed toExceptioncovers why not
wrapping any throws from the userland callbacks makes sense.
Hi
Now what I would suggest instead of breaking that guarantee, is to pull
enriching the anemicpreg_last_error_msg()error message forward into
this RFC instead of leaving it for later, store the real reason in the
error state, and the exception inherits it through the very same channel,
with the guarantee intact.That would also work for me. But the
E_WARNINGshould remain when the
PREG_THROW_ON_ERROR flag is not set, because some users might rely on
the warning being emitted to turn it into an Exception themselves by
means of an error handler.What is important to me is that the new flag cleanly results in an
Exception and only an Exception for all possible errors, because this is
what users will expect from it.On your second point, if this is a violation of a policy, then there isn't
much to argue. I will just retract the vote and fix that.But I think I got confused here, and I would appreciate you explaining how
that violates the policy.To make sure we're on the same ground, this is what I understood from your
statement about wrapping exceptions thrown in user callbacks:preg_replace_callback( $pattern, fn () => throw new CustomException(), // <- You want this wrapped in PregException? $subject, flags: PREG_THROW_ON_ERROR, );Yes. I expect a PregException where $e->getPrevious() instanceof
CustomException().If I got it right (and I suspect I did), then how does that violate the
policy?
A user callback isn't external functionality, is it? Because as far as I
understand, external functionality is something the extension itself
depends on as part of its own implementation.Arguably this specific case is a bit debatable, but as the author of the
throwable policy RFC, I believe that it is at least violated in spirit.The goal of the throwable policy generally, and also with regard to that
specific paragraph is to allow reliably handling groups of errors
without needing to wrap every individual statement into its own
try-catch block. Consider this:try { $contents = get_from_api('http://example.com'); // sanitize credit card numbers $contents = preg_replace_callback( '/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/', function ($matches) { return mask_credit_card($matches[0]); }, $contents, falgs: PREG_THROW_ON_ERROR, ); echo $contents; } catch (PregException $e) { echo "Sanitization failed\n"; } catch (HttpException $e) { echo "Download failed\n"; }I am catching the PregException to handle failures during the credit
card sanitization step. If mask_credit_card() throws its own exception
that is not wrapped, my catch blocks are insufficient and I would
instead need to write it something like this:try { $contents = get_from_api('http://example.com'); } catch (HttpException $e) { echo "Download failed\n"; return; } try { // sanitize credit card numbers $contents = preg_replace_callback( '/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}/', function ($matches) { return mask_credit_card($matches[0]); }, $contents, falgs: PREG_THROW_ON_ERROR, ); } catch (Exception $e) { echo "Sanitization failed\n"; return; } echo $contents;To reliably handle just the exceptions that happen during sanitization
and nothing else. This is a lot of extra boilerplate code and noise.Now if I am still interested in the inner exception for the callback
failure, something like this would work:} catch (PregException $e) { if ($e->getCode() === PregException::CALLBACK_FAILURE) { echo "Sanitization callback failed: ",$e->getPrevious()->getMessage();
} else {
echo "Sanitization failed\n";
}
}Because if the error code is callback failure, I know that there is a
previous Exception. So I don't lose any functionality / information.I am also unaware of any functions that behave like that (wraps exceptions
thrown in user callbacks in its own exception).There are a few cases where the CSPRNG (which throws RandomException on
failure) is used internally and the exception on CSPRNG failure is
wrapped. However much of the standard library predates the throwable
policy (which was accepted in May 2025;
https://wiki.php.net/rfc/extension_exceptions), that's why it doesn't
follow it.In fact, the opposite is the case for one of the precedents this RFC
follows (json_encode()withJSON_THROW_ON_ERROR- although it doesn't
accept a user callback): https://3v4l.org/CtHYH#v8.5.10Yes, that flag and JsonSerializable itself is much older than the policy.
Best regards
Tim Düsterhus
Hi
Arguably this specific case is a bit debatable, but as the author of
the
throwable policy RFC, I believe that it is at least violated in
spirit.
The goal of the throwable policy generally, and also with regard to
that
specific paragraph is to allow reliably handling groups of errors
without needing to wrap every individual statement into its own
try-catch block.Obviously you wrote the policy and so are best placed to interpret it
(and I am not a core developer / person with voting rights); however I
agree with the angle Osama is coming from here - I wouldn't say this
is an error that is (always) part of the same group. There wasn't any
error in the call topreg_replace_callbackitself (or any of its
functionality) - the error was in a way during the processing of the
Yes, I agree that this case is not entirely clear-cut - and it's good
we're having this discussion now.
If I have understood the other example correctly, this contradicts
quite significantly with the CSPRNG throwing an Exception that
RandomExceptioncontains - as the failure is a core issue within the
function call itself as opposed to logic that occurs in userland.
I think there might be a misunderstanding based on how you phrased that
paragraph. To provide a more specific example:
Consider I have a session implementation that uses Redis as its session
storage backend. Session IDs need to be created using secure randomness,
i.e. using the CSPRNG. Both the Redis backend and the CSPRNG can
theoretically fail. As a user when create a new session I want to be
able to just catch (SessionInitializedFailedException) and not care
about whether the CSPRNG or the Redis connection failed, and I might not
even know if it's Redis, Memcache, a File System or a MySQL database.
Thus any underlying issues must be wrapped into a session-specific
exception.
preg_replace_callback() is different in that I explicitly pass in a
callback and thus I'm technically in full control over the code that is
being executed and I can theoretically know what exceptions could
possibly be thrown and might intentionally want to handle them
explicitly. On the other hand, failing to execute the callback means
that the replacing operation failed, no further callbacks will be called
and preg_replace_callback() will not return anything - and that is a
“running this regex failed” a.k.a. PregException situation to me.
If anything, I would argue that under the policy this should go the
other way and becomePregError:The Error hierarchy MUST NOT be used for errors that are expected to
be thrown (and caught) during normal operation of a PHP program.
In terms of the possible errors that could occur, I would expect at
leastPREG_INTERNAL_ERROR,PREG_BAD_UTF8_ERROR&
PREG_JIT_STACKLIMIT_ERRORto be code errors that require a developer
to need to correct their code (as my understanding of these would be
that the pattern is invalid, or not quoted correctly, etc. Although
PREG_BACKTRACK_LIMIT_ERROR&PREG_RECURSION_LIMIT_ERRORare more
likely to occur based on user input, then the limit for both is
controlled by an ini setting - so again, this likely isn't something I
would say is expected to be thrown and caught during normal operation
of a PHP program. The final error (PREG_BAD_UTF8_OFFSET_ERROR) I
think would still likely need a code change to fix it occurring -
although I have only done a quick Google to see when it may occur.
This is a good point. I agree that things like pattern compilation
failures should be a PregError, since this is a clear programmer error
and regular expressions are not supposed to be untrusted inputs. For the
error error situations I would need to check as well if they are
expected during regular operation or not. The backtrack or recursion
limits I can see being caught intentionally to provide better error
messages to a user (thus PregException).
Best regards
Tim Düsterhus
I think there might be a misunderstanding based on how you phrased that
paragraph. To provide a more specific example:
Sorry - I think I might have just worded my message poorly, as your
more detailed example is how I understood the RandomException to
look like. I think the core is that in both cases; but specifically
with the session example, the action is what has failed (and
regardless of cause then it is desired to have a single catch to
handle that).
preg_replace_callback()is different in that I explicitly pass in a
callback and thus I'm technically in full control over the code that is
being executed and I can theoretically know what exceptions could
possibly be thrown and might intentionally want to handle them
explicitly
Looking at this from a different angle - the first part (in that the
developer is in full control of the code) might be the most important
part here. If the developer wants any failure (a non-zero
preg_last_error() result or an error that occurs during the
callback) to have a single catch block then the developer is able to
throw a PregException themselves (or a child of PregException).
However if PregException wraps any Throwables in the userland
callbacks, a developer who wants to have multiple catch blocks (or
throw some exceptions that propagate to a higher scope in their
callback) can only do that by catching and rethrowing.
Hi
Arguably this specific case is a bit debatable, but as the author of
the
throwable policy RFC, I believe that it is at least violated in
spirit.
The goal of the throwable policy generally, and also with regard to
that
specific paragraph is to allow reliably handling groups of errors
without needing to wrap every individual statement into its own
try-catch block.Obviously you wrote the policy and so are best placed to interpret it
(and I am not a core developer / person with voting rights); however I
agree with the angle Osama is coming from here - I wouldn't say this
is an error that is (always) part of the same group. There wasn't any
error in the call topreg_replace_callbackitself (or any of its
functionality) - the error was in a way during the processing of theYes, I agree that this case is not entirely clear-cut - and it's good
we're having this discussion now.If I have understood the other example correctly, this contradicts
quite significantly with the CSPRNG throwing an Exception that
RandomExceptioncontains - as the failure is a core issue within the
function call itself as opposed to logic that occurs in userland.I think there might be a misunderstanding based on how you phrased that
paragraph. To provide a more specific example:Consider I have a session implementation that uses Redis as its session
storage backend. Session IDs need to be created using secure randomness,
i.e. using the CSPRNG. Both the Redis backend and the CSPRNG can
theoretically fail. As a user when create a new session I want to be
able to just catch (SessionInitializedFailedException) and not care
about whether the CSPRNG or the Redis connection failed, and I might not
even know if it's Redis, Memcache, a File System or a MySQL database.
Thus any underlying issues must be wrapped into a session-specific
exception.
preg_replace_callback()is different in that I explicitly pass in a
callback and thus I'm technically in full control over the code that is
being executed and I can theoretically know what exceptions could
possibly be thrown and might intentionally want to handle them
explicitly. On the other hand, failing to execute the callback means
that the replacing operation failed, no further callbacks will be called
andpreg_replace_callback()will not return anything - and that is a
“running this regex failed” a.k.a. PregException situation to me.If anything, I would argue that under the policy this should go the
other way and becomePregError:The Error hierarchy MUST NOT be used for errors that are expected to
be thrown (and caught) during normal operation of a PHP program.
In terms of the possible errors that could occur, I would expect at
leastPREG_INTERNAL_ERROR,PREG_BAD_UTF8_ERROR&
PREG_JIT_STACKLIMIT_ERRORto be code errors that require a developer
to need to correct their code (as my understanding of these would be
that the pattern is invalid, or not quoted correctly, etc. Although
PREG_BACKTRACK_LIMIT_ERROR&PREG_RECURSION_LIMIT_ERRORare more
likely to occur based on user input, then the limit for both is
controlled by an ini setting - so again, this likely isn't something I
would say is expected to be thrown and caught during normal operation
of a PHP program. The final error (PREG_BAD_UTF8_OFFSET_ERROR) I
think would still likely need a code change to fix it occurring -
although I have only done a quick Google to see when it may occur.This is a good point. I agree that things like pattern compilation
failures should be a PregError, since this is a clear programmer error
and regular expressions are not supposed to be untrusted inputs. For the
error error situations I would need to check as well if they are
expected during regular operation or not. The backtrack or recursion
limits I can see being caught intentionally to provide better error
messages to a user (thus PregException).Best regards
Tim Düsterhus
Hi
Arguably this specific case is a bit debatable, but as the author of
the
throwable policy RFC, I believe that it is at least violated in
spirit.
The goal of the throwable policy generally, and also with regard to
that
specific paragraph is to allow reliably handling groups of errors
without needing to wrap every individual statement into its own
try-catch block.Obviously you wrote the policy and so are best placed to interpret it
(and I am not a core developer / person with voting rights); however I
agree with the angle Osama is coming from here - I wouldn't say this
is an error that is (always) part of the same group. There wasn't any
error in the call topreg_replace_callbackitself (or any of its
functionality) - the error was in a way during the processing of theYes, I agree that this case is not entirely clear-cut - and it's good
we're having this discussion now.If I have understood the other example correctly, this contradicts
quite significantly with the CSPRNG throwing an Exception that
RandomExceptioncontains - as the failure is a core issue within the
function call itself as opposed to logic that occurs in userland.I think there might be a misunderstanding based on how you phrased that
paragraph. To provide a more specific example:Consider I have a session implementation that uses Redis as its session
storage backend. Session IDs need to be created using secure randomness,
i.e. using the CSPRNG. Both the Redis backend and the CSPRNG can
theoretically fail. As a user when create a new session I want to be
able to just catch (SessionInitializedFailedException) and not care
about whether the CSPRNG or the Redis connection failed, and I might not
even know if it's Redis, Memcache, a File System or a MySQL database.
Thus any underlying issues must be wrapped into a session-specific
exception.
preg_replace_callback()is different in that I explicitly pass in a
callback and thus I'm technically in full control over the code that is
being executed and I can theoretically know what exceptions could
possibly be thrown and might intentionally want to handle them
explicitly. On the other hand, failing to execute the callback means
that the replacing operation failed, no further callbacks will be called
andpreg_replace_callback()will not return anything - and that is a
“running this regex failed” a.k.a. PregException situation to me.If anything, I would argue that under the policy this should go the
other way and becomePregError:The Error hierarchy MUST NOT be used for errors that are expected to
be thrown (and caught) during normal operation of a PHP program.
In terms of the possible errors that could occur, I would expect at
leastPREG_INTERNAL_ERROR,PREG_BAD_UTF8_ERROR&
PREG_JIT_STACKLIMIT_ERRORto be code errors that require a developer
to need to correct their code (as my understanding of these would be
that the pattern is invalid, or not quoted correctly, etc. Although
PREG_BACKTRACK_LIMIT_ERROR&PREG_RECURSION_LIMIT_ERRORare more
likely to occur based on user input, then the limit for both is
controlled by an ini setting - so again, this likely isn't something I
would say is expected to be thrown and caught during normal operation
of a PHP program. The final error (PREG_BAD_UTF8_OFFSET_ERROR) I
think would still likely need a code change to fix it occurring -
although I have only done a quick Google to see when it may occur.This is a good point. I agree that things like pattern compilation
failures should be a PregError, since this is a clear programmer error
and regular expressions are not supposed to be untrusted inputs. For the
error error situations I would need to check as well if they are
expected during regular operation or not. The backtrack or recursion
limits I can see being caught intentionally to provide better error
messages to a user (thus PregException).Best regards
Tim Düsterhus
Hi Tim,
A couple of updates after digging into this further...
- I found a case that makes the
$e->getMessage() ===preg_last_error_msg()`` guarantee I leaned on not really hold in general.
APregExceptionfreezes its message and code when it is thrown, but
preg_last_error_msg()reads the per-request global.
And so the two agree only while nothing runs between the throw and the read.
A callback that makes its own flagged call breaks that...when the inner
call throws, the outer operation bails and overwrites the global with
INTERNAL_ERROR(why it lands on that code is point 2) before you read it.
For example:
try {
preg_replace_callback(
'/\w/',
function ($m) {
preg_match('//u', "\xff", $inner, PREG_THROW_ON_ERROR); //
the inner call throws
return 'Y';
},
'a',
flags: PREG_THROW_ON_ERROR,
);
} catch (\PregException $e) {
var_dump($e->getMessage()); // "Malformed UTF-8 characters, ..."
frozen in the exception
var_dump(preg_last_error_msg()); // "Internal error" the global,
already overwritten
var_dump($e->getCode()); // 4 PREG_BAD_UTF8_ERROR
var_dump(preg_last_error()); // 1 `PREG_INTERNAL_ERROR`
}
So the equality is a property of a single flagged call, not an
invariant...and the exception is a faithful snapshot of its own call, while
preg_last_error_msg() is a global the next call moves.
I should have stated it that narrowly.
- I was also wrong about the callback case.
When a user callback throws, PHP does setpreg_last_error()and
preg_last_error_msg().
They come back asPREG_INTERNAL_ERRORand "Internal error", not "No
error", so my earlier claim that a callback throw leaves the error state
untouched was incorrect.
I dug in the C code to see how that works, and how that value gets set
changes what it means.
When the callback throws, the replacement bails out (
https://github.com/php/php-src/blob/82a15338e298142f52854b64d803695d4e5252df/ext/pcre/php_pcre.c#L1960),
and the error: path calls pcre_handle_exec_error(count) (
https://github.com/php/php-src/blob/82a15338e298142f52854b64d803695d4e5252df/ext/pcre/php_pcre.c#L2032-L2033
).
At that point count is the successful match count, not a PCRE error code,
and pcre_handle_exec_error() has no case for a non-negative value, so it
falls through to its default and returns PHP_PCRE_INTERNAL_ERROR (
https://github.com/php/php-src/blob/82a15338e298142f52854b64d803695d4e5252df/ext/pcre/php_pcre.c#L132
).
The engine matched fine, but the "Internal error" is a fallback the
bail-out path leaves behind, not a diagnosis of anything PCRE did wrong.
That means there is no real message for this case...
A genuine PCRE error carries a specific reason ("Malformed UTF-8 ...",
"Backtrack limit exhausted", and so on...)...
A callback throw only ever yields the generic "Internal error", because
nothing in PCRE actually failed.
The real information is the exception the callback threw, which carries its
own message and type.
So I would say this case is unlike the PCRE errors in a concrete way...that
is for them the error state describes the failure, but here it does not.
To me that leans (though it does not settle) toward letting the callback's
exception propagate rather than wrapping it.
Wrapping would turn a specific userland exception into a PregException
whose own message can only be "Internal error".
Whether that lean is enough, or whether not wrapping here is still a
violation of the throwable policy, I think is your call as its author. I am
only laying out what the code does.
With that said, I think the things we have so far that make the case for
letting the callback's exception propagate are:
- A callback throwing is not a PCRE error. The engine matched, and
userland threw. The"Internal error"it records is the fallback above,
not a diagnosis. - There is no real message to deliver. The genuine message and type are
in the callback's own exception. APregExceptionhere would carry only
"Internal error". - Wrapping destroys catch-by-type. Domain exceptions from the callback
all collapse intoPregException, so callers can no longer catch them by
type and have to inspect->getPrevious(). - It is the more flexible default. A caller who wants a single catch can
throw aPregExceptionfrom their own callback. A caller who wants their
own types back under wrapping cannot get them without catch-and-rethrow.
Now whether any of these is a knockout on its own is probably
arguable...But I think together they justify letting the callback's
exception propagate.
But of course it turns on how the throwable policy should apply, so I need
your judgement here.
Thanks,
Osama
- I disagree with the behavior of not wrapping Exceptions thrown in
user callbacks: I believe the correct choice is to throw a
\PregException with the Exception thrown in the callback as the
->previousexception. Not wrapping the user callback exception means
that one needs acatch(Exception)with a try just around the preg_
call to reliably handle all errors during regular expression execution,
which nullifies much of the benefit of having a dedicated exception
class in the first place.It also violates the exception policy in
https://github.com/php/policies/blob/main/coding-standards-and-naming.rst#throwables,
which states:If an extension uses external functionality that may throw an exception it MUST wrap any exception thrown by that functionality into an appropriate exception of its own. It MUST set the $previous property to the original exception when doing so.
I've read this last week and this interpretation of the exception policy has taunted me since then. Tim, I think you are mistaken in your point of view on this. I'm not aware of any point where exceptions thrown in user callback methods are wrapped, but at least autoloading, a very prominent one, does not. https://3v4l.org/vYmts Changing this (in general for all user callbacks) would be a very inconvenient BC break for many projects.
I think this policy should be read differently. I think as a language user you should not have to be aware of the implementation of a functions internals. if some function is using something that could throw an exception this implementation detail should be hidden from the user. Also because if the function is re-implemented another way this exception could change. From the perspective of the language user, a callable provided to a function is not part of the functions internals.
Specifically for this case, wrapping the users' exceptions in this RFC to me is very contra-intuïtive because it is inconsistent with other callbacks. Do you have an example where this wrapping currently takes place?
Greetings, Casper
Hi
It also violates the exception policy in
https://github.com/php/policies/blob/main/coding-standards-and-naming.rst#throwables,
which states:If an extension uses external functionality that may throw an exception it MUST wrap any exception thrown by that functionality into an appropriate exception of its own. It MUST set the $previous property to the original exception when doing so.
I've read this last week and this interpretation of the exception
policy has taunted me since then. Tim, I think you are mistaken in your
point of view on this. I'm not aware of any point where exceptions
thrown in user callback methods are wrapped, but at least autoloading,
a very prominent one, does not. https://3v4l.org/vYmts Changing this
(in general for all user callbacks) would be a very inconvenient BC
break for many projects.
Yes, much of the existing standard library predates the policy and more
generally also predates any kind of API design regarding exceptions.
The first part of the standard library where the Exception hierarchy got
any real design is the new random API in PHP 8.2 - and that served as
the blueprint for the new URI extension and the exception policy.
Regarding your specific example of autoloading, I'd argue that the
correct behavior for autoloading is to wrap all exceptions. From a user
perspective, autoloading is a blackbox service and it thus should behave
like any other service (e.g. like the session example I have given in a
previous email in this thread). In particular autoloading shows up
“implicitly” in many cases and having a clear indicator that autoloading
is what failed is useful. As an example, when using class_exists(), I
don't want arbitrary exceptions to show up: https://3v4l.org/m0DIP#veol
I think this policy should be read differently. I think as a language
user you should not have to be aware of the implementation of a
functions internals. if some function is using something that could
throw an exception this implementation detail should be hidden from the
user. Also because if the function is re-implemented another way this
exception could change. From the perspective of the language user, a
callable provided to a function is not part of the functions internals.
I would agree for functions like array_map() or array_filter() where
the sole purpose is executing the user callback and which cannot fail
for other reasons. But for preg_replace_callback() and similar, the
high level operation is “perform a replacement” and the provided
callback is just an implementation detail. I would find it unexpected
that preg_replace() with a broken replacement string (e.g.
hypothetically referencing a group that doesn't exist, this case
currently doesn't seem to emit an error) would throw PregException,
but preg_replace_callback() with a broken replacement callback would
throw arbitrary errors.
Best regards
Tim Düsterhusp
Hi all,
The discussion period for
PREG_THROW_ON_ERRORhas passed with no open issues, so I'm opening the vote.Recap: the RFC adds an opt-in
PREG_THROW_ON_ERRORflag.
Pass it to anypreg_*matching function and any PCRE error the call records is additionally thrown as a\PregException, so you can catch it instead of checking the return value.
The exception's code and message matchpreg_last_error()andpreg_last_error_msg()exactly, and a call without the flag behaves exactly as it does today.RFC: https://wiki.php.net/rfc/preg_throw_on_error
PR: https://github.com/php/php-src/pull/22797Voting is open now and closes on 2026-09-18 17:00:00 UTC.
Regards,
Osama
Hi everyone,
I'm cancelling the vote on PREG_THROW_ON_ERROR.
The flag as it stands violates the throwable policy, as Tim's point shows.
That's not something to fix with the vote open, so I'm pulling it back
rather than changing the proposal out from under people who already
voted.
I'll keep the discussion here, and once things settle I'll start a fresh vote.
The RFC is back under discussion.
Thanks to Tim for catching this before it went further.
Regards,
Osama