Symfony & When to catch exceptions

Dave Newson
ELMO Software
Published in
5 min readJul 25, 2022

Over the years, our application accumulated many try/catch blocks, intended to either prevent or handle errors. While each block was added by a well-meaning developer, we came to realise that most of these try/catch blocks provided no value, and in some cases actually introduced more problems. To understand why this is, let’s first look at how the Symfony Exception handler works.

Default Exception Handler

Symfony registers a default exception handler with PHP, so by default any thrown exception that remains uncaught will eventually be passed to the Symfony Exception Handler.

In our application, the Symfony Exception Handler is wired up to to:

  • Log the exception to our logging platform, ELK.
  • When running as a CLI Command, write the exception to CLI stderr
  • When running as a Web Request, create an error view (HTTP 500) and show it to the user.
  • Begin graceful termination of the request.

Try/Catch & Exceptions

try/catch blocks allow you to try an action and catch exceptions thrown within that block.
When you catch an exception, the exception will stop bubbling up the call-stack, and unless you intentionally re-throw the exception, it will not reach the uncaught exception handler (because you caught it!).

When you catch and do not re-throw, you are taking responsibility for the Exception, and none of the default Symfony behaviours apply.

Specific or Broad?

catch in PHP allows you to catch only specific Exceptions, or use class-inheritance to catch a set of exceptions.

Ideally, code that catches Exceptions is only catching specific issues that may occur when running the code — for instance HttpExceptions for an API Client, or ResultNotFound exceptions when using the Doctrine ORM.

On occasion, your code may want to catch more broad exceptions, or you may be tempted to catch all exception using the\Throwable class, for systems that must not fail. These cases should be rare, and implemented with extreme caution.

When to use try/catch?

As a general rule, we try to avoid using try/catch blocks, as the default handler’s ability to log and gracefully exit is the most important behaviour for any error.

Any catch block should be able to justify its existence by providing genuinely helpful extra functionality, which should be more useful than that of the default exception handler.

Anti-patterns

Developers like “solved problems”, and will reuse a solution to a problem wherever possible. This mindset has its own drawbacks however, as a bad solution can propagate through a codebase just as fast as a good one.

In our case, failing to educate around the framework error handler early-on led to a number of custom implementations, and these anti-patterns would often bloat code, or hide errors from both users and developers, leading to some head-scratching bugs.

Below are some examples of our miss-steps, and suggestions for better alternatives.

1- Re-throwing the same exception

This code just catches and re-throws the same exception. This provides no value, so we should just drop the try/catch block entirely.

try {
// .. risky code
} catch (\Exception $ex) {
throw $ex;
}

Replace with:

// .. risky code

This pattern often shows up during refactoring, or because of a desire to add a breakpoint for debugging purposes.

2- Logging the message and terminating

This code attempts to log the Exception in response to an error, and exit:

try {
// .. risky code
} catch (MyCustomException $ex) {
$this->logger->error('An error occurred: ' . $ex->message());
exit 1;
}

Unfortunately this code only captures the exception’s message and omits lots of important details like the class, line, or stack-trace. This code also abruptly ends execution by calling exit, which prevents any of Symfony’s shutdown events being called.

The default exception handler provides this functionality better, so we would suggest once again omitting the try/catch:

// .. risky code

The exit statement is a little more difficult to deal with, but can cause bugs of its own. We would typically fix that with a refactor to allow better flow-control and graceful exit.

3- User-facing errors format

In this pattern, we have a Controller that’s acting as a JSON API, and the try/catch has been implemented to send a JsonResponse object.

try {
// .. risky code
} catch (\Exception $ex) {
return new JsonResponse(["error" => true], 500);
}

Firstly, as this code is not logging the exception, we won’t receive any information about the exception in our logging platform, so there’s no way for developers to look into any failure within the try block.

Secondly, rather than implement try/catch blocks in all API controllers, you can simply configure Symfony’s router to give JSON responses by default. This is done by specifying _format:json in the route:

my_route:
controller: "MyController::index"
defaults: 4
_format: json

Now when errors occur (4xx and 5xx) the responses are given in JSON instead of HTML.

We can then omit the try/catch block once again:

// .. risky code

Good Use Cases

Try/catch blocks aren’t exactly outlawed, but you should have a good reason for using them.

1- Re-throwing an exception with additional context

In this example, we’re implementing an HTTP API Client wrapper, and want to attach a little more business information to any HTTP exceptions that occur.

try {
$this->client->requestLeave($user->id, $request->date);
} catch (HttpException $previous) {
throw new LeaveDomainException(
"Failed to send leave request {$request->id} for {$user->id}",
0,
$previous
);
}

We are adding value by providing some useful context around the failure, which will be super handy for determining business impacts in the logger. We also retain the nitty-gritty details of the $previous exception by attaching it to our new domain exception.

2- Gracefully continuing after an exception

If you have a long-running process that processes lots of atomic items, like a batch-assigner, a try/catch is a tempting way to ensure that one failure doesn’t block other atomic actions in the same batch process.


foreach ($assignments as $assignment) {
try
{
$this->doAssign($assignment);
} catch (\Exception $previous) {
$this->logger->critical(
"Failed to assign {$assignment->id}",
['exception' => $previous],
);
}
}

Essentially we’re saying “if any of these one things fail, log it, but continue with the rest”.

While this can be a useful pattern in terms of error-handling, large batch jobs themselves are something of an anti-pattern in itself. If an error occurs in a dependency (like the database) then ALL jobs will fail with the same error.

This approach is better implemented as granular atomic jobs in a queue-based system (think SQS and workers), which enjoys other benefits like dead-letter queues and retries, which the above code lacks.

What’s Next?

try/catch blocks are a powerful tool for managing errors, and their ability to augment context is one of their best use-cases.

However they should always be used with care, as they can be easily misused to provide a worse experience than the standard error handler. Always keep an eye out for these anti-pattern cases or regressive behaviours in your day-to-day development, and remove or refactor these problems away whenever they’re encountered.

--

--