mehedimi / static-vel
Effortless static response caching for Laravel: capture rendered pages and serve them straight from nginx.
Requires
- php: ^8.3
- illuminate/auth: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^10.5|^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Static response caching for Laravel
Effortless static response caching for Laravel: capture rendered responses after they are sent, then let nginx serve them as plain files — without touching Laravel at all.
flowchart LR
A["Request<br/>GET /blog?category=e-commerce"] --> B{"Authenticated?"}
B -->|yes| NF["Laravel responds<br/>with fresh content<br/>(never captured)"]
B -->|no| C{"Inertia partial<br/>reload?"}
C -->|yes| NF
C -->|no| D{"Safe query<br/>string?"}
D -->|no| NF
D -->|yes| E{"Query string<br/>present?"}
E -->|no| F["nginx looks for<br/>blog.html<br/>(.json for Inertia)"]
E -->|yes| G["nginx looks for<br/>blog.category=e-commerce.html<br/>(.json for Inertia)"]
F -->|file exists| S["Served as a static file<br/>PHP never boots"]
G -->|file exists| S
F -->|missing| L["Laravel renders<br/>the page"]
G -->|missing| L
L --> T["Response sent<br/>to the browser"]
T --> U["static-vel captures<br/>+ stores the file"]
U -.->|next visit| S
style S fill:#10b981,color:#ffffff
style L fill:#3b82f6,color:#ffffff
style NF fill:#3b82f6,color:#ffffff
style U fill:#f59e0b,color:#ffffff
Loading
- Opt-in per route — pages are only captured where you attach the
static-velmiddleware. - Zero request overhead — capture happens in
terminate(), after the browser already got the response. - Web-server serving — nginx returns the stored file for matching paths and falls back to Laravel when no file exists.
- Inertia-aware — with
handle_inertiaenabled, Inertia JSON payloads are captured too, so client-side navigations are served statically as well.
Requirements
- PHP 8.3+
- Laravel 11, 12 or 13
- nginx (or any web server able to serve files with a fallback to Laravel)
Installation
composer require mehedimi/static-vel
Optionally publish the config file:
php artisan vendor:publish --tag=static-vel-config
Capturing pages
Attach the static-vel middleware alias to any route or route group whose rendered response is identical for every visitor:
use Illuminate\Support\Facades\Route; Route::middleware('static-vel')->group(function () { Route::view('/', 'welcome'); Route::view('/about', 'about'); Route::get('/products/{product}', [ProductController::class, 'show']); });
The first GET request that returns a 200 response is written to storage/static-vel/ after the response has been sent. / is treated as index, and the response kind decides the extension:
| Request | Stored file |
|---|---|
/ (page load) |
storage/static-vel/index.html |
/ (Inertia visit) |
storage/static-vel/index.json |
/about (page load) |
storage/static-vel/about.html |
/about (Inertia visit) |
storage/static-vel/about.json |
/blog?category=e-commerce |
storage/static-vel/blog.category=e-commerce.html |
/products/12 |
storage/static-vel/products/12.html |
Files are written atomically (temporary file + rename), so nginx never picks up a half-written page.
What is not captured
- Non-
GETrequests - Responses with a status code other than
200(redirects, 404s, 500s…) - Streams and file downloads
- Responses that are neither
text/htmlnor an Inertiaapplication/jsonpayload - Inertia partial reloads (
X-Inertia-Partial-Component) — they exist to fetch fresh data, and caching them would overwrite the full payload with a subset of props - Inertia visits, when
handle_inertiaisfalse - Requests carrying the auth marker cookie — authenticated visitors' responses must never overwrite the guest cache
- Requests with query strings whose parameters were not allowed via
CacheKey::allowQueryKeys() - Responses the application explicitly skipped via
Capture::skip() - Requests with an unsafe query string (too long, or containing characters that could form a path)
- Paths attempting traversal (
/..%2Fetc/passwd)
Skipping capture
The application can tell the package to skip capturing a specific response at runtime — useful when a route conditionally renders something that must never be cached:
use Mehedi\StaticVel\Capture; public function show(Product $product) { if ($product->isPersonalized()) { Capture::skip(); } return view('product', compact('product')); }
The response is still rendered and sent to the visitor — only the static capture is skipped. The flag lives on the request, so it is scoped to exactly one request and never leaks to the next.
Query strings
By default, query strings never produce cache files — /blog?category=x is served by Laravel on every visit, and only the clean /blog path is cached. This means visitors cannot flood the storage directory with made-up query strings.
To cache query variants, the application opts in per request with the CacheKey service — ideally right after validating the parameter values with Laravel's validation API, so only values that passed your rules can ever end up in a cache file name:
use Mehedi\StaticVel\CacheKey; // App\Http\Controllers\BlogController public function index(Request $request) { $validated = $request->validate([ 'category' => ['nullable', 'string', 'max:50'], 'page' => ['nullable', 'integer', 'min:1'], ]); // Only validated keys become part of the cache key: CacheKey::allowQueryKeys(array_keys($validated)); // The raw query string becomes part of the file name, verbatim: // /blog?category=e-commerce -> blog.category=e-commerce.html }
The rules:
- No allowed keys, no cached query. Unless the request's controller calls
CacheKey::allowQueryKeys(), every query variant stays dynamic. - Every parameter must be allowed. One request parameter outside the allowed keys (
?category=x&madeup=y) skips the capture for that request. - Validation gates the values. A request whose value fails your rules never returns a
200response, so it is never captured — malicious visitors cannot inject data into a cache file name. - Keys don't leak. Allowed keys are stored on the request itself and die with it — they never spill into other requests, including under Octane / PHP-PM workers.
- The query string is used verbatim (raw, exactly as sent).
?category=e%2Dcommerceand?category=e-commerceare two different cache files, and so are?a=1&b=2and?b=2&a=1. Verbatim is what lets nginx reconstruct the file name from$argswithout any hashing. - Query strings longer than 128 characters, or containing anything outside
A-Za-z0-9_=&%.+,~-(after decoding: no slashes or backslashes) are never cached — those requests always reach Laravel. This keeps path traversal (?x=..%2F..%2Fetc) out of the file name, on both the capture and the nginx side. - First capture wins per exact URL — a captured variant keeps serving until the cache is cleared.
Warning
A page whose content varies by query parameter can now be cached safely — each variant gets its own file. But every variant must still render the same response for every visitor: never attach the middleware to pages where a query parameter personalizes content (e.g. ?user_id=), and never to authenticated dashboards.
Serving with nginx
This is the whole trick: when a captured file exists, nginx serves it and Laravel is never invoked. When it doesn't, the request falls through to Laravel as usual — which is also how the page gets captured in the first place.
server { listen 80; server_name example.com; root /var/www/example.com/public; index index.php; # Internal redirect targets: # 418 -> requests with a query string, served from their own file # 419 -> "hand this to Laravel" # Both codes are deliberately obscure, so they never collide with a # real response. error_page 418 = @static_query; error_page 419 = @laravel; # 1. Rewrite every page request to its static file: # /about -> /static-vel/about.html # / -> /static-vel/index.html # /blog?x=y -> /static-vel/blog.x=y.html (via @static_query) # Inertia nav -> the same file with the .json extension location / { # Logged-in visitors always get fresh content from Laravel. if ($cookie_static_vel_auth) { return 419; } # Partial reloads ask for fresh data - always let Laravel answer. if ($http_x_inertia_partial_component) { return 419; } # Query strings that must never become part of a file name # (raw or encoded slashes, backslashes, dot segments) - let # Laravel answer. This allowlist is mirrored by the middleware. if ($args ~ "[^A-Za-z0-9_=&%.+,~-]") { return 419; } if ($args ~ "(%2F|%2f|%5C|%5c)") { return 419; } # Defense in depth: $args is the one input nginx does NOT # normalize, so dot segments are rejected outright. if ($args ~ "\.\.") { return 419; } # Requests with a query string are served from their own file. if ($args) { return 418; } set $ext html; if ($http_x_inertia) { set $ext json; } rewrite ^/$ /static-vel/index.$ext last; rewrite ^ /static-vel$uri.$ext last; } # 2. Same as above, but the file name carries the query string. location @static_query { set $ext html; if ($http_x_inertia) { set $ext json; } rewrite ^/$ /static-vel/index.$args.$ext last; rewrite ^ /static-vel$uri.$args.$ext last; } # 3. Serve the rewritten path from the storage directory. # `internal` keeps the cache from being requested directly. location ^~ /static-vel/ { internal; # Defense in depth: $uri is already normalized by nginx (dot # segments are resolved before location matching), and the # middleware never stores such file names. This guard only # protects against future config edits. if ($uri ~ "\.\.") { return 404; } # Parent directory of the storage path (storage/static-vel by default). # Point this at your `public` directory if you store inside public/. root /var/www/example.com/storage; try_files $uri @laravel; } # 4. Fallback: let Laravel render (and capture) the page. location @laravel { rewrite ^ /index.php last; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; } }
A request for /about therefore resolves as:
- A browser page load → nginx looks for
storage/static-vel/about.html→ served, PHP never boots. - An Inertia navigation (
X-Inertiaheader) → nginx looks forstorage/static-vel/about.json→ served, PHP never boots. - A request with a query string (
/blog?category=e-commerce) → nginx looks forstorage/static-vel/blog.category=e-commerce.html→ served, PHP never boots. - No file yet (or an unsafe query string, or a logged-in visitor) →
@laravel→ Laravel renders the page → the middleware stores it after the response is sent. - Every future visitor gets the static file until the cache is cleared.
Notes:
- The same config works whether
handle_inertiaistrueorfalse— withfalseno.jsonfiles are ever stored, so Inertia requests simply miss the cache and reach Laravel. - The query-string allowlist in nginx must match the one in the middleware (
A-Za-z0-9_=&%.+,~-, plus rejecting encoded slashes) — they are the two halves of the same file-name convention. - Don't register application routes under the
/static-vel/prefix — it belongs to the cache.
Note
Why the rewrites can't be tricked into directory traversal (../):
$uriis normalized by nginx before location matching —%XXsequences are decoded,.and..segments are resolved, and slashes are merged. A request for/blog/..%2f..%2fstoragenever reaches the rewrites as dots;$uribecomes/storage. So the rewritten path/static-vel$uri.$extcan only point deeper, never up.$argsis the one input nginx does not normalize — which is exactly why it is vetted on both sides: a character allowlist, encoded-slash rejection (%2F,%5C), and an explicit..rejection. If anything looks off, the request goes to Laravel instead of a file lookup.- The file-serving
location ^~ /static-vel/isinternal— an attacker can never request those URLs directly. - The
if ($uri ~ "\.\.")guard inside the cache location is pure defense in depth for future config edits.
Serving with Laravel Octane / PHP-PM
Application servers (Laravel Octane with Swoole, RoadRunner or FrankenPHP, and PHP-PM) don't use PHP-FPM — the app listens on a TCP port and nginx proxies to it. Keep everything above the same, but replace the fallback and the PHP location:
# Fallback: hand the ORIGINAL request URI to the application server. # $request_uri is untouched by the rewrites above, so Laravel sees # exactly what the client asked for. location @laravel { proxy_pass http://127.0.0.1:8000$request_uri; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # The fastcgi location is not needed with an application server.
Important
proxy_pass http://127.0.0.1:8000$request_uri; — the $request_uri part matters. Without it, nginx forwards the rewritten URI (/static-vel/blog.x=y.html), and Laravel would 404 on every cache miss.
Notes for long-running workers:
- Octane invokes terminable middleware after flushing the response, so capture works out of the box; the middleware itself is stateless and safe for persistent workers.
- PHP-PM uses the same proxy fallback (adjust the port). Its handling of terminable middleware has historically been inconsistent across versions — after installing, verify that the first visit actually creates a file under the storage path; if it doesn't, the static layer still serves whatever was captured by other means.
php artisan static-vel:clearstays in the deploy script exactly the same.
Authenticated visitors
nginx cannot check Laravel sessions — but it can inspect cookies. static-vel maintains a dedicated marker cookie (static_vel_auth, configurable) that only exists for authenticated visitors:
- The package sets the cookie automatically on the
Loginevent and clears it onLogout— no setup required. - The nginx config above skips the static cache whenever the request carries the cookie (
if ($cookie_static_vel_auth)) and lets Laravel render the page. - The capture middleware also skips requests carrying the cookie (and authenticated users in general), so personalized responses can never overwrite the guest cache.
Result: guests get the static page, logged-in users always get fresh content from the application.
Keeping the cookie in sync (recommended)
Add the sync middleware to your web middleware group so the cookie always mirrors the real auth state — it covers sessions that predate the package install, remember-me auto-logins, and session expiry without a logout event:
// bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ \Mehedi\StaticVel\Http\Middleware\SyncAuthCookie::class, ]); })
Notes:
- The very first request after a login (or remember-me auto-login) may still receive the cached guest page — the marker cookie is only sent back with the next request. After that, the user is on Laravel-rendered pages.
- Drift is always fail-safe: a stale marker cookie only costs a cache miss (Laravel renders), never wrong content.
- Auth detection is cookie-based. Visitors authenticated purely by API tokens (Authorization headers) are treated as guests by nginx.
- Avoid dashes in the cookie name — nginx exposes cookies as
$cookie_<name>variables.
Config generator
A small Vue + Tailwind app ships in config-generator/ to build the nginx configuration for you: enter your endpoints (each with optional query parameter names), your domain, roots, auth cookie, whether Inertia payloads should be served, and whether you run PHP-FPM or Octane — it generates the complete server block and shows the exact cache file every endpoint maps to, warning you about query parameters that will never be cached. Query parameter values are dynamic plain strings: every distinct query string is cached under its own file name.
It is a development tool only — export-ignored from dist archives and not required by the package:
cd config-generator
pnpm install
pnpm run dev
Once the package repository is on GitHub, the app is deployed automatically to GitHub Pages on every push to main (workflow: .github/workflows/deploy-config-generator.yml — it builds the app and pushes config-generator/dist to the gh-pages branch). Enable Settings → Pages → Source: Deploy from a branch → gh-pages once, and the generator is available at https://<user>.github.io/static-vel/.
Inertia support
Set handle_inertia to true (the default) and static-vel handles the full picture:
- Full page loads (no
X-Inertiaheader) are served the HTML shell. - Inertia navigations (client-side link clicks,
X-Inertia: true) are served the cached JSON payload — component, props, url and version included. - Partial reloads (
X-Inertia-Partial-Component) always reach Laravel: they exist precisely to fetch fresh props, and the middleware never caches their subset payloads.
Important
Always clear the static cache after deploying. The Inertia version and the hashed asset URLs (/assets/app-abc123.js) are baked into the captured HTML and JSON. A stale static file would reference deleted assets. Add this to your deploy script:
php artisan static-vel:clear
Cache invalidation
php artisan static-vel:clear
Removes every captured page (the directory itself is kept). Run it after each deploy, or whenever a page's content changes. The command verifies the directory is actually empty afterwards — if some files could not be deleted, it exits with a failure code instead of reporting success, so deploy pipelines stop instead of silently serving stale pages.
That failure means an ownership mismatch: the captured pages are written by the PHP user (e.g. www-data), while the command runs as someone else without write permission on the directory. Fix it once:
sudo chown -R www-data:www-data storage/static-vel
Since nginx serves files directly, there is no TTL — a captured page stays until you clear it. Build the clear step into your deploy pipeline (e.g. as a Laravel Deployer or Forge deploy script task).
Listing covered routes
php artisan static-vel:routes
Lists every route the capture middleware covers, together with the static file it writes — the file names are resolved with the exact same path mapping the middleware uses:
+--------+----------------+----------------------------------------+
| Method | URI | Static file |
+--------+----------------+----------------------------------------+
| GET | / | index.html, index.json |
| GET | /about | about.html, about.json |
| GET | /blog | blog.html, blog.json |
| GET | /products/{id} | products/{id}.html, products/{id}.json |
+--------+----------------+----------------------------------------+
- A route is covered when it carries the
static-velalias, theCaptureStaticResponseclass, a custom alias pointing at that class, or a middleware group containing any of them. If the middleware is registered globally, everyGETroute is covered. - Only
GETroutes are listed — nothing else is ever captured. The remaining runtime conditions still apply (status200, no auth marker cookie, query keys allowed viaCacheKey::allowQueryKeys()), so a listed route is covered, not necessarily captured on every request. - The JSON file is only shown while
handle_inertiais enabled.
Configuration
Published as config/static-vel.php:
| Key | Default | Description |
|---|---|---|
enabled |
env('STATIC_VEL_ENABLED', true) |
Master switch. The middleware becomes a pass-through when false. |
path |
env('STATIC_VEL_PATH', storage_path('static-vel')) |
Directory captured responses are written to. |
handle_inertia |
env('STATIC_VEL_HANDLE_INERTIA', true) |
Also capture Inertia JSON payloads as <path>.json. |
auth_cookie |
env('STATIC_VEL_AUTH_COOKIE', 'static_vel_auth') |
Marker cookie for authenticated visitors. Set to null to disable the auth handling. |
Security notes
- Captured responses are served to everyone except authenticated visitors (detected via the auth marker cookie) — only opt in pages that are safe to share between guests.
- Never attach the middleware to authenticated dashboards: the auth handling protects guests from personalization, but a page only ever rendered for logged-in users shouldn't be captured at all.
- Path traversal (
/..%2F...) is rejected; files can never be written outside the configured directory. - The nginx
internaldirective on the cache location keeps captured files from being requested as URLs directly. - First capture wins per exact URL (path + query string) — see the warning above.
- Cache flooding is dead by construction: query strings never create files unless the application allowed the keys and the values passed validation. The file name itself is still capped at 128 query-string characters — and
static-vel:clear/STATIC_VEL_ENABLED=falseremain the operational levers.
Testing
composer test composer lint # Pint code style
Roadmap
-
static-vel:warm— pre-generate pages from a route list (useful right after a deploy + clear) - Apache
mod_rewriteserving example - Auto-invalidation on model / cache events
License
static-vel is open-sourced software licensed under the MIT license.
Banner powered by ogdynamic.com