13 — Caching and performance#
The performance model is deliberately crude, because crude survives shared hosting: published
pages are rendered to HTML files and served from disk. No Redis, no reverse proxy, no edge, no
assumptions about the host beyond a writable storage/.
13.1 Cache layers#
| Layer | Driver | Holds | Lifetime |
|---|---|---|---|
| Page HTML cache | files under storage/app/kodepress-cache/ |
fully rendered public pages | until invalidated |
| Application cache | Laravel file driver |
block manifest, resolved menus, part resolution, usage indexes, search projections, sitemap dirty flag | until invalidated, or a short TTL |
| Theme CSS | a hashed file on the public disk | compiled tokens | until tokens change |
| Browser / CDN | HTTP headers | assets (immutable, 1 year), HTML (no-cache, revalidated) |
per header |
| OPcache | PHP | compiled PHP | per deploy (cleared by the deploy script) |
HTML is sent with Cache-Control: no-cache so a visitor always revalidates and a published change
is visible immediately, plus an ETag so the response is usually a 304. Serving HTML from our own
file cache is fast; letting a browser hold a stale page for minutes is not worth the risk of an owner
saying "I published it and nothing changed".
13.2 Page cache keys and files#
storage/app/kodepress-cache/
└── site-1/
├── bn/
│ ├── _index.html # the homepage
│ ├── about.html
│ └── blog/
│ ├── _index.html
│ ├── _index--page-2.html
│ └── my-first-post.html
└── en/
└── about.html
The key is site_id / locale / path [/ page-N] [/ year]:
| Component | Why it is in the key |
|---|---|
site_id |
multi-site isolation (Phase 5), harmless before that |
locale |
content and chrome differ per language |
path |
the page itself |
page-N |
paginated archives are separate entries |
| year | only for pages whose footer uses {{year}}; see 08 |
Device is not in the key: responsive CSS handles layout, and device-based menu visibility is done
with CSS classes precisely so the cache stays single-variant. Query strings are not in the key: a
page with a query string that affects output (search, pagination beyond page) is not cached at all.
Each entry is two files: about.html and about.meta.json (rendered-at timestamp, content hash,
locale, the page id, and the dependency list from the usage index). The metadata is what makes
targeted invalidation and cache reporting possible.
13.3 Invalidation rules#
| Change | Clears |
|---|---|
| Publish / unpublish / schedule-fires on a page | that page, every locale and pagination variant |
| Page path change | the old path and the new path (the old becomes a 301) |
| Page deleted or trashed | that page |
| Global block published | every page whose usage index lists it |
| Form edited | every page whose usage index lists it |
| Media replaced | every page whose usage index lists it |
| Category or tag renamed | every archive page of that taxonomy, plus pages whose post_list filters on it |
| New post published | the blog index, the author archive, and the archives of its categories and tags (the post_list blocks on them change) |
| Menu, menu item or mega panel changed | all pages |
| Template part (header/footer/topbar/announcement) published | all pages |
| Theme tokens or custom CSS saved | all pages |
| Site options changed (logo, social, analytics) | all pages |
| Locale added or removed | all pages |
| Deploy | all pages, application cache, OPcache |
CacheInvalidator is the only place that maps a model event to a clear. Models fire events; the
invalidator decides. Nothing else calls HtmlCache::forget*.
Why "all pages" is acceptable. Header, footer, menu and theme changes are rare (a handful per month on a live site) and clearing is cheap — it is a directory delete. The cost is re-rendering on the next visit, roughly 150–400 ms per page on the target host, once per page. The alternative (partial templating with fragment caching) adds a class of "stale header on one page" bugs that a non-technical owner cannot diagnose. See ADR-0007.
13.4 What is never cached#
- Any request from an authenticated user (so inline editing and the edit bar always work, and an editor never sees their own stale page).
- The search page.
- A page containing a block with
cacheable: false(Login/Account, live Search). - Any request with a query string other than
page. POSTand every other non-GET method.- Preview routes.
- The admin area (obviously), which also sends
no-store.
A page skipped for one of these reasons records the reason in a debug header
(X-KodePress-Cache: bypass; reason=auth) when APP_DEBUG is on, so "why is this page slow?" has an
answer.
13.5 Warming#
kodepress:cache-warmrenders the homepage and the N most recently published pages (default 20). It runs after a deploy, after a chrome-wide clear, and nightly.- Warming is chunked: it renders at most 10 pages per cron tick and keeps a cursor, so it never trips a shared-hosting time limit.
- Warming is best-effort. A failed render logs and moves on; it never blocks a deploy.
13.6 Public page performance budget#
Measured on the target class of host (shared cPanel, no opcode preloading, spinning or modest SSD):
| Metric | Budget | Notes |
|---|---|---|
| TTFB, cache hit | < 150 ms server time | one stat, one file read, headers |
| TTFB, cache miss | < 600 ms | page + part + menu queries, render, write |
| Total page weight | < 500 KB for a typical page | WebP images dominate |
| CSS | < 40 KB uncompressed, 1 file | theme CSS only |
| JS | < 30 KB uncompressed, 1 file | Alpine + the header/menu/accordion behaviours |
| Requests | < 20 | fonts (2), css (1), js (1), images |
| Queries, cache hit | 0 | nothing touches MySQL |
| Queries, cache miss | < 15 | asserted by a test |
| Largest Contentful Paint | < 2.5 s on 4G | hero image eager with fetchpriority |
| Cumulative Layout Shift | < 0.05 | width/height on every image, no late-injected banners |
A test asserts the query count on a cache miss and the asset counts; budgets that are not tested are decoration.
13.7 Front-end delivery#
- One stylesheet (the compiled theme) and one script bundle. Block-specific CSS is appended to the theme file for blocks actually in use on the site, decided at compile time from the usage index.
- The script bundle is Alpine plus the small set of behaviours (sticky header, drawer, mega panel,
accordion, lightbox, dismissible bar, back-to-top). It is
deferred. Nothing in the public output requires JavaScript to read the content — menus render as links, accordions start open-first, images are real<img>tags. public/buildis committed, so the server needs no Node; the Vite manifest hash in filenames handles busting.- Fonts: 2 woff2 files preloaded,
font-display: swap, self-hosted. - No third-party requests unless the owner adds an analytics snippet or enables a captcha.
13.8 Admin performance#
| Risk | Mitigation |
|---|---|
| N+1 in page lists | eager load author, category, version; asserted with a query-count test |
| Large media library | paginated grid (60 per page), thumbnails only, lazy images |
| Menu with hundreds of items | one query, tree built in PHP, preview rendered on demand |
| Big trees in the editor | tree held once in the Livewire component; Alpine handles per-keystroke state so no round trip per character |
| Autosave storms | debounced and throttled (05) |
| Audit log growth | indexed by subject and user, paginated, pruned nightly |
| Submissions growth | indexed by form + date, paginated, optional retention |
13.9 Observability on a host with no tooling#
Dashboard -> Systemshows: last cron tick, cache size and entry count, queued scheduled posts, disk free, PHP version, image driver, last backup, last deploy commit.storage/logsrotates daily with 14 days kept. The dashboard surfaces the count of errors in the last 24 hours with a link, because an owner will never read a log file.kodepress:doctor(11) is the one command to run when something feels wrong.- A
X-KodePress-Render: 142ms; cache=missheader is emitted whenAPP_DEBUGis on.
13.10 Tests for this document#
| Test | Asserts |
|---|---|
cached_page_served_without_queries |
zero queries on the second request |
publish_clears_only_that_page |
a second cached page survives |
part_publish_clears_everything |
both pages gone |
theme_save_clears_everything |
both pages gone |
new_post_clears_blog_index_and_archives |
index, category and tag archives gone, unrelated page kept |
authenticated_request_bypasses_cache |
and does not poison the cache for guests |
pagination_cached_separately |
?page=2 has its own entry |
locale_cached_separately |
/about and /en/about |
query_string_not_cached |
?utm=x is served fresh and writes no entry |
cache_miss_query_budget |
under 15 queries |
warm_command_is_chunked |
at most 10 pages per run, cursor advances |
cache_directory_is_not_web_readable |
a direct HTTP fetch of a cache file 403s or 404s |
KodePress documentation · generated from the Markdown sources by
tools/build-docs-site.py · internal preview, not indexed.