Ramblings of a Tampa engineer
The autonomous subway in Suwon South Korea opened and it has a front glass window.  This is a long exposure going through the tunnel.
Photo by Mathew Schwartz / Unsplash

Recently when Halo Infinite came out, a hobby site of mine grew from roughly a 1mb database to 5.5gb in half a year, additionally moving from nearly 10 records to 20 million.

The hardware has not changed for this project and continues to be on the same $40 Linode instance (affiliate link for $100 credit) and has shown a few growing pains. I wanted to revisit all the optimization techniques Laravel, PHP and MariaDB offers in order to see if we can squeeze more performance out of this site.


PHP - Composer - Optimized Autoloader

composer install --optimize-autoloader --no-dev
  • This will install production dependencies with an optimized autoloader.

In development mode composer has no issues crawling the filesystem to resolve PSR0/4 namespaces which incurs a performance cost. Once in production its expected that new classes are not being added to system. So the optimization can produce generated class maps rules that even get inserted into opcache. This means class loading is insanely fast.

Since I use Deployer I double-checked my deployment process is correctly doing this.


Laravel - Artisan Optimize

When you deploy Laravel you want to pre-generate files that incur a cost to generate. This ranges from configuration files to view files. Once done, these files have been combined and located in one area so the file scanning IO no longer needs to occur.

php artisan optimize
# Configuration cache cleared successfully.
# Configuration cached successfully.
# Route cache cleared successfully.
# Routes cached successfully.
# Files cached successfully.

If you leverage Blade files you may also add another command in order to build all Blade files. This moves the generation from on-demand to ahead of time which makes the end user experience much quicker.

php artisan view:cache
# Compiled views cleared successfully.
# Blade templates cached successfully.

Laravel - Production Assets

I've sadly seen a few folks just deploying their Laravel projects with precompiled development assets. If you leverage Laravel Mix you want to toggle into production mode.

yarn run prod
# ┌──────────────────────────────────────────┬───────────┐
# │                                     File │ Size      │
# ├──────────────────────────────────────────┼───────────┤
# │                               /js/app.js │ 113 KiB   │
# │                   /js/app.js.LICENSE.txt │ 561 bytes │
# │                              css/app.css │ 108 KiB   │
# │…400.ttf?b823fc0dbb5a5f0c21bbcc2a268f92aa │ 178 KiB   │
# │…0.woff2?ebb7a127d2d8ee6f183274b7557718ab │ 103 KiB   │
# │…900.ttf?e615bbcb258550973c165dfc0d871c96 │ 379 KiB   │
# │…0.woff2?59edf72a325ac2048d6077f64773674f │ 151 KiB   │
# └──────────────────────────────────────────┴───────────┘

You can see app.js was built to 113 killobytes when development mode is nearly 700 killobytes. I additionally leverage PurgeCSS in order to purge unused selectors. This takes a bit of configuration because of the dynamic nature of some of my selectors.

Its worth it in development to occasionally run your local install with a set of production built assets. As you might notice things act differently in "production" mode.


PHP - Opcache

Before we jump into this one, PHP7.4 support for preloading opcache looks extreme powerful. Laravel does not support it natively and I'd like to stay away from plugins that work quite close to the core.

First you need to determine if you have the native (PHP 5.5+) cache module installed.

root@zanzibar [~]# php -m | grep 'cache'
Zend OPcache
Zend OPcache

If you have it enabled, then just double check a few properties.

  • opcache.enable - On, so its actually enabled.
  • opcache.enable_cli - On, so its enabled for CLI.
  • opcache.memory_consumption - 128, I prefer 128mb for how much memory to allocate to this. Keep this to powers of 2.
  • opcache.interned_strings_buffer - 8, I prefer 8mb, a small size knowning my application design of strings. Also keep this to powers of 2.
  • opcache.max_accelerated_files - 4000, This is an odd property that works off primes, so while I put 4000 the real value will become 7963.
  • opcache.validate_timestamps - Off, This disables automatic detection of changed files to refresh cache. So it must be done manually.
  • opcache.save_comments - On, because oddily some libraries depend on the comments, which may be annotations, etc)

The import value to note here is validate_timestamps which can seriously affect your application getting updated code unless your deployment process can handle either cleaning the cache or reloading the php process.

My deployment tool handles this via a php-fpm recipe. So anytime a deploy goes out - fpm is reloaded and changed files are loaded.


Laravel = N+1 Problem

So when building Laravel applications, I enjoy using the debugbar extension to make it easier to digest performance issues.

If we take a look at this homepage, we have an obvious problem here. We are grabbing 5 players, then pulling a relation one by one off each of those players. Without Laravel, the optimization here would be to properly join the tables and select the needed data.

Here, I can just ->with(['serviceRecord']) and I'm good to go. Now that page results in a singular query finding all the related data for that set of players.

While the queries were not expensive - this is a cheap way to optimize a system. Additionally, we can slip a line into our App Service Provider in order to detect these kind of mistakes from occuring in the future.

// app/Providers/AppServiceProvider.php

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

MariaDB - Query Cache

Your database is always under constant requests on a popular website, so tuning and optimizing a database is not something we can cover in a small snippet in a blog. A query cache though is an incredibly powerful feature that can cache SELECT queries and return a cached value for the exact query.

MariaDB takes the complex effort of determing when to refresh the cache, so for the most part - you can simply enable this and reap the benefits.

While your mileage may very depending on setup. You can enable query cache on MariaDB with just a few configuration tweaks.

  • have_query_cache - Yes - To enable it.
  • query_cache_size - 8mb - For an 8mb cache pool.
  • query_cache_type - 1 - For a mode that caches all possible queries.

Now you can query the database for some status and see how its handling.

MariaDB [(none)]> SHOW STATUS LIKE 'Qcache%';
+-------------------------+---------+
| Variable_name           | Value   |
+-------------------------+---------+
| Qcache_free_blocks      | 385     |
| Qcache_free_memory      | 1662336 |
| Qcache_hits             | 420933  |
| Qcache_inserts          | 280391  |
| Qcache_lowmem_prunes    | 147248  |
| Qcache_not_cached       | 15511   |
| Qcache_queries_in_cache | 641     |
| Qcache_total_blocks     | 2288    |
+-------------------------+---------+

So we are looking at 420,933 hits and 15,511 misses. It looks like a solid positive return for a simple change. Obviously if your application deals with money, finance or something instead of Halo stats - you might want to learn more about the downsides of caching.


So with just a few hours of work you can improve the performance of any site. We haven't even discussed many things like:

  • HTTP/2.
  • EXPLAIN queries and tuning them.
  • PHP-FPM tuning.
  • Image optimization.
  • Cookie-less asset domain.

We've scratched the surface enough though for the basics.

You’ve successfully subscribed to Connor Tumbleson
Welcome back! You’ve successfully signed in.
Great! You’ve successfully signed up.
Success! Your email is updated.
Your link has expired
Success! Check your email for magic link to sign-in.