nginx split config: cached HTML for marketing, PHP-FPM for the rest
Serving static cached HTML for your marketing pages while Laravel handles auth routes cuts PHP-FPM load dramatically. Here's the exact nginx config I use.
I've been running this split for about three years now and I'm still surprised more Laravel shops don't do it. Your marketing pages — homepage, pricing, about, blog — don't need PHP-FPM. They need to be fast. Your authenticated app routes do need PHP, but they're a fraction of your total traffic. Splitting the two at the nginx level is simple, and it will cut your PHP-FPM worker pressure significantly.
The actual problem
Most Laravel apps I inherit are configured with a single location block that proxies everything through PHP-FPM. Every request — including /, /pricing, /blog/some-post that hasn't changed in a week — spins up a PHP process, bootstraps the framework, hits the database for nav items or feature flags, and renders a Blade template. For a busy marketing site, that's hundreds of PHP-FPM requests per minute that could be zero.
I had a client last year — a Seattle-area biotech with a public-facing product site sitting in front of their authenticated LIMS portal. The marketing side was getting hammered by crawlers and organic traffic. PHP-FPM was running 20+ workers at idle just to serve pages that hadn't changed in days. The actual app — where scientists logged in to pull assay data — was starved for workers during traffic spikes.
The fix took about 45 minutes to deploy and their FPM worker count at idle dropped to 4.
How the split works
The idea is straightforward:
- Laravel (or a separate artisan command, or a deploy hook) pre-renders marketing pages and writes them as plain
.htmlfiles to a cache directory. - nginx checks for a cached file first. If it exists and isn't stale, serve it directly from disk. No PHP.
- If there's no cached file — authenticated routes, POST requests, anything with a session cookie — fall through to PHP-FPM as normal.
This is not full-page caching middleware inside Laravel. This is nginx not calling PHP at all for those pages. That distinction matters. Laravel's own response cache packages are fine, but they still bootstrap the framework to check the cache. This doesn't.
The nginx config
Here's the actual server block pattern I use. I'll explain the pieces after.
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/example/public;
index index.php;
# Where artisan (or a deploy script) writes pre-rendered HTML
set $page_cache_path /var/www/example/page-cache;
# --- Static asset handling (unchanged) ---
location ~* \.(?:css|js|woff2?|ttf|svg|png|jpg|jpeg|gif|ico|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
# --- The split: try cache first, fall through to PHP ---
location / {
# Build the cache file path from the URI
# e.g. /pricing -> /var/www/example/page-cache/pricing.html
# e.g. / -> /var/www/example/page-cache/index.html
set $cache_file "";
# Only attempt cache for GET requests with no session cookie
# and no query string (query strings often mean personalized responses)
if ($request_method = GET) {
set $cache_file $page_cache_path$uri;
# Strip trailing slash for directory-style URIs
if ($uri ~ "/$") {
set $cache_file "${page_cache_path}${uri}index.html";
}
if ($uri !~ "/$") {
set $cache_file "${page_cache_path}${uri}.html";
}
}
# If authenticated (has session cookie), skip cache entirely
if ($cookie_example_session) {
set $cache_file "";
}
# If there's a query string, skip cache
if ($query_string) {
set $cache_file "";
}
# Try the cache file, then the normal Laravel entry point
try_files $cache_file /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 60;
}
}
The key line is try_files $cache_file /index.php?$query_string. If $cache_file is a non-empty string pointing to a file that exists on disk, nginx serves it. If the variable is empty (authenticated user, POST, query string) or the file doesn't exist, it falls through to PHP-FPM exactly as it would in any standard Laravel nginx config.
Writing the cache files from Laravel
I use a simple artisan command that renders specific routes and writes the output:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Http\Request;
use Illuminate\Routing\Router;
class WarmPageCache extends Command
{
protected $signature = 'cache:pages';
protected $description = 'Pre-render marketing pages to static HTML';
// Routes (by name) that should be statically cached
protected array $routes = [
'home',
'pricing',
'about',
'contact',
'blog.index',
];
public function handle(): int
{
$cacheDir = base_path('../page-cache');
File::ensureDirectoryExists($cacheDir);
foreach ($this->routes as $routeName) {
$uri = route($routeName, absolute: false);
// Boot a fake request through the kernel
$request = Request::create($uri, 'GET');
$response = app()->handle($request);
if ($response->getStatusCode() !== 200) {
$this->warn("Skipping {$uri} — got {$response->getStatusCode()}");
continue;
}
$path = $uri === '/'
? $cacheDir . '/index.html'
: $cacheDir . $uri . '.html';
File::ensureDirectoryExists(dirname($path));
File::put($path, $response->getContent());
$this->info("Cached: {$uri} -> {$path}");
}
return Command::SUCCESS;
}
}
I call this from my deploy script after assets are compiled: php artisan cache:pages. If a blog post changes, I run it again. If content changes are frequent I'll hook it to a model observer on the relevant Eloquent models — publish a post, regenerate the cache file.
Gotchas that will bite you
The session cookie name. In the nginx config above I'm checking $cookie_example_session. Your Laravel app's session cookie name is set in config/session.php under cookie. It defaults to laravel_session but most production apps rename it. Get this wrong and you'll serve cached pages to authenticated users. Check your config and match it exactly.
CSRF tokens in cached HTML. If your marketing pages have inline forms — contact forms, newsletter signups — that use Laravel's @csrf blade directive, the cached HTML will contain a stale token. The session it was generated against doesn't exist for the next visitor. Move those forms to a separate route that isn't cached, or use a JS fetch to grab a fresh token on load. I usually just make contact forms POST to a separate subdomain endpoint and avoid the whole issue.
The if directive in nginx. nginx docs famously warn that if is evil in location blocks. The specific uses here — setting a variable, not proxying or rewriting — are generally safe, but know what you're doing before you start nesting them. If you need more complex logic, a Lua module or map block is cleaner.
Cache invalidation on deploy. Don't forget to either delete the cache directory on deploy (and regenerate it) or blow away stale files explicitly. I wipe it at the start of the deploy and regenerate at the end. Thirty seconds of PHP-FPM serving the real pages while the cache rebuilds is fine.
Nested URI paths. If you have URLs like /blog/2024/some-post, you need the cache directory structure to match. The artisan command above uses File::ensureDirectoryExists(dirname($path)) for this reason. nginx will look for /page-cache/blog/2024/some-post.html and it needs to exist.
When I'd reach for this
I use this pattern on any Laravel app where there's a meaningful marketing or public-facing side that gets real traffic. If you have 20+ routes that are publicly cacheable and you're serving more than a few hundred requests per minute, this is almost always worth it.
I also use it on managed hosting clients where I control the server. If you're on Laravel Forge with default configs, you can still add this — just drop the custom nginx config into Forge's site configuration and add the artisan command to your deploy script.
I wouldn't use this when:
- Every page is authenticated. Then there's nothing to cache and this adds complexity for no gain.
- You're on a PaaS (Heroku, Render, Railway) where you don't control nginx config. Use Laravel's response caching middleware instead — it's not as efficient but it works within the constraints.
- Your "marketing" pages are actually highly dynamic — A/B tests per user, geolocation-based content, heavy personalization. The cache serves one version to everyone.
- You have a CDN in front that already handles this. If Cloudflare is caching your marketing pages at the edge, you've already solved this problem at a higher layer.
One more thing
Set a Cache-Control header on the files nginx serves from the cache directory. Right now they'll get nginx's default headers. Add this to the location / block or a separate location matching .html:
location ~* \.html$ {
add_header Cache-Control "public, max-age=3600";
expires 1h;
}
That tells downstream caches and browsers the page is good for an hour, which gives you another layer of relief on repeat visits.
This is one of those things that sounds more complicated than it is. The nginx config is maybe 30 lines. The artisan command is straightforward. The payoff — PHP-FPM spending its workers on requests that actually need PHP — is real and immediate. It's worth the 45 minutes.
Need help shipping something like this? Get in touch.