Making Capell fast
Capell is fastest when most public requests never start PHP. Treat performance as a three-tier serving path, then size the PHP runtime for the smaller set of requests that reach it.
The three serving tiers
Section titled “The three serving tiers”| Tier | Serves | Expected path |
|---|---|---|
| CDN edge | Anonymous public HTML already stored near the visitor | Cloudflare responds without contacting your server |
| Web server | Generated HTML on the page_cache disk | nginx or Apache reads a file without starting PHP |
| PHP runtime | Admin, authenticated, personalised, uncached, and write requests | PHP-FPM or Octane boots or reuses Laravel |
Keep the tiers in that order. Octane can reduce the cost of the third tier, but it cannot beat a safe edge hit or a local static file.
Serve generated HTML before PHP
Section titled “Serve generated HTML before PHP”The package that owns the page cache defines its filename and invalidation contract. Use its documented mapping rather than guessing from the public URL. A typical nginx shape is:
set $page_cache_root /absolute/path/to/public/page-cache/https.example.com;
location = / { add_header Cache-Control "max-age=300, public, s-maxage=1800, stale-while-revalidate=86400" always; try_files $page_cache_root/pc__index__pc.html /index.php?$query_string;}
location / { add_header Cache-Control "max-age=300, public, s-maxage=1800, stale-while-revalidate=86400" always; try_files $uri $uri/ $page_cache_root$uri.html /index.php$is_args$args;}Both locations above declare add_header, which means they no longer inherit any
add_header set at server or http scope. These are exactly the responses that never
reach Laravel, so they have no middleware headers to fall back on either. Repeat the full
security-header set inside each of these locations alongside Cache-Control, or the
cached pages ship without it — see
Security headers.
This is only the final lookup. Before it, bypass the static path for anything that can
vary by visitor or request: methods other than GET or HEAD, query strings that are
not explicitly cache-safe, Laravel session cookies, authorization headers, signed or
preview URLs, and Livewire or other navigation requests. Keep the same bypass contract
at the web server, package middleware, and CDN.
The example policy means:
- browsers may reuse the response for five minutes (
max-age=300); - a shared cache may retain it for 30 minutes (
s-maxage=1800); - the shared cache may serve the previous response for up to one day while it
revalidates (
stale-while-revalidate=86400).
Choose shorter values when content must propagate faster. Never add a public policy to authenticated, personalised, preview, or authoring responses.
Put Cloudflare in front
Section titled “Put Cloudflare in front”Two things must already be true, or the site breaks the moment traffic is proxied: the
zone’s SSL/TLS mode must be Full (strict) rather than Flexible, and the application
must trust the proxy. Going live covers both. Neither
is a performance concern — a site that gets them wrong serves redirect loops and
http:// asset URLs regardless of how the cache rules are written.
- Proxy the site’s DNS record through Cloudflare.
- Create a Cache Rule for the site’s anonymous public
GETandHEADroutes. - Exclude admin, API, authentication, account, checkout, Livewire, health, preview, and signed paths.
- Exclude requests containing the configured Laravel session cookie.
- Set cache eligibility to Eligible for cache.
- Set Edge TTL to use the origin
Cache-Controlheader when present and bypass when it is absent. - Set Browser TTL to respect the origin.
- Add a separate Bypass cache rule for the session cookie as a defence in depth.
For a site using the default cookie, the bypass expression is:
(http.host eq "www.example.com" and http.cookie contains "laravel_session=")Do not use a rule that force-caches every HTML response or ignores origin cache
control. Capell and its packages use private, no-store, and missing public headers
to keep unsafe responses out of shared caches.
Give the application a least-privilege Cloudflare API token with Cache Purge permission for this zone. Configure the page-cache-owning package’s Cloudflare purge adapter with that token and the zone ID. Content invalidation should purge the affected URLs or cache tags; deploys may purge the whole zone when a precise list is unavailable.
The safe deploy order is:
- switch to the new release;
- generate and warm the new origin HTML;
- purge Cloudflare;
- verify the next anonymous request is a
MISSand the following request is aHIT.
Purging before the origin is warm creates an avoidable burst of PHP work and can refill the edge from stale or incomplete origin state.
Verify both sides:
curl -sS -D - -o /dev/null https://www.example.com/ \ | grep -Ei '^(cache-control|cf-cache-status|age|set-cookie):'curl -sS -D - -o /dev/null https://www.example.com/ \ | grep -Ei '^(cache-control|cf-cache-status|age|set-cookie):'curl -sS -D - -o /dev/null \ -H 'Cookie: laravel_session=cache-bypass-check' \ https://www.example.com/ \ | grep -Ei '^(cache-control|cf-cache-status|age|set-cookie):'The anonymous pair should settle at MISS then HIT with no Set-Cookie. The
session-bearing request must remain uncached; Cloudflare may label that response
BYPASS or DYNAMIC.
Tune a low-powered server
Section titled “Tune a low-powered server”Start with PHP-FPM unless measurements show that dynamic requests dominate. A two-core, 4 GB host can run Capell reliably when the database and PHP worker counts are bounded and at least 500 MB remains available during peak work.
For PHP-FPM, use pm = ondemand so idle sites do not retain a full pool. Set
pm.max_children from measured peak resident memory rather than CPU count alone:
safe children = floor(PHP memory budget / measured peak worker RSS)Start with a small pm.max_children, a short pm.process_idle_timeout, and
pm.max_requests between 250 and 1000 so gradual leaks are recycled. A queue worker is
another long-lived PHP process and belongs in the same budget.
Enable OPcache for the web SAPI. For an immutable release, a practical starting point is:
opcache.enable=1opcache.memory_consumption=192opcache.interned_strings_buffer=16opcache.max_accelerated_files=20000opcache.validate_timestamps=0Restart or reload the PHP runtime after switching releases. If files are edited in place, keep timestamp validation enabled instead of using the immutable-release setting.
Use this as an initial 4 GB allocation, then replace it with measured high-water marks:
| Area | Starting budget |
|---|---|
| Operating system, web server, and monitoring | 700-1000 MB |
| Database | 600-1000 MB |
| PHP web and queue workers | 900-1400 MB |
| Redis and other local services | 100-300 MB |
| Uncommitted headroom | at least 500 MB |
Do not fill every budget simultaneously. Database imports, cache generation, image work, backups, and deploys create short peaks; memory caps and headroom turn those peaks into visible failures instead of host-wide outages.
Where Octane fits
Section titled “Where Octane fits”Laravel Octane keeps Laravel booted for tier-three requests. RoadRunner is a good fit when the host uses non-thread-safe PHP and does not provide the Swoole extension. Begin with one or two workers, cap the service, set a finite maximum request count, and compare dynamic-route latency and steady memory with PHP-FPM before cutting over.
Two Octane workers can each retain an application-sized memory image, so a faster p50 can cost more idle memory than an on-demand FPM pool. Keep the static and Cloudflare tiers in front of Octane, and retain the FPM configuration for immediate rollback.
On every deploy, run:
php artisan capell:runtime-refreshphp artisan octane:reloadThe refresh rebuilds Capell’s runtime state; the reload ensures workers boot the new release. See Running Capell on Laravel Octane for the extension reset contract and Web server configuration for static-cache and multi-node requirements.