How to speed up your Laravel test suite by using the GitHub Actions cache cover image

How to speed up your Laravel test suite by using the GitHub Actions cache

Jon Milsom • Saturday 14th October 2023

php laravel github-actions

When you're working in software development, especially if you are part of a team, it's good practice to implement Continuous Integration "CI"; continually running your test suite after each git push or each time a PR is opened or updated, but as your test suite grows it can start to slow down the test stage of your pipeline, meaning you have to wait before merging to main and deploying confidently.

Speed is key and whilst you'll eventually need to parallelise the execution of your test suites across multiple nodes, you should optimise each step in the linear process as well.

Normal steps in a Laravel test suite run via GitHub actions

Step 1 - Setup the environment and pull in the code base

Step 2 - Install dependencies via composer

Step 3 - Execute the tests using phpunit or pest

Optimising the composer install stage (step 2 from the list above)

Once your project is setup and any composer packages installed, you'll only need to change your composer config again if you want to add a new package to support new functionality or update to a newer version of an existing package if there is, for example, a security patch available.

By default your composer install command will run each time your test suite does, but it's likely to be doing the same work over and over - re-installing the same set of packages and versions as defined in your composer.json and composer.lock files.

We can remove this slow, repetitive work by caching the output of the composer install command, and using the cached version for future test runs.

How to cache your composer install output using GitHub Actions

Conveniently, there is a built-in GitHub Action to help save to and restore from the GitHub cache.

Each repository can have up to 10GB of cache data and will automatically tidy up after itself by (a) deleting cached data that is not accessed after more than a week and (b) evict older cached items when the cache becomes full:

"A repository can have up to 10GB of caches. Once the 10GB limit is reached, older caches will be evicted based on when the cache was last accessed. Caches that are not accessed within the last week will also be evicted."

Source: GitHub

What additional steps do I need to add to my GitHub action?

Very simply, you need to check the cache for an existing entry before you run composer install. If an entry exists you can restore the files from the cache and skip over the slow composer install command.

You also need to save to the cache after running composer install if there was no pre-existing entry in the cache - this will make sure the next time your GitHub actions runs, it won't repeat the process.

You don't need to delete from the cache at any point - GitHub will do this for you.

What cache key should I use?

The cache key is used when saving to and restoring from the cache, it acts as a pointer to the correct chunk of data. It's important that the cache key changes when the content of the cache changes - just like pre-compiled CSS and JS files in frontend development we can use a hash to represent the content of the files neatly, and provide a fixed number of characters in our cache key.

An example of a good cache key might be a hash of both the composer.lock and composer.json files, with a prefix that makes sense to other members of your team.

key: composer-v1-${{ hashFiles('composer.json', 'composer.lock') }}

It's also good to represent the context that the cache was built in; for example the operating system. This will make sure any slight differences in the environment (for example, availability of PHP extensions) are included in the cache key:

key: composer-v1-${{ runner.os }}-${{ hashFiles('composer.json', 'composer.lock') }}

Finally, if you are using a matrix strategy to test against multiple versions of PHP, you should also include the PHP version in the cache key.

key: composer-v1-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('composer.json', 'composer.lock') }}

Putting it all together

Here's an example of a GitHub actions .yaml file before and after implementing caching:

Before

  - name: Composer install
    run: composer install

After

  - name: Try restore the vendor directory from cache
    uses: actions/cache/restore@v3
    id: composer-github-cache
    with:
      path: vendor
      key: composer-v1-${{ runner.os }}-${{ hashFiles('composer.json', 'composer.lock') }}

  - name: Composer install
    if: steps.composer-github-cache.outputs.cache-hit != 'true'
    run: composer install

  - name: Save the vendor directory to the cache
    if: steps.composer-github-cache.outputs.cache-hit != 'true'
    uses: actions/cache/save@v3
    with:
      path: vendor
      key: composer-v1-${{ runner.os }}-${{ hashFiles('composer.json', 'composer.lock') }}

A few extra lines of yaml, a huge speed improvement! 🚀

Did you find this interesting?

Follow @jonmilsom