Fast Laravel Case Study: Laravel News

Main Thread 6 min read

Over the past few months, I've been working with Eric Barnes to make Laravel News faster. This is the first in a series of case studies I wanted to do for Fast Laravel. Proving these page caching strategies work for more than just "simple" Laravel applications.

Laravel News is a high traffic, content-driven site. New blog posts, links, and ads. There's a lot of segments that change on each page. Many developers might assume you can't cache such a site. But you can. And we did.

We took a systematic approach and started with the most-requested pages: home page, blog page, article pages, and links. These pages account for roughly 70% of the total requested pages. So, theoretically, caching these would mean a 70% reduction in web server traffic.

Before getting started, I ran the Fast Laravel Shift. This automates the configuration of a Laravel application for page caching - disabling stateful Laravel features and setting HTTP cache headers. Basically, everything covered in the first few lessons of the Fast Laravel video course.

Once the Laravel News application was configured for page caching, it was time to review each page to ensure it could be cached. In the end, I made three main refactors.

Converting from Livewire

Laravel News was built with Livewire. Not because it needed Livewire. Just because it was. A textbook example of YAGNI. Every page was a Livewire component. But only one (the newsletter form) actually used Livewire's reactivity.

The problem is Livewire embeds component state into the rendered HTML. Things like the snapshot also rely on the usr session. So if that HTML is cached, Livewire's initialization data can become stale or invalid, resulting in 419 responses or components that fail to properly load.

Fortunately, since nearly all of these Livewire components simply rendered a view, they could be converted to Blade components. In most cases, anonymous Blade components. The refactor was very straightforward.

  1. Move the Livewire component class to app/View/Components (if needed)
  2. Move the underlying Blade template to resources/views/components
  3. Replace any Livewire specific code (extends, $this-> prefix, etc)

While there were only a few pages, some had nested components. So there was a lot to convert. After I converted a few by hand, I let AI do the rest. Once converted, I knew all of the pages were technically cacheable.

Handling dynamic segments

Although these pages were cacheable, some had a few dynamic segments. The notification at the top of the homepage. The sponsor cards. The newsletter signup form. All of these were dynamic in some way.

For example, the sponsor card needs to rotate every hour. But we cache the page for a day. We don't want to bust the cache every hour to swap the card. So the cached page needs to update the sponsor card. To do that, we used good old AJAX.

Since Laravel News already uses Alpine.js (as a Livewire dependency), I opted for Alpine AJAX plugin. Then I added a few HTML attributes on these segments, and created some endpoints to return HTML fragments. When the cached page loads, Alpine AJAX swaps the placeholders with fresh content from the server.

There are all sorts of ways to swap segments of an HTML page. In Fast Laravel I use HTMX. It's really your choice based on your project stack. What's important is knowing this as a strategy to cache even partially dynamic pages.

Managing the cache

Caching is the easy part. The real challenge is cache management. Knowing how long to cache and when to clear the cache. For Laravel News, we knew we wanted to cache the pages for a relatively long time. We started by caching for a day at the edge and a few minutes in the browser.

The browser cache is the fastest. Cached pages are served directly from the user's browser. There is no web call. So you can see sub-millisecond response times. This allows the user to navigate a site very quickly. For example, reading an article, then returning to the home or blog page to read another.

While the browser cache is fresh, it will not make a web request. So you don't want to cache on the browser for long. Again, we chose a few minutes. A timeframe to align with typical usage. After that time, the browser would send a request to Cloudflare which could return its cached version. If the page had not changed, you got a quick 304. If the page changed, it was served from Cloudflare's edge. Both of which happen under 100ms.

Cloudflare's cache is what we could control. We could purge from their dashboard or their API. We added API calls to do so within model events. For example, when an Article model was updated, its cached page was purged. Using events covered most of the cases where we needed to purge the cache.

The gotcha for Laravel News was scheduled content. Articles are written beforehand and scheduled to be published. So there's no model event that fires when a scheduled article goes live. The published_at timestamp is already set - the article just becomes visible when it passes now().

Again, there are a lot of ways to handle this. But the simplest approach was to piggyback on an existing scheduled command that shares new articles and links to social media. Since that command already runs when content is published, we purged the cache there too. Worst case, stale content was served for five minutes until the command ran.

Competing with Laravel Cloud

One additional gotcha was Laravel Cloud. Laravel Cloud now has an edge caching feature, which uses Cloudflare underneath. However, this created two competing caches. We'd clear the cache in Cloudflare, but Laravel Cloud would serve its cached version. In the end, we bypassed Laravel Cloud's caching since we were already managing Cloudflare's cache directly.

The results

After implementing these changes, Laravel News went from around 15% cached to consistently around 60% cached. Sometimes reaching 70%. I expect these numbers are actually much higher. However, Laravel News is a high traffic site. So it receives a lot of requests, diluting the percent cached. And, with cache busting in place, we could likely cache longer.

The main value is the speed. The highest traffic pages load from the edge in around 20ms. Then they're served from the browser in around 1ms. Eric said repeatedly, "The site just feels faster!"

But I expect real value too. The underlying infrastructure now receives less than half of the traffic. That means you could downgrade your compute and save money. For example, laravelshift.com, which is 98% cached, runs on a $5/month DigitalOcean server.

Caching pages isn't new. Caching was built into HTTP from the beginning. I remember learning about caching 20 years ago. As we proved with Laravel News, caching applies to more than just a simple site. If you want to learn more about caching, check out the Fast Laravel course. Once you learn these strategies, you can apply them to every site you build.

Find this interesting? Let's continue the conversation on Twitter.