02 — Architecture#
2.1 Stack#
| Layer | Choice | Notes |
|---|---|---|
| Framework | Laravel 11 | PHP 8.3. See ADR-0001 |
| Database | MySQL 8.0 | utf8mb4_0900_ai_ci, InnoDB, JSON columns used deliberately |
| Views | Blade | Public output is Blade components rendered from the block tree |
| Admin interactivity | Livewire 3 | Editor state, lists, settings screens |
| Client-side glue | Alpine.js | Local UI state only (toggles, dropdowns, drag handles) |
| Drag and drop | SortableJS | Sections, blocks, menu tree |
| Rich text | TipTap | richtext field type; output sanitised server-side |
| Images | intervention/image | Compression, WebP, size variants |
| Permissions | spatie/laravel-permission | Roles, permissions, model_has_roles |
| Auth scaffolding | Laravel Breeze (Blade) | Login and password reset only; the admin UI is ours |
| Asset build | Vite | Runs locally; public/build is committed |
| Tests | Pest (on PHPUnit) | Feature tests dominate |
Deliberately absent#
Redis, Horizon, queue workers, Node at runtime, Docker, websockets, Inertia, React/Vue, a public content API. Each is unavailable or unsupportable on the target host.
2.2 Runtime constraints and the choices they force#
The target is shared cPanel hosting with SSH access, one cron entry, no root, no daemons.
| Constraint | Consequence in KodePress |
|---|---|
| No Redis | CACHE_STORE=file, SESSION_DRIVER=database |
| No queue workers | QUEUE_CONNECTION=sync. Anything slow is either done inline with progress UI, or chunked across cron ticks |
| One cron entry | * * * * * php artisan schedule:run drives scheduled publishing, cache warming, backup chunks, cleanup |
| No Node on the server | Vite output is committed; npm is never run on the server |
Low memory, short max_execution_time |
Image processing, backups and cache rebuilds are chunked and resumable |
| No root | No system packages and no php.ini edits assumed. .user.ini is ignored by mod_lsapi on some hosts, so never depend on it |
Image processing note. imagick is not guaranteed on shared hosting. The image pipeline detects
the available driver at install time (imagick, else gd) and records it in config/kodepress.php.
WebP conversion needs GD compiled with WebP support (PHP 8.3 normally has it); if it is missing the
installer warns and the pipeline stores originals plus resized JPEG/PNG variants instead.
2.3 Request lifecycle — public page#
Visitor GET /about
|
v
(web middleware: session, CSRF on writes, locale resolution)
|
v
ResolveSite -> site by host, else the single default site
|
v
SetLocale -> /bn/.. , /en/.. , or the site default (see doc 12)
|
v
PageController@show (catch-all route, registered LAST)
|
+-- HtmlCache::get(site, locale, path) --> HIT --> return cached HTML (200)
|
+-- MISS
|-- Redirects::match(path) -> 301/302 if a rule matches
|-- Page::publishedByPath(site, path) -> 404 view if none
|-- load pages.current_version_id -> page_versions.content (the block tree)
|-- PartResolver: header + footer for this page (see doc 08)
|-- PageRenderer::render(tree) -> Blade component per block (see doc 04)
|-- ThemeCss::url() -> token-generated stylesheet (see doc 09)
|-- HtmlCache::put(...) -> write the file
+-- return HTML (200)
Key properties:
- The catch-all route is registered after all admin and system routes.
- Cache lookup happens before any model query. A cached page costs one file read.
- On a cache hit nothing writes to the database, so the site survives traffic spikes on cheap hosting. View counters, if ever added, batch through cron — never per request.
2.4 Request lifecycle — publish#
Editor clicks Publish
|
v
PublishPage action (one transaction)
|-- validate the tree against BlockRegistry schemas
|-- sanitise every richtext / html field value
|-- create a page_versions row (content snapshot, author, label)
|-- pages.current_version_id = the new version
|-- pages.status = published | scheduled, published_at set
|-- slug changed? -> insert a 301 in `redirects` from the old path
+-- audit_logs row
|
v (after commit)
PageWasPublished event
|-- HtmlCache::forgetPage(page) -> clear this page, every locale
|-- HtmlCache::warm(page) -> re-render immediately (configurable)
+-- Sitemap::markDirty()
Draft autosave is not a publish: it writes pages.draft_content and never touches the cache or
the public site.
2.5 Rendering model#
Three renderers share one tree format:
| Renderer | Input | Output | Used by |
|---|---|---|---|
PageRenderer |
page_versions.content |
full page HTML | public pages, cache warm |
PartRenderer |
template_parts.content |
header / footer HTML | wrapper around page HTML |
PanelRenderer |
menu_items.mega |
mega-menu panel HTML | menu rendering |
All three walk the same sections -> columns -> blocks structure and resolve each block through
BlockRegistry to app/Blocks/<Name>/view.blade.php. There is exactly one code path that turns a
block into HTML; the renderers differ only in the wrapper they emit.
Grid: a section renders a 12-column grid. column.width is the desktop span; tablet and mobile
spans come from section settings, defaulting to stacking below 768 px. Columns never use absolute
positioning — that is what keeps the output responsive by construction.
2.6 Folder layout#
app/
├── Actions/ single-purpose writes (PublishPage, DuplicatePage, RestoreVersion, ...)
├── Blocks/ ONE FOLDER PER BLOCK — the extension point
│ ├── Heading/{schema.php, view.blade.php, preview.png}
│ ├── Text/ Image/ Button/ Video/ Gallery/ Form/ PostList/ Accordion/ Html/
│ ├── MenuSlot/ Logo/ Search/ LanguageSwitcher/ SocialIcons/
│ └── AnnouncementBar/ Newsletter/ BackToTop/
├── Console/Commands/ kodepress:install, kodepress:cache-warm, kodepress:backup, cms:* aliases
├── Http/
│ ├── Controllers/ thin: Public\PageController, Public\FeedController, Admin\*
│ ├── Middleware/ ResolveSite, SetLocale, AdminArea, EnsureInstalled
│ └── Requests/ all validation
├── Livewire/
│ ├── Editor/ PageEditor, OutlineTree, SettingsPanel, SectionGallery, MediaPicker
│ ├── Menus/ MenuBuilder, MegaPanelEditor
│ ├── Design/ ThemeSettings, PartEditor, TemplateGallery
│ └── Settings/ SiteInfo, Users, Redirects, Backup, Forms
├── Models/ one per table in doc 03
├── Policies/ PagePolicy, MenuPolicy, TemplatePartPolicy, ThemePolicy, FormPolicy, ...
├── Services/
│ ├── Blocks/ BlockRegistry, SchemaValidator, FieldTypes/
│ ├── Rendering/ PageRenderer, PartRenderer, PanelRenderer, PartResolver
│ ├── Cache/ HtmlCache, CacheInvalidator
│ ├── Media/ ImagePipeline, MediaStorage
│ ├── Seo/ SitemapBuilder, RedirectMatcher, MetaBuilder
│ ├── Theme/ TokenCompiler
│ └── Backup/ BackupRunner, RestoreRunner
└── Support/ helpers: fmt_date(), Sanitizer, Slug, Tree
config/kodepress.php block paths, cache dir, image driver, locales, admin path, limits
database/migrations/ exactly the tables in doc 03
database/seeders/ RoleSeeder, ThemeSeeder, TemplateSeeder, DemoContentSeeder
lang/{bn,en}/ admin.php, blocks.php, validation.php, public.php, emails.php
resources/
├── views/
│ ├── admin/ layout + screens (Livewire renders inside)
│ ├── public/ layout, page shell, 404, feed templates
│ ├── components/ block wrappers: section, column, block
│ └── parts/ header / footer presets
├── css/ js/ Vite sources (app, editor, admin)
└── fonts/ Noto Sans Bengali, Inter (self-hosted)
public/build/ COMMITTED Vite output
storage/app/kodepress-cache/ rendered public HTML
storage/app/public/media/ uploads (symlinked to public/storage)
docs/ this documentation
tests/Feature/ tests/Unit/ Pest
2.7 Data boundaries#
- Every content table carries
site_idfrom the first migration, even though Phase 0–4 ship a single site. A globalSiteScopeapplies to all of them, so Phase 5 turns on multi-site without a schema migration. See ADR-0003. page_versions.contentis the single source of truth for published content;pages.draft_contentholds unpublished work. Nothing else stores a block tree for a page.- Media files live on the
publicdisk; themediatable is metadata only. Deleting a row deletes the file and its variants in the same action. - One service owns each side effect: only
HtmlCachewrites the HTML cache, onlyImagePipelinewrites image variants, onlyPublishPagecreates versions. Call the owner; never reimplement it.
2.8 What is stored as JSON, and why#
JSON is used where the shape is user-defined and never queried relationally:
| Column | Holds | Why JSON |
|---|---|---|
page_versions.content, pages.draft_content |
the block tree | arbitrary user structure; read whole, written whole |
template_parts.content |
header / footer tree | same |
menu_items.mega |
mega-panel tree | same |
menu_items.visibility, menu_items.settings |
per-item rules and styling | sparse and evolving |
template_parts.conditions |
assignment rules | evaluated in PHP, never in SQL |
pages.seo |
title, description, OG image, canonical, noindex | sparse metadata |
theme_settings.tokens |
design tokens | one row per site, compiled to CSS |
forms.fields |
form field definitions | user-defined |
media.conversions |
generated variant paths | derived data |
Anything that must be filtered, sorted or joined — status, slug, dates, taxonomy, menu location — is a real column with a real index.
2.9 Service responsibilities at a glance#
| Service | Owns | Never does |
|---|---|---|
BlockRegistry |
discovering blocks, caching the manifest, exposing schemas | render HTML |
SchemaValidator |
validating a tree against schemas, coercing defaults | sanitise HTML |
Sanitizer |
HTML allow-listing for richtext and the HTML block | decide permissions |
PageRenderer |
tree -> HTML | read the cache or write it |
HtmlCache |
read, write, forget, warm cached HTML | decide when to invalidate |
CacheInvalidator |
mapping a model change to what must be cleared | render |
PartResolver |
picking the header/footer for a given page | render |
TokenCompiler |
tokens -> one CSS file, with a content hash in the name | know about pages |
ImagePipeline |
compression, WebP, variants, metadata | authorise uploads |
SitemapBuilder |
sitemap.xml and robots.txt | handle redirects |
RedirectMatcher |
matching a path to a redirect rule | create rules (actions do) |
KodePress documentation · generated from the Markdown sources by
tools/build-docs-site.py · internal preview, not indexed.