Supercharge your Laravel application with static file caching. Laravel Static converts your dynamic Laravel responses into static HTML files, dramatically improving performance and reducing server load.
Traditional Laravel applications generate HTML on every request, hitting your database and executing PHP code repeatedly. Laravel Static solves this by:
- Converting dynamic responses to static HTML files — Serve pre-generated HTML instead of executing PHP on every request
- Reducing server load — Let your web server (Nginx, Apache) serve static files directly
- Improving response times — Static files are served in milliseconds, not hundreds of milliseconds
- Supporting multiple caching strategies — Choose between route-based caching or automatic web crawling
- Handling complex scenarios — Multi-domain support, query string handling, and HTML minification
- PHP 8.1 or higher
- Laravel 11.0 or higher
Install the package via Composer:
composer require backstage/laravel-staticPublish the configuration file:
php artisan vendor:publish --tag="laravel-static-config"Optionally, publish the migrations if you need database-backed features:
php artisan vendor:publish --tag="laravel-static-migrations"
php artisan migrateAdd the STATIC_ENABLED=true environment variable to your .env file:
STATIC_ENABLED=trueApply the StaticResponse middleware to routes you want to cache:
use Backstage\LaravelStatic\Middleware\StaticResponse;
Route::get('/', function () {
return view('welcome');
})->middleware(StaticResponse::class);
// Or apply to route groups
Route::middleware([StaticResponse::class])->group(function () {
Route::get('/about', [PageController::class, 'about']);
Route::get('/contact', [PageController::class, 'contact']);
Route::get('/blog', [BlogController::class, 'index']);
});Generate your static files:
php artisan static:buildThat's it! Your routes are now served as static HTML files.
The configuration file is located at config/static.php. Here's a breakdown of all available options:
'driver' => 'crawler', // Options: 'crawler' or 'routes'| Driver | Description |
|---|---|
crawler |
Uses Spatie Crawler to automatically discover and cache all internal URLs starting from your homepage. Best for sites with many interconnected pages. |
routes |
Only caches routes that have the StaticResponse middleware explicitly applied. Best for selective caching. |
'enabled' => env('STATIC_ENABLED', true),Toggle static caching on or off. Useful for disabling in development while keeping it enabled in production.
'build' => [
'clear_before_start' => true, // Clear existing cache before rebuilding
'concurrency' => 5, // Number of concurrent HTTP requests
'accept_no_follow' => true, // Follow nofollow links when crawling
'default_scheme' => 'https', // URL scheme for crawler requests
'force_root_url' => env('STATIC_FORCE_ROOT_URL', false), // Force generated links to app.url during builds
'crawl_observer' => \Backstage\LaravelStatic\Crawler\StaticCrawlObserver::class,
'crawl_profile' => \Spatie\Crawler\CrawlProfiles\CrawlInternalUrls::class,
'bypass_header' => [
'name' => 'X-Laravel-Static',
'value' => 'off',
],
],'whitelist' => [
'hosts' => null, // null = cache every host; array = only these hosts
],Restrict which hostnames static caches may be created for. When null (the default), caches are created for every host that hits the middleware. Provide an array of hostnames to only cache those hosts — useful when your app is reachable through multiple domains (e.g. a staging or preview domain) but you only want to cache the canonical ones.
Hostnames are matched against the request host (case-insensitive) and support * wildcards — e.g. *.example.com matches any subdomain (but not the apex example.com, which must be listed separately). To restrict caching to your app's own hostname and its www variant:
'whitelist' => [
'hosts' => [
$host = parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST),
'www.' . $host,
],
],'files' => [
'disk' => env('STATIC_DISK', 'public'), // Laravel filesystem disk
'include_domain' => true, // Create separate caches per domain
'include_query_string' => true, // Include query strings in cache keys
'filepath_max_length' => 4096, // Maximum file path length
'filename_max_length' => 255, // Maximum filename length
],'options' => [
'on_termination' => false, // Save cache after response sent (async)
'minify_html' => false, // Minify HTML before caching
],Write precompressed copies of each static file alongside the original so your web
server can serve them directly, without compressing on every request. gzip is
built into PHP; brotli requires the ext-brotli
extension and is silently skipped when it isn't installed.
'compression' => [
'gzip' => true, // Write a .gz copy (env: STATIC_COMPRESS_GZIP)
'gzip_level' => 9, // 0-9, higher is smaller/slower
'brotli' => false, // Write a .br copy (env: STATIC_COMPRESS_BROTLI)
'brotli_level' => 11, // 0-11
'keep_uncompressed' => true, // Also keep the plain .html copy
],By default each cached page has page.html, page.html.gz, and (optionally)
page.html.br siblings. Compression happens once at cache-write time, so a high
level is usually worth it.
Storing only the compressed file. Set keep_uncompressed to false to skip the
plain .html copy and roughly halve disk usage. The uncompressed file is only
dropped when a compressed one was actually written, so a page is never left with
nothing to serve (e.g. brotli requested but ext-brotli missing). The trade-off is
that your web server must then serve the compressed file to every client,
including the rare one that doesn't send Accept-Encoding: gzip — with nginx that
means gzip_static always; plus gunzip on; to decompress on the fly for those
clients. If you keep the uncompressed copy, nginx falls back to it automatically.
See Web Server Configuration for serving these files.
Generate static files for all configured routes:
php artisan static:buildWhen using the routes driver, only routes with the StaticResponse middleware are cached. When using the crawler driver, the crawler starts from your homepage and discovers all internal links.
Rebuild a single page instead of crawling the whole site — the targeted
counterpart to static:clear --uri:
php artisan static:build https://example.com/blog/my-postThe page is re-rendered through its own route (no clear, no crawl), so only that one cached file is refreshed.
Clear all cached static files:
php artisan static:clearThe command asks for confirmation before clearing. Skip the prompt (e.g. in
CI/deploy scripts) with --force:
php artisan static:clear --forceClear specific URIs:
php artisan static:clear --uri=/about --uri=/contactClear by route names:
php artisan static:clear --routes=home --routes=about --routes=blog.indexClear by domain (useful for multi-tenant applications):
php artisan static:clear --domain=example.com
php artisan static:clear --domain=subdomain.example.comClear only the cached files generated for requests with a query string (e.g.
/products?page=2), keeping the plain query-less pages intact:
php artisan static:clear --only-with-query-stringsReport what the cache actually holds and what is driving its size:
php artisan static:statusNothing is ever removed from the cache except by static:clear, and a file is
written per unique URI including its query string — so a cache that fills up a
server is usually not the pages themselves but their variants. This command shows
where the size sits before you decide what to prune.
Files on disk ........................................................... 24
Size on disk ........................................................ 1.7 MB
Cached pages ............................... 17 (compressed copies excluded)
Distinct URLs ........................................................... 11
WARN 6 of 17 cached pages (35%) are extra query-string variants of 11 distinct URLs.
Followed by a breakdown per host, per file type (where the multiplication from gzip/brotli siblings shows up), storage held by URLs with and without a query string, the largest directories, the URLs with the most cached variants, the most common query parameters, an age distribution, and the largest single files. It closes with the config settings that govern cache growth, so the numbers and the levers are on one screen.
The two tables that usually explain a runaway cache are top URLs by variants
and most common query parameters — if utm_source or fbclid sits at the
top, tracking parameters are multiplying your cache and
static:clear --only-with-query-strings reclaims that space immediately.
| Option | Description |
|---|---|
--disk= |
Disk to inspect, defaults to the configured static disk |
--limit= |
Rows per top-N table (default 15) |
--json |
Machine-readable output, for monitoring or a disk-usage alert |
Compressed siblings count towards size but not towards the page count, so enabling gzip never looks like cache growth. Only local disks can be inspected: walking a remote disk would mean one API call per file.
Laravel Static supports multi-domain setups out of the box. When include_domain is enabled (default), each domain gets its own cache directory:
storage/app/public/
├── example.com/
│ ├── GET/
│ │ ├── index.html
│ │ └── about.html
├── subdomain.example.com/
│ ├── GET/
│ │ └── index.html
When include_query_string is enabled, different query strings create separate cache files:
/products?page=1 → products/page=1.html
/products?page=2 → products/page=2.html
/search?q=laravel → search/q=laravel.html
Enable HTML minification to reduce file sizes:
// config/static.php
'options' => [
'minify_html' => true,
],This removes unnecessary whitespace, comments, and optimizes the HTML output using the voku/html-min library.
During development or testing, you may want to bypass the static cache. The package includes a bypass header mechanism:
curl -H "X-Laravel-Static: off" https://example.com/This header tells the middleware to skip the static cache and generate a fresh response.
Use the StaticCache facade to clear cache programmatically:
use Backstage\LaravelStatic\Facades\StaticCache;
// Clear all cache
StaticCache::clear();
// Clear specific paths
StaticCache::clear(['/about', '/contact']);Refresh one or more pages without rebuilding the whole site. Each URL is
re-rendered through its own route, so only those cached files are written — the
targeted counterpart to clear():
use Backstage\Static\Laravel\Facades\StaticCache;
// Rebuild a single page
StaticCache::build('https://example.com/blog/my-post');
// Rebuild several at once
StaticCache::build([
'https://example.com/blog/my-post',
'https://example.com/blog',
]);To do this off the request cycle, dispatch the queued BuildStaticPage job:
use Backstage\Static\Laravel\Jobs\BuildStaticPage;
BuildStaticPage::dispatch($post->url());The job is a no-op when static caching is disabled, and a failed render is reported rather than thrown — the page keeps its previous cached copy until the next build.
Create a custom crawl observer to customize the crawling behavior:
namespace App\Crawlers;
use Backstage\LaravelStatic\Crawler\StaticCrawlObserver;
use Psr\Http\Message\UriInterface;
use Psr\Http\Message\ResponseInterface;
class CustomCrawlObserver extends StaticCrawlObserver
{
public function crawled(UriInterface $url, ResponseInterface $response, ?UriInterface $foundOnUrl = null): void
{
// Add custom logic before caching
logger()->info("Caching: {$url}");
parent::crawled($url, $response, $foundOnUrl);
}
}Update your configuration:
'build' => [
'crawl_observer' => \App\Crawlers\CustomCrawlObserver::class,
],Control which URLs get crawled by creating a custom crawl profile:
namespace App\Crawlers;
use Psr\Http\Message\UriInterface;
use Spatie\Crawler\CrawlProfiles\CrawlProfile;
class CustomCrawlProfile extends CrawlProfile
{
public function shouldCrawl(UriInterface $url): bool
{
$path = $url->getPath();
// Skip admin routes
if (str_starts_with($path, '/admin')) {
return false;
}
// Skip API routes
if (str_starts_with($path, '/api')) {
return false;
}
return true;
}
}Routes with parameters cannot be automatically cached (they require specific values). You can also explicitly exclude routes by not applying the middleware:
// These routes will be cached
Route::middleware([StaticResponse::class])->group(function () {
Route::get('/', [HomeController::class, 'index']);
Route::get('/about', [PageController::class, 'about']);
});
// These routes will NOT be cached (no middleware)
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/user/{id}', [UserController::class, 'show']); // Has parametersIf your web server isn't rewriting pretty URLs (missing try_files / mod_rewrite), Laravel derives the request root from index.php and can leak it into generated links and cache paths — e.g. /index.php/about instead of /about.
The package guards against this in two ways:
-
Path normalization (always on): a leading
index.phpsegment is stripped from the cached file path, so a page requested as/index.php/aboutis still cached asabout.html. This applies to both drivers. -
Force root URL (opt-in): enable
force_root_urlso that during a build the root URL used byurl(),route(), andasset()is forced toconfig('app.url'), keepingindex.phpout of the links your pages contain:// config/static.php 'build' => [ 'force_root_url' => true, ],
STATIC_FORCE_ROOT_URL=true
This affects the
routesdriver (which renders pages in the build process). Thecrawlerdriver renders each page in a separate HTTP request, so for it the path normalization above is what keeps cache paths clean — the real fix there is correcting your server's URL rewriting.
Enable on_termination to generate cache files after the response is sent to the user:
'options' => [
'on_termination' => true,
],This improves perceived performance as users don't wait for the cache file to be written.
For optimal performance, configure your web server to serve static files directly without hitting PHP.
server {
listen 80;
server_name example.com;
root /var/www/html/public;
# Try static cache first, then Laravel
location / {
# Check for static cache file
set $cache_path /storage/example.com/GET$uri;
# Handle index files
if (-f $document_root$cache_path/index.html) {
rewrite ^ $cache_path/index.html last;
}
# Handle direct files
if (-f $document_root$cache_path.html) {
rewrite ^ $cache_path.html last;
}
# Fall back to Laravel
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}If you enabled precompression, turn on nginx's
static-precompression modules so it serves the .gz / .br siblings when the
client supports them. nginx looks for <file>.gz / <file>.br matching the file
it's about to serve, so no path changes are needed — just add:
# gzip_static is built into nginx; brotli_static needs the ngx_brotli module.
gzip_static on;
brotli_static on;Place these inside the server (or location) block above. When a .br/.gz
sibling exists and the request carries a matching Accept-Encoding, nginx serves
it and sets Content-Encoding automatically; otherwise it falls back to the
uncompressed file.
<IfModule mod_rewrite.c>
RewriteEngine On
# Check for static cache
RewriteCond %{DOCUMENT_ROOT}/storage/%{HTTP_HOST}/GET%{REQUEST_URI}.html -f
RewriteRule ^(.*)$ /storage/%{HTTP_HOST}/GET/$1.html [L]
RewriteCond %{DOCUMENT_ROOT}/storage/%{HTTP_HOST}/GET%{REQUEST_URI}/index.html -f
RewriteRule ^(.*)$ /storage/%{HTTP_HOST}/GET/$1/index.html [L]
# Laravel fallback
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L]
</IfModule>Clear cache when content changes using model events:
use Backstage\LaravelStatic\Facades\StaticCache;
class Post extends Model
{
protected static function booted()
{
static::saved(function (Post $post) {
StaticCache::clear([
"/blog/{$post->slug}",
'/blog',
]);
});
static::deleted(function (Post $post) {
StaticCache::clear([
"/blog/{$post->slug}",
'/blog',
]);
});
}
}Prefer re-rendering the changed page over clearing it when you want the cache to
stay warm. Dispatch the BuildStaticPage job so the edited page is rebuilt
immediately instead of being regenerated lazily on the next visit:
use Backstage\Static\Laravel\Jobs\BuildStaticPage;
static::updated(function (Post $post) {
BuildStaticPage::dispatch($post->url());
});Add a scheduled task to rebuild your cache periodically:
// app/Console/Kernel.php or bootstrap/app.php (Laravel 11+)
Schedule::command('static:build')->daily();Clear and rebuild cache during deployments:
#!/bin/bash
# deploy.sh
php artisan static:clear
php artisan static:build| Feature | Routes Driver | Crawler Driver |
|---|---|---|
| Setup complexity | Manual (add middleware to each route) | Automatic (discovers all pages) |
| Control | Fine-grained | Less control |
| Speed | Faster (only caches specified routes) | Slower (crawls entire site) |
| Discovery | Manual | Automatic |
| Best for | Selective caching, large apps | Content sites, blogs |
- Request Interception: The
StaticResponsemiddleware intercepts outgoing responses - Eligibility Check: Only
GET/HEADrequests with200 OKstatus are cached - File Generation: HTML content is saved to the configured storage disk
- Optional Minification: If enabled, HTML is minified before saving
- Directory Structure: Files are organized by domain, HTTP method, and URI path
The PreventStaticResponseMiddleware (automatically registered) handles bypass headers and ensures proper behavior during cache building.
- Ensure
STATIC_ENABLED=trueis set in your.env - Verify the
StaticResponsemiddleware is applied to your routes - Check that the storage disk is writable
- Routes with parameters cannot be cached automatically
- Only
200 OKresponses are cached
- Verify static files exist in your storage directory
- Check web server configuration
- Ensure the bypass header is not being sent accidentally
- Check if pages are linked from the homepage
- Verify
accept_no_followsetting if usingrel="nofollow"links - Review your crawl profile configuration
- Note: JavaScript-rendered content is not supported
If you encounter file path length errors:
- Check the
filepath_max_lengthandfilename_max_lengthsettings - Consider using shorter URLs or disabling query string caching
- The package will skip files that exceed the configured limits
Run the test suite:
composer testRun tests with coverage:
composer test-coverageRun static analysis:
composer analyseFormat code:
composer formatPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
Built with Spatie Crawler and voku/HtmlMin.
The MIT License (MIT). Please see License File for more information.