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.