June 5, 2025

Serve Cached Laravel Blade Templates directly from Nginx, Part 2.

Using HTML Fragments to dynamically update cached static templates to achieve having a performant site while still being able to display personalized content.

In my previous blog, I discussed how Nginx and Laravel can collaborate to generate static HTML versions of Laravel Blade templates, which can be served directly by Nginx. While the results I got were quite impressive, this technique only works for completely static websites.

I decided that I’d write a follow-up article to explore how dynamic content can be added to these generated static templates. As a fan of htmx, I decided to look into how HTML Fragments can be used to achieve this. This article will continue from where we left off in part 1, so if you haven’t checked it out yet, I’d recommend giving it a quick read [here] before proceeding.

HTML Fragments

The concept of creating dynamic content on a website by injecting HTML snippets generated by programming languages has been around since the early days of web development, both on the server as SSI (Server Side Includes) and on the client side as AJAX requests.

In the beginning of the 2010’s with the release of jQuery, it got easier to request a HTML snippet through an AJAX request and inject it into the HTML DOM with its jQuery.load() functionality.

From there we had 10 years of a strong movement moving rendering logic into the front end, using JavaScript to render content in the client browser. The difference is that data was now being sent as JSON objects instead of text/html snippets that were injected directly into the DOM.

Fast forward 10 years and since the beginning of the 2020s, we see a trend of moving rendering logic back again to the backend and like in the old jQuery days, sending HTML snippets over AJAX requests to update parts of the template when there’s a user interaction, these snippets are called HTML Fragments.

The frameworks unpoly and htmx have been giving a great push towards moving template rendering to the backend by making it easy to implement partial re-rendering of templates with HTML Fragments. Laravel also caught up on this trend and added support for HTML Fragments in Laravel Blade back in 2022 with Laravel 9.

Fragment Rendering

Let’s get right into it and look at how we can use Laravel Blade fragments together with the caching technique that we went through in part 1. We first need to define which sections of our Laravel Blade template that need to be dynamic by adding the @fragment and @endfragment directives to our templates.

We’ll use the same product catalog app that we set up in part 1 and add a greeting at the top of the page to create personalized content for the logged-in user as well as a discounted price.

<div id="greeting">
    @fragment('greeting')
        @include('fragments.greeting')
    @endfragment
</div>
<div>
    @foreach ($products as $product)
        <div>
            <img src="{{ $product->image_url }}" alt="{{ $product->name }}">
            <h2>
                <a href="{{ route('products.show', $product) }}">
                    {{ $product->name }}
                </a>
            </h2>
            <p>{{ Str::limit($product->description, 100) }}</p>
            <p id="product_price_{{ $product->id }}">
                @fragment('product_price_' . $product->id)
                    @include('fragments.product_price', compact('product'))
                @endfragment
            </p>
        </div>
    @endforeach
</div>

To keep the code a bit cleaner, I moved the fragment part of the template out into separate Laravel Blade template files and included them with the @include directive.

Since the AJAX request that goes to the backend to fetch the HTML Fragments carries all the cookies set by Laravel, we can safely access the session of the logged-in user in our HTML Fragments. Here are the contents of the two HTML Fragments extracted to two separate Blade template files.

// resources/views/fragments/greeting.blade.php
<div>
    @guest
        <p>
            Hello, become a member <a href="{{ route('login') }}">here</a>
            and shop at discounted prices.
        </p>
    @else
        @if (Auth::user()->email === 'user@email.com')
            <p>
                Hi {{ Auth::user()->name }}, thanks for always
                choosing {{ config('app.name') }} for your purchases.
            </p>
        @else
            <p>
                Hello {{ Auth::user()->name }}, thank you for being
                a valued {{ config('app.name') }} customer.
            </p>
        @endif
        <a href="{{ route('logout') }}">Logout</a>
        <form id="logout-form" action="{{ route('logout') }}" method="POST">
            @csrf
        </form>
    @endguest
</div>

// resources/views/fragments/product_price.blade.php
@guest
    ${{ number_format($product->price, 2) }}
@else
    // give user@email.com 50% discount
    @if (Auth::user()->email === 'user@email.com')
        <s>${{ number_format($product->price, 2) }}</s>
        ${{ number_format($product->price * 0.5, 2) }}
    // other registered users will get 20% discount
    @else
        <s>${{ number_format($product->price, 2) }}</s>
        ${{ number_format($product->price * 0.8, 2) }}
    @endif
@endguest

Replacing Fragments

Now that we’ve set up our templates using the @fragment directives, we can simply fetch only that part of the Laravel Blade template by using view()->fragment('name_of_fragment') to get one part of the whole template. Let’s take a look at how the controller method was changed to accommodate these new changes.

<?php declare(strict_types=1);

namespace App\Http\Controllers;

use App\Cache\ViewFileCache;
use App\Models\Category;

class CategoryController extends Controller
{
    public function show(Category $category, ?bool $returnFragments = false)
    {
        $products   = $category->products()->paginate(10);
        $productIds = array_map(fn ($product) => 'product_price_' . $product->id, $products->items());
        $fragments  = array_merge(['greeting'], $productIds);
        $viewName   = 'categories.show';
        $viewData   = compact('category', 'products', 'fragments');

        return $returnFragments
            ? $this->getFragments($viewName, $viewData, $fragments)
            : new ViewFileCache()->cacheView($viewName, $viewData);
    }
}

// Route::get('/categories/{category}/{returnFragments?}', [CategoryController::class, 'show'])
//             ->middleware([DeleteCategoryCache::class])
//             ->name('categories.show');

The way this works is that when we access this endpoint through the path /categories/1, we’ll get a cached version of the full template if it’s not cached already and if the user is not logged in. If we access the endpoint through the /categories/1/fragments path, we’ll only get the parts of the template that we need to swap out. The getFragments() method that builds the HTML Fragment response simply loops over the $fragments array and creates an array with the fragment name as key and HTML Fragment as value.

protected function getFragments(string $viewName, array $viewData = [], array $fragmentNames = []): View|string|array
{
    $view = view($viewName, $viewData);
    if (count($fragmentNames) === 0) {
        return $view;
    }

    $fragments = [];
    foreach ($fragmentNames as $fragmentName) {
        $fragments[$fragmentName] = $view->fragment($fragmentName);
    }
    return $fragments;
}

This generates a JSON response that looks like the example below. Instead of sending one AJAX request per HTML Fragment which is the standard, I decided to lump them together into one response to make it more efficient to fetch all the dynamic data needed for a given template.

{
    "greeting": "<div class=\"bg-white p-6 rounded-lg mb-3 text-center font-bold\"><p>Hello, become a member <a href=\"http://localhost/login\" class=\"text-blue-500 hover:underline\">here</a> and shop at discounted prices.</p></div>",
    "product_price_31": "$800.32",
    "product_price_32": "$145.18",
    "product_price_33": "$650.07",
    "product_price_34": "$194.54",
    "product_price_35": "$874.51",
    "product_price_36": "$297.21",
    "product_price_37": "$714.08",
    "product_price_38": "$19.31",
    "product_price_39": "$54.41",
    "product_price_40": "$108.94"
}

Now that we have the data we need to replace in the front end, all we need to do is write some vanilla JavaScript to replace it in the DOM. If the user is not logged in, we do not need to fetch the HTML Fragments and we’ll return early. Otherwise, as soon as the DOM is loaded, we’ll replace the fragments with a loading icon while waiting for the AJAX request to complete and once the AJAX request has completed, we’ll loop over the response body replacing fragments like in the example below.

document.addEventListener('DOMContentLoaded', function () {
    const isLoggedIn    = document.cookie.indexOf('is_logged_in=') > -1;
    const fragmentsPath = window.location.pathname.replace(/\/+$/, '')
                            + '/fragments';

    // don't fetch fragments if user is not logged in
    if (isLoggedIn === false) {
        return false;
    }

    // show loading icon in all fragments
    document.querySelectorAll('.fragment-show-loading-icon')
        .forEach(element => {
        element.innerHTML = loadingIconSvg;
    });

    // replace loading icon with real contents
    fetch(fragmentsPath, {
            method: 'GET',
            credentials: 'include',
            mode: 'cors'
        })
        .then(response => response.json())
        .then(data => {
            Object.keys(data).forEach(id => {
                document.getElementById(id).innerHTML = data[id];
            });
        });
});

The AJAX request that is sent to the backend to fetch the HTML Fragments also touches the Laravel session so we’ll get refreshed session cookies in our browser, thus we do not need to worry about the user getting logged out after the session cookie becomes stale due to inactivity. In other words, we can use our Laravel application and user session as we normally would.

Let’s take a look at two screenshots that show the product catalog app with its default cached template for the logged-out user and the modified cached template for the logged-in user. They both used the same cached version of the template as you can see on the date and time when the template was generated at the top-right of the page.

Template when user is not logged in. Template when user is logged in.

Preloading Fragments

We’ll take it one step further and add two variables to our Nginx config, this is completely optional but it will speed up the time it takes to load the final page.

These two variables are used to set a Link header to our Nginx responses that need to load HTML Fragments. This link header will inform the browser as soon as it gets the HTTP response that it should start fetching and preloading the HTML Fragments needed for that template. This is great because now we do not have to wait for our template and script tags to load inside the HTML page for the AJAX request to begin.

# Set the variable $user_is_logged_in to;
# An empty string if the is_logged_in cookie is not set
# The string "logged_in" if the is_logged_in cookie is set
map $cookie_is_logged_in $user_is_logged_in {
    '' '';
    default 'logged_in';
}

# Set the value for the Link header
# If the user is logged in and browsing /categories/{id} or /products/{id}
# $fragments_preload_header variable will be set with the Link header value
# We'll also make sure to never add the Link header to fragment requests
# The Link header will look like this for a category index page:
# Link: </categories/1/fragments>; rel=preload; as=fetch; crossorigin=use-credentials
map "${user_is_logged_in}:${request_uri}" $fragments_preload_header {
    ~*/fragments '';
    ~*^logged_in:/products '<${request_uri}/fragments>;
        rel=preload;
        as=fetch;
        crossorigin=use-credentials';
    ~*^logged_in:/categories '<${request_uri}/fragments>;
        rel=preload;
        as=fetch;
        crossorigin=use-credentials';
    default '';
}

# Inside the location block of your Nginx config
add_header Link $fragments_preload_header;

This will add a header like the following to all Nginx server responses for requests to the product listing page (/categories/{id}) with ID 1. Link: </categories/1/fragments>; rel=preload; as=fetch; crossorigin=use-credentials When requesting the product details page (/products/{id}) for a product with ID 1, the following header will be added. Link: </products/1/fragments>; rel=preload; as=fetch; crossorigin=use-credentials

Final Results

As we saw in part 1, serving the cached HTML template directly from Nginx is incredibly fast, on my local machine running Docker, it got served at a blazing speed of only 4ms. This in contrast to rendering the same template in PHP with Laravel Blade took 182ms.

They’re still below 200ms so the difference might not seem to matter that much but it’s important to keep in mind that this is on my local machine on a stock Laravel setup running SQLite. When running your app in the cloud there will be external cache servers like Redis and database servers like MySQL or similar which will all dramatically increase the time it takes for PHP to return a response back to the browser. The more complicated the tech stack is, the more the difference there will be between serving the cached version of the template as opposed to serving the template from your PHP backend running Laravel.

After completing the cached template request in 4ms, the DOM was complete after 80ms, in contrast to 323ms without cache, which is four times faster even with the simple stack running on my local machine.

The biggest difference though is how it feels navigating around from page to page, the initial load for the default version of the template is so fast that it feels almost instant. The smaller parts of the template that need to be dynamically changed for logged-in users show a loading indicator until the AJAX request completes with the final HTML Fragments giving a much more pleasant user experience than a white screen with nothing on it.

That’s it for this time, I had a great time experimenting and playing around with this hybrid version of a cached static and partial dynamic website. I hope this was a fun read and that it will give someone out there inspiration to try something similar out on their own.

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

Until next time, have a good one!