PHP API Exception relies on the following principles:
- Each exception must be unique.
- Consumers MUST NOT rely on the human-readable error message to identify it.
- Comply with PSR-3. See: https://www.php-fig.org/psr/psr-3/
If you've never used Exceptions to handle and display errors, here's a great quick introductory video by freekmurze. This implementation, however, fails short on the first two principles.
Let's say one of our processes sending the emails dies after marking the campaign as being sent and we want to handle this specific case from elsewhere in the code. There's no way for us to reliably determine why ensureSendable()
threw an exception. By using unique exceptions, we can specifically catch those we need to handle differently and let the rest bubble up. Our calling code would look like this:
try {
$this->ensureSendable($campaign);
} catch (\Acme\Exceptions\CouldNotSendCampaign\BeingSent $exception) {
// Check campaign hasn't been in sending state for too long
// If so, do something about it, like creating an alert on a different system
throw $exception;
}
What happens when we want to add the sent date if the campaign was already sent? Changing the exception message would introduce a breaking change to all consumers that rely on this message to handle this error or modify it (for example, to apply internationalization or convert the date to local time). In order to address this, ApiException introduces two fields: class
and data
.
{
"class": "Acme\\Exceptions\\CouldNotSendCampaign\\AlreadySent",
"message": "The campaign with id 8354 was already sent on 2020-03-03T01:35:31+00:00.",
"statusCode": 400,
"data": {
"campaignId": 8354,
"sentAt": "2020-03-03T01:35:31+00:00"
}
}
Do note the message is still relevant for consumers that don't have to transform it.
/**
* @param array<mixed>|null $data Any JSON serializable data we should
* send to the consumer along with the
* built-in information
* @param \Throwable|null $previous The previous exception thrown to
* maintain the exception chain
*/
ApiException::__construct(?array $data = [], ?\Throwable $previous = null)
<?php
namespace Pnoexz\Examples\Exceptions;
use Pnoexz\ApiException\Http\ClientError\NotFoundException;
class ArticleNotFoundException extends NotFoundException
{
public string $typedMessage = "Article not found";
}
Will output the following JSON:
{
"class": "Pnoexz\\Examples\\Exceptions\\ArticleNotFoundException",
"message": "Article not found",
"statusCode": 404
}
If you need to support an HTTP Status Code that's not on the standard or is missing, it's possible to extend from \Pnoexz\ApiException\Http\ClientError\ClientErrorException
(default level WARNING), \Pnoexz\ApiException\Http\ServerError\ServerErrorException
(default level ERROR) or Pnoexz\ApiException
(no default level).
<?php
namespace Pnoexz\Examples\Exceptions;
use Pnoexz\ApiException;
class ArticleNotFoundException extends ApiException
{
protected int $httpStatus = 701;
protected string $level = \Psr\Log\LogLevel::WARNING;
public string $typedMessage = "Article not found";
}
Exceptions have an array of JSON serializable data if you need to provide context to consumers. This can be either internal, such as providing the context for your logger, or external, so clients can handle errors gracefully.
Suppose we are building an ecommerce system in which users can't have more than one pending cart at the same time. If the user tries to create a new cart, the frontend can use the data field in our response to redirect to their existing cart using its id. This example assumes the returned value of $this->getPendingCartByUser($user)
can be serialized in JSON.
<?php
namespace Pnoexz\Examples\Exceptions;
use Pnoexz\ApiException\Http\ClientError\ConflictException;
class PendingCartExistsException extends ConflictException
{
public string $typedMessage = "A pending cart already exists";
}
$potentiallyPendingCart = $this->getPendingCartByUser($user);
if (!empty($potentiallyPendingCart)) {
throw new PendingCartExistsException(['cart' => $potentiallyPendingCart]);
}
{
"class": "Pnoexz\\Examples\\Exceptions\\PendingCartExistsException",
"message": "A pending cart already exists",
"statusCode": 409,
"data": {
"cart": {
"id": 8,
"items": [{ "id": 8931 }]
}
}
}
In this case, we will be catching an exception that contains sensitive data (a raw SQL query). We need to separate what the client will see and what we should log. This might require some extra code in our handler, but when throwing the exception, all we need to do is the pass what we caught to the second parameter.
class FruitRepository
{
public function get(int $id)
{
try {
$sth = $this->dbh->prepare("
SELECT name, colour, calories
FROM fruit
WHERE id = :id
");
$sth->execute([':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
} catch (\Exception $e) {
throw new DatabaseException([], $e);
}
}
}
We can then catch this exception from the calling method and do similarly; maintaining the exception trace and sending back a nice output to the client.
These examples use the Slim3 error handler but should work similarly with other frameworks.
/**
* @param Request $request
* @param Response $response
* @param \Throwable $throwable
* @return Response
*/
public function __invoke(
Request $request,
Response $response,
ApiException $exception
) {
$encodedBody = \json_encode($exception);
$this->log(
$exception->getLevel(),
$exception->getMessage(),
$encodedBody
);
return $response->withJson($encodedBody, $exception->getStatusCode());
}
/**
* @param Request $request
* @param Response $response
* @param \Throwable $throwable
* @return Response
*/
public function __invoke(
Request $request,
Response $response,
\Throwable $throwable
) {
$statusCode = 500;
$level = LogLevel::ERROR;
$message = $throwable->getMessage();
if ($throwable instanceof ApiException) {
$statusCode = $throwable->getStatusCode();
$level = $throwable->getLevel();
}
$body = [
'statusCode' => $statusCode,
'level' => $level,
'message' => $message,
];
$this->log($level, $message, $body);
return $response->withJson($body, $statusCode);
}