K KodePressdocumentation
Laravel 11 · MySQL 8 Specification Built 2026-10-10

Architecture Decision Records#

One entry per decision that would otherwise be re-argued. Each records the decision, why it was taken, and what it rules out. A decision is changed by adding a new ADR that supersedes an old one — entries are not edited after the fact except to mark them superseded.

# Decision Status
0001 Laravel 11 as the baseline Accepted
0002 One JSON document per content tree Accepted
0003 site_id on every table from day one Accepted
0004 Four extra built-in blocks Accepted
0005 URL prefix for non-default locales Accepted
0006 Search without FULLTEXT Accepted
0007 Clear everything on chrome changes Accepted
0008 No config:cache in production Accepted
0009 Specificity-based part assignment Accepted
0010 Send form notifications inline Accepted
0011 Hand-written public CSS, no utility framework Accepted
0012 Product name KodePress, renamed install command Accepted
0013 No strict CSP by default Accepted

ADR-0001 — Laravel 11 as the baseline#

Decision. Build on Laravel 11 with PHP 8.3, as specified in the source master prompt, and write nothing that depends on APIs removed in Laravel 12 or later.

Why. The specification names Laravel 11 and the target shared hosts reliably offer PHP 8.3. Pinning the documented version avoids a mismatch between these documents and the generated code.

Consequences. Dependencies are chosen in their Laravel-11-compatible versions. Code avoids anything deprecated in 11 so a later upgrade is mechanical. Upgrading the framework major version is a deliberate, tested piece of work with its own ADR — never a side effect of another change.

Open. If implementation starts well after this specification was written, confirm the Laravel version with the owner before Phase 0, because starting on a version that is already out of security support would be a poor trade. Default assumed: Laravel 11.


ADR-0002 — One JSON document per content tree#

Decision. Store a whole page (and header, footer, mega panel) as a single JSON document, not as rows of sections, columns and blocks.

Why. Pages are read whole and written whole. A single document makes a version an exact snapshot, autosave a single UPDATE, undo/redo a client-side array of trees, and the editor state identical to the stored state. Relational storage would add joins to the hot path and make version history expensive and lossy.

Consequences. "Which pages use block X?" cannot be a SQL query; it is answered by a usage index rebuilt on publish and treated as derived data (04.8). The tree format needs explicit versioning and upcasters (04.10). Everything that must be filtered or sorted — status, path, dates, taxonomy — stays a real indexed column.


ADR-0003 — site_id on every table from day one#

Decision. Every content table carries site_id with a foreign key and a global scope from the first migration, although Phase 0–4 ship a single site.

Why. Retrofitting tenancy means a migration across every table, a sweep of every query, and a long tail of leaks. Adding one nullable-free column now costs nothing: one index, one scope.

Consequences. Phase 5 multi-site is routing, scoping and UI work, not a schema change. Every query goes through models with the scope applied; raw queries in the codebase must add site_id themselves and are rare by policy. Cache keys include the site id from the start.


ADR-0004 — Four extra built-in blocks#

Decision. Ship spacer, divider, icon_list and global in addition to the 18 blocks the source specification lists.

Why. The eight ready-made sections cannot be built without spacing and dividers; the mega-menu layouts are mostly icon-and-label lists, and reimplementing one per layout would duplicate markup; global is how a page references a reusable section, which Phase 4 requires.

Consequences. 22 core blocks. Core stays closed after that: any further block is a block pack or a plugin, and adding one to core requires an ADR. This keeps the palette small enough to scan, which is the reason the limit exists.


ADR-0005 — URL prefix for non-default locales#

Decision. Multi-language sites use a path prefix for every locale except the default (/about, /en/about). Single-language sites have no prefix anywhere.

Why. A path prefix is cacheable per path with no header-based variation, needs no DNS or subdomain work on shared hosting, is obvious to a non-technical owner, and is unambiguous for crawlers and hreflang. Subdomains need DNS and certificates; a query parameter is bad for SEO and for sharing; Accept-Language negotiation cannot be reconciled with a shared HTML cache.

Consequences. The catch-all route strips a known locale prefix before resolving the path. The cache is keyed by locale. The language switcher emits real links. A prefix_default option exists for owners who want /bn/... on everything, and flipping it writes redirects.


ADR-0006 — Search without FULLTEXT#

Decision. Public search uses a normalised LIKE-plus-ranking query over the title, the excerpt and a plain-text projection of the published tree. No MySQL FULLTEXT index, no external search engine.

Why. The default InnoDB full-text parser does not tokenise Bengali usefully, and the ngram parser must be chosen at index creation time with a fixed token size that serves Bengali and English badly at once. An external engine (Meilisearch, Elastic) is impossible on the target host. Substring matching over a normalised projection is predictable in both languages.

Consequences. Search quality is adequate, not excellent, and it degrades on very large sites; the practical limit is a few thousand posts, which is far beyond the target audience. The projection is stored in the file cache and rebuilt on publish, so it is derived data and never a migration concern. If search becomes a real requirement, a plugin can add a better index — the search service is an interface for exactly that reason.


ADR-0007 — Clear everything on chrome changes#

Decision. A change to a menu, a template part, theme tokens or site options clears the entire page HTML cache rather than computing which pages were affected.

Why. These changes affect nearly every page anyway. Partial invalidation would need fragment-level caching of header, footer and menu, which introduces a class of bug — a stale header on some pages — that a non-technical owner cannot diagnose and we could not reproduce on demand. Clearing is a directory delete; re-rendering costs 150–400 ms per page, once, lazily, as visitors arrive.

Consequences. Chrome edits briefly make the site slower until pages re-render; cache-warm mitigates it for the busiest paths. CacheInvalidator stays small and auditable. Page-level changes still invalidate only the page, so ordinary editing is unaffected.


ADR-0008 — No config:cache in production#

Decision. The deploy script runs route:cache and view:cache but never config:cache.

Why. On shared hosting, a cached config combined with an edited .env produces a site reading stale credentials with no visible cause, and the recovery requires exactly the SSH access that is often the hardest thing to get working. The performance gain is small compared with the HTML cache, which already removes most of the framework from the hot path.

Consequences. env() is called only in config files, never at runtime, so behaviour stays predictable. The deploy script documents the omission so nobody "fixes" it later.


ADR-0009 — Specificity-based part assignment#

Decision. Which header or footer a page gets is decided by a fixed specificity per rule type (all 10, type 20, category 30, path 40, pages 50, per-page override 100), with ties broken by position then id.

Why. User-managed priority numbers are the classic source of "why is the wrong header showing?". Fixed specificity makes the behaviour explainable in one sentence — the more specific rule wins, and a per-page choice beats every rule — and makes it deterministic.

Consequences. An owner cannot express "this broad rule beats that narrow one". No real request for that has appeared, and the per-page override covers the cases that matter. The admin UI shows each part's matched page count and each page's resolved part with the reason, so the resolution is visible rather than magic.


ADR-0010 — Send form notifications inline#

Decision. Contact-form notification emails are sent during the request (QUEUE_CONNECTION=sync), with failures caught and logged after the submission is already stored.

Why. No queue worker exists on the target host. The alternative — queueing to the database and sending on the next cron tick — adds up to a minute of delay to the most time-sensitive notification the product sends.

Consequences. A slow SMTP server slows the form response. A failing SMTP server never loses a submission: the row is written first, the user still sees success, and the failure appears in Dashboard -> Needs attention. If a site genuinely needs volume, a plugin can switch to the scheduler-driven path.


ADR-0011 — Hand-written public CSS, no utility framework#

Decision. The public stylesheet is a single hand-written Blade template compiled from theme tokens. No Tailwind or similar in public output. The admin UI does use a compiled utility stylesheet.

Why. Public CSS must be small, stable and fully determined by tokens, and it must be generated on a server with no Node. A utility framework would either require a build per site or ship a large stylesheet full of classes no page uses. Writing the primitives by hand keeps the output under 40 KB and makes "change a token, change the site" literally true.

Consequences. Block views are limited to the kp- classes the theme defines, which is also what guarantees they contain no hard-coded colours and that a future dark mode needs no block changes. Adding a visual primitive means editing one template, deliberately.


ADR-0012 — Product name KodePress, renamed install command#

Decision. The product is KodePress. The install command is php artisan kodepress:install, with cms:install kept as a working alias. Config is config/kodepress.php; the cache lives in storage/app/kodepress-cache; public CSS classes are prefixed kp-.

Why. The source master prompt called the product "Easy CMS" and the command cms:install. The owner named it KodePress. Keeping the alias means older notes, scripts and the source document keep working.

Consequences. Both command names are tested. App\ stays the application namespace per Laravel convention; KodePress\ is used only for plugin namespaces.


ADR-0013 — No strict CSP by default#

Decision. Ship nosniff, X-Frame-Options, Referrer-Policy, a minimal Permissions-Policy and HSTS, but no strict Content-Security-Policy by default. Offer an opt-in strict CSP with a report-only first step.

Why. The HTML block and the analytics snippets exist precisely to inject third-party code. A CSP that blocks them presents to a non-technical owner as "my site is broken" with no diagnosable cause.

Consequences. XSS defence rests on sanitisation at the boundary and on design.customcode being Admin-only — both tested. Owners who want a strict CSP can enable it and are warned that custom snippets may need nonces. If a future release makes third-party injection declarative (a known-hosts list), a strict default becomes possible and gets its own ADR.


Differences from the source master prompt#

The source document is ../../easy-cms-claude-code-prompt.docx. Where this documentation differs, this documentation wins:

Source said This documentation says Why
"Easy CMS", cms:install KodePress, kodepress:install (alias kept) ADR-0012
18 built-in blocks 22 ADR-0004
Database list without pivots adds category_page, media_folders, sessions, failed_jobs; names every column and index the list was a sketch; doc 03 is the contract
"categories, tags" for posts many-to-many categories plus primary_category_id; nestable nested categories are required by the menu auto_children feature
Four phases, split into six same six phases the source already split them this way
Menu locations footer-1, footer-2 footer-1 … footer-4 a 4-column footer with a menu per column is an explicit acceptance criterion
Nothing about the content format version content.version plus upcasters restoring an old version must keep working
Nothing about search implementation ADR-0006 Bengali tokenisation forces the decision
Nothing about part assignment ties ADR-0009 specificity table "the most specific rule wins" needed a definition
Optional 2FA, unspecified code length TOTP 6 digits (RFC 6238); email/SMS codes 4 digits house rule: emailed and texted codes are 4 digits

KodePress documentation · generated from the Markdown sources by tools/build-docs-site.py · internal preview, not indexed.