Personally I have chosen this style as it makes my life easy and I find it clear and easy to work with.
Take a look at this PHPUnit test:
/**
* @test
* @covers \MicroDeps\Curl\CurlException
*/
public function itExceptsOnInvalidLogFolder(): void
{
$this->expectException(CurlException::class);
$this->expectExceptionMessage(
sprintf(
CurlException::MSG_DIRECTORY_NOT_CREATED,
'/invalid/path',
'mkdir(): Permission denied'
)
);
The test is going to do something that is expected to throw an exception of a specific type and the exception message should also be an explicit string.
PHPUnit has excellent support for testing exceptions as documented here:
https://phpunit.readthedocs.io/en/9.5/writing-tests-for-phpunit.html#testing-exceptions
Keeping it DRY
Now, hopefully you are already familiar with the concept of DRY when coding. DRY stands for “Don’t Repeat Yourself” and the principle is quite simple – you should not be copying and pasting stuff around. Instead there should be a single source for things that you can then include in your code base in multiple places are required.
In this code, we are using a constant to define the exception message and that means that we can easily change the actual wording of the exception message, without breaking this test, nor having to do any bulk find/replace type shenanigans.
The exception message is stored as a public constant on the exception class that it is related to:
https://github.com/LongTermSupport/microdeps-curl/blob/master/src/CurlException.php#L14
public const MSG_DIRECTORY_NOT_CREATED = 'Directory "%s" was not created, error: %s';
By storing the exception message as a constant, we make writing tests a lot easier and can be very precise about the expected exception message.
Message Format
The exception message is a special kind of string that implements a “format” which can then be used with the sprintf
built in function. The format has placeholders for values that are then passed in when calling the function.
When we want to throw the exception, we need to also use the same constant and pass in the required parameters. For example:
https://github.com/LongTermSupport/microdeps-curl/blob/master/src/CurlHandleFactory.php#L76-L83
if (!is_dir($logFileDir) && !@mkdir($logFileDir, 0755, true) && !is_dir($logFileDir)) {
$error = error_get_last();
throw CurlException::withFormat(
CurlException::MSG_DIRECTORY_NOT_CREATED,
$logFileDir,
$error['message'] ?? 'unknown error'
);
}
Static Exception Create Method
You’ll notice that the throw statement does not use the common style of throw new CurlException
but instead is calling a static method on the CurlException class which acts as a factory method to create an instance of the exception with a specific message.
Here is that method:
public static function withFormat(string $format, string ...$values): self
{
return new self(sprintf($format, ...$values));
}
Previous Exceptions
Where the Exception is being thrown after catching a previous Exception, it’s important that we log that correctly. For that reason there is a separate withFormatAndPrevious method:
public static function withFormatAndPrevious(string $format, Throwable $previous, string ...$values): self
{
return new self(sprintf($format, ...$values), $previous);
}
This is used when catching another Exception but wanting to ensure that our code emits a standard Exception type so that it can be handled easily, for example:
try {
curl_setopt_array($this->handle, $this->options->get());
} catch (ValueError $valueError) {
$valid = array_flip(get_defined_constants(true)['curl']);
$invalid = array_diff_key($this->options->get(), $valid);
throw CurlException::withFormatAndPrevious(
CurlException::MSG_INVALID_OPTIONS,
$valueError,
print_r($invalid, true),
/* @phpstan-ignore-next-line confused by curl_version return type */
curl_version()['version']
);
}
Private Constructor
To enforce that we always throw our exceptions with a format, I’ve actually made the constructor for the Exception class private.
private function __construct(string $message, Throwable $previous = null)
{
$message = $this->appendInfo($message);
parent::__construct($message, 0, $previous);
}
Conclusion
This is just my style. I’m not saying its the only way to do things, but I do think it’s nice and worth considering.
This approach to structuring your Exception classes and predefining the exception message as constants within the Exception class is, I believe, a nice tidy way of managing your exception messages and also making them easy to test.
This approach allows you to have “general” Exception classes that can be used in multiple error scenarios without feeling the need to create a specific Exception class for each and every error scenario.
Most importantly, it allows you to easily and accurately test your exception throwing code to confirm the exact message.