June 29, 2025

Cache Laravel Blade Templates in Memory with Laravel Octane.

Caching Laravel Blade templates in memory with Laravel Octane on RoadRunner to see how close we can get in performance to serving static files directly from Nginx while still using PHP.

In my previous blog, I did a series on how to serve cached Laravel Blade templates as static HTML files directly from Nginx.

In this blog, I’ll be using a stock Laravel app and run it with Laravel Octane on the RoadRunner application server. The goal is to examine how close we can get, in terms of performance, with this setup compared to serving static HTML files directly from Nginx.

Laravel Octane

Laravel Octane differs from your standard PHP-FPM setup in that it boots your application once and keeps it in memory. This is in contrast to PHP-FPM where the application and environment are bootstrapped on every request and destroyed at the end of every request freeing up the used memory. While this can be positive in the way that you don’t have to worry about memory management, it also uses more resources and increases response times.

PHP has had a bad reputation for not being apt for long-lived processes due to its inefficient refcounting and garbage collection. However, since PHP7, it has seen huge improvements in both memory management and garbage collection. Especially since the work that Dmitry Stogov and Nikita Popov did with garbage collection in PHP7.3 and also by modifying how refcounts are stored, moving them from the zval onto the data type itself, leading to improved memory management and a significant performance boost.

I believe that the improvements in the PHP core together with better specifications and standards in the PHP community in recent years have made PHP applications and open-source libraries in general more stable and sturdy and I don’t necessarily think that one needs to scratch daemonized PHP processes out from one’s list any longer. That said, there are some common culprits that one must avoid when working with processes that are long-lived, some of them listed in the RoadRunner documentation [here].

RoadRunner

RoadRunner is an application server built in Go, especially for PHP and is the default application server for Laravel Octane. Laravel Octane also works with FrankenPHP, Open Swoole and Swoole. RoadRunner has lots of great features and also takes care of the PHP worker processes, keeping them alive between requests.

Each worker has its own environment and doesn’t share memory with other workers. However, since the process stays alive, you can do interesting things like caching values into static PHP arrays that will persist between requests. In the examples that will be demonstrated in this blog, I’ll set up a static array to cache rendered Laravel Blade templates to see how much we can increase the performance of a basic Laravel application by serving cached templates directly from memory.

There are also lots of configuration options available in RoadRunner. If there for example should be a memory leak in your application you can set RoadRunner to gracefully reload itself after a certain amount of requests to clear used memory. It’s also possible to set it to reload itself if its memory usage has reached a certain threshold. In your RoadRunner config file (rr.yaml) you would simply set these values like so:

http:
  pool:
    max_jobs: 1000 # Max requests before reload
    supervisor:
      max_worker_memory: 512 # Max memory before reload

Once configured, the application server will automatically reload the workers gracefully and it will look something like in the example below. The first example is when the amount of jobs limit (max request limit) has been reached and the second is when the worker has hit its max memory limit.

php-octane-1     |   200    GET / ....................................................... 16.00 ms
php-octane-1     |   200    GET / ....................................................... 14.00 ms
php-octane-1     | requests execution limit reached, worker will be restarted
php-octane-1     | worker stopped
php-octane-1     |   200    GET / ....................................................... 20.00 ms
php-octane-1     |   200    GET / ....................................................... 58.00 ms
php-octane-1     |   200    GET / ....................................................... 57.00 ms
php-octane-1     | memory_limit
php-octane-1     | worker stopped
php-octane-1     |   200    GET / ....................................................... 61.00 ms

Setup

Laravel has great documentation on how to install and set up Laravel Octane on their website, but I’ve also uploaded the repository that I’ve used in these examples on GitHub [here]. If you’d like to try Laravel Octane out for a bit without going through all the installation steps, go ahead and clone the GitHub repository above and do a docker compose up --build to get started.

This will boot up an environment with 4 different entry points to different setups.

http://localhost:1111 # Nginx 1.27.3 + PHP 8.4.3
http://localhost:2222 # Nginx 1.27.3 + PHP 8.4.3 (with OPcache)
http://localhost:3333 # Nginx 1.27.3 + PHP 8.4.3 (on RoadRunner)
http://localhost:4444 # (no reverse proxy) + PHP 8.4.3 (on RoadRunner)

I’ll stress test these servers to see how well they perform and in one last example, I’ll enable the template caching middleware mentioned earlier to see how far we can push Laravel Octane on RoadRunner. I’m excited to see how close we can get to the performance we got in my previous blog, [here], where I cached Laravel Blade templates as static HTML files which got served directly from Nginx.

class CacheTemplateMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $cacheHeader   = strtolower(strval(
            app('request')->header('cache-control')
        ));
        $cacheKey      = $request->getRequestUri();
        $templateCache = new TemplateCache;

        // invalidate cache?
        if ($cacheHeader === 'no-cache') {
            Cache::forget($cacheKey); // common Redis cache
            $templateCache->forget($cacheKey); // in-memory template cache
        }

        // valid cache? if so, return early
        $template = $templateCache->get($cacheKey);
        if (! is_null(Cache::get($cacheKey)) && ! is_null($template)) {
            return response($template, 200)
                        ->header('Content-Type', 'text/html');
        }

        // proceed to controller action
        return $next($request);
    }
}

class IndexController extends Controller
{
    public function index(Request $request)
    {
        $path = $request->getRequestUri();
        $time = Carbon::now()->getTimestampMs();
        $view = view('welcome')->render();

        // Store cache generation timestamp in common Redis cache
        Cache::put($path, $time);

        // Store generated template in memory cache
        (new TemplateCache)->store($path, $view);

        return $view;
    }
}

class TemplateCache
{
    protected static $templateCache = [];

    public function all()
    {
        return self::$templateCache;
    }

    public function get($key)
    {
        return self::$templateCache[$key] ?? null;
    }

    public function store($key, $value)
    {
        self::$templateCache[$key] = $value;
    }

    public function forget($key)
    {
        if (! array_key_exists($key, self::$templateCache)) {
            return;
        }
        unset(self::$templateCache[$key]);
    }
}

Results

To see how well these different environments performed, I used the siege command line tool (similar to ab, or, Apache Benchmark) using 10 concurrent clients that all made 100 requests each. This is the same test that I did in the blog where I cached Laravel Blade templates as static HTML files and served them directly from Nginx. Now let’s take a look at the results.

[Nginx 1.27.3 + PHP 8.4.3]                       63.76 secs
[Nginx 1.27.3 + PHP 8.4.3 (with OPcache)]         7.30 secs
[Nginx 1.27.3 + PHP 8.4.3 (on RoadRunner)]        6.85 secs
[(no reverse proxy) + PHP 8.4.3 (on RoadRunner)]  6.57 secs

Results w/ Caching Template Middleware activated.
-------------------------------------------------
[Nginx 1.27.3 + PHP 8.4.3 (on RoadRunner)]        0.94 secs
[(no reverse proxy) + PHP 8.4.3 (on RoadRunner)]  0.82 secs

Final Thoughts

The test I conducted in the previous blog with Laravel Blade templates served directly from Nginx as HTML files had a total time of 1.10 secs.

It’s quite remarkable to see that even when RoadRunner is behind a Nginx reverse proxy it outperforms the Nginx server that is serving static files from the filesystem. I’m surprised to see just how fast it is to serve an application with a template cached in memory.

Simply by enabling OPcache, we went from 60+ seconds down to 7 seconds which is quite remarkable on its own. OPcache stores bytecodes in shared memory that is handled by the PHP-FPM master process, this is available for all child processes which makes it very effective and quick for all the child processes to gain a warm cache.

I think this was a really interesting test and I think Laravel Octane certainly has its use cases, especially when using Laravel as a front-facing website where performance is of great importance. However, Laravel Octane might not be the obvious choice when it comes to backend systems where one would prioritize integrity over performance.

Getting the first request to load up the page as quickly as possible improves the user experience greatly and is of course very important. But, it’s important to remember that it’s only one part of the final application that is being loaded in the client browser. Serving assets such as images, stylesheets and scripts also takes its own time to load and render so optimizing the initial page load is not only the part that one should focus on to increase the overall performance of one’s application.

Adding link headers to your Nginx response, which I showed an example of in [this] blog article, can greatly improve the total load time of your webpage and works with the HTTP/1.1 specification. Also, Early Hints (HTTP status code 103) can be used in the same manner to more efficiently load assets. Nginx got support for it in version 1.29.0 but this feature is designed to work with HTTP/2 and HTTP/3 which makes it a tiny bit more difficult to run and test on your local environment than plain link headers.

That’s it for this time, I enjoyed finally taking some time to try out Laravel Octane and RoadRunner and was surprised by the performance. I didn’t expect it to be faster than my previous tests where I served HTML files from Nginx directly. I hope that this serves as a reference of what performance to expect when you’re in a scenario to decide whether or not to use Laravel Octane in your next project.

If you’d like to try out this app on your machine or if you’d like to check out the code, the whole repository is available [here].

Until next time, have a good one!