Skip to content

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.

TierServesExpected path
CDN edgeAnonymous public HTML already stored near the visitorCloudflare responds without contacting your server
Web serverGenerated HTML on the page_cache disknginx or Apache reads a file without starting PHP
PHP runtimeAdmin, authenticated, personalised, uncached, and write requestsPHP-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.

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.

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.

  1. Proxy the site’s DNS record through Cloudflare.
  2. Create a Cache Rule for the site’s anonymous public GET and HEAD routes.
  3. Exclude admin, API, authentication, account, checkout, Livewire, health, preview, and signed paths.
  4. Exclude requests containing the configured Laravel session cookie.
  5. Set cache eligibility to Eligible for cache.
  6. Set Edge TTL to use the origin Cache-Control header when present and bypass when it is absent.
  7. Set Browser TTL to respect the origin.
  8. 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:

  1. switch to the new release;
  2. generate and warm the new origin HTML;
  3. purge Cloudflare;
  4. verify the next anonymous request is a MISS and the following request is a HIT.

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:

Terminal window
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.

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=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

Restart 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:

AreaStarting budget
Operating system, web server, and monitoring700-1000 MB
Database600-1000 MB
PHP web and queue workers900-1400 MB
Redis and other local services100-300 MB
Uncommitted headroomat 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.

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:

Terminal window
php artisan capell:runtime-refresh
php artisan octane:reload

The 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.