Joseph Edmonds

Joseph's Blog

PHP Exceptions: Writing for clarity and testability

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:

https://github.com/LongTermSupport/microdeps-curl/blob/master/tests/CurlHandleFactoryTest.php#L70-L77

    /**
     * @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:

https://github.com/LongTermSupport/microdeps-curl/blob/86bb3e41323b7d6521d9f6b23dd5d32b70ee8a76/src/CurlException.php#L27-L30

    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:

https://github.com/LongTermSupport/microdeps-curl/blob/86bb3e41323b7d6521d9f6b23dd5d32b70ee8a76/src/CurlException.php#L32-L35

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:

https://github.com/LongTermSupport/microdeps-curl/blob/86bb3e41323b7d6521d9f6b23dd5d32b70ee8a76/src/CurlConfigAwareHandle.php#L46-L59

     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.

https://github.com/LongTermSupport/microdeps-curl/blob/86bb3e41323b7d6521d9f6b23dd5d32b70ee8a76/src/CurlException.php#L21-L25

    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.

Simple Nginx Trick to Reduce Server Load

It is quite common to have some Nginx config along the lines of

location / {
    try_files $uri $uri/ /index.php$is_args$args;
}

What this does is tries the exact URL, tries the URL as a folder and if not, passes the request to PHP to be processed by website system, eg Magento/Symfony etc.

What that can mean is that 404 requests for static assests are causing PHP to fire up and process the request and return a 404. If that happens a lot, for whatever reason, it can represent a significant server load.

Instead you can do this

# make image/asset 404s get an nginx 404 rather than be handled by PHP
location ~* .(png|jpeg|ico|gif|css|js|webp|avif) {
    try_files $uri =404;
}

# now handle everything else with a PHP fallback
location / {
    try_files $uri $uri/ /index.php$is_args$args;
}

What this will do is for any request with an extension in the list png|jpeg|ico|gif|css|js|webp|avif then nginx will either server the file, or will return a normal Nginx 404. This will happen very quickly and PHP will not be bothered with the request at all.

Then any requests which are not for static assets will be handled with the normal block.

This is the kind of tiny optimsation that, along with many other sensible choices and optimisations, can add up to significantly better performance and lower hosting costs.

Playing with the Github Client

I’m currently trying to migrate a load of Github issues into the new Github projects system

Unfortunately, they do not make this easy via the web interface. There is though the CLI client which seems eminently scriptable, so this is my findings as I explore this approach.

First you need to install the client and authenticate with the command gh auth login

First thing you might notice is that the gh client output is full of fancy colours. This is nice from a UI point of view, but makes scripting more difficult. We can get around it easily by piping through cat

gh search issues --state open --repo "LongTermSupport/lxc-bash" | cat

Another thing you might notice is that args are in long form with leading -- but they do not include an = sign. A simple gotcha that caught me out at first.

Pulling out issue IDs for scripting

You can cd into a repo folder and use gh issue list to get a list of issues in that repo. Chances are though, you need to search for specific issues by state, project, etc. The best way to do this is using gh search.

This snippet will pull out the issue IDs on their own:

gh search issues \
        --state open \
        --repo "LongTermSupport/lxc-bash" \
        --limit 1000 \
        | cat \
        | cut -d '      ' -f 2 

This snippet runs the Github search, then it is piped through cat to strip out colors and fancy formatting. Finally we use cut to pull out the numeric ID.

Updating the Project

Our next mission is to use a BASH loop to update the issues that we have found. If you are using the legacy projects in Github, the ones that are tied to a specific repo, then this loop should work for you:

for issueId in $(gh search issues \
        --state open \
        --repo "LongTermSupport/lxc-bash" \
        --limit 1000 \
        | cat \
        | cut -d '      ' -f 2  );
do
        echo "Issue ID: $issueId"
        set -x
        gh issue edit $issueId --add-project "project-name"
        set +x
        echo "EXITING AFTER FIRST ISSUE FOR TESTING"
        exit 1
done
~        

This runs the previous gh client search and pulls out the ID, then it will loop through the IDs and run an edit command to add the project.

Gotcha: You can’t use gh client to work with the new Github projects 🙁

Unfortunately, it seems that the client does not support the new projects system natively, as detailed on this ticket:

https://github.com/cli/cli/issues/4547

The suggestion is instead to use the client to make a GraphQL mutation…

(At this point, I’m really starting to miss Jira with simple bulk issue updates)

Get in touch today

Send me a message concerning your idea, problem, or question – and I’ll personally respond as soon as possible.

Connect with me on LinkedIn.