04 — Block system#
Everything visible on a KodePress site is a block tree. This document defines the tree format, the block folder contract, the field types and the rules the renderer and editor both obey.
4.1 The content tree#
{
"version": 1,
"sections": [
{
"id": "s_7hq2",
"name": "Hero",
"settings": {
"background": "light",
"background_media_id": null,
"padding": "large",
"width": "container",
"gap": "md",
"vertical_align": "center",
"mobile_stack": true,
"anchor": "hero",
"css_class": "",
"hidden_on": []
},
"columns": [
{
"id": "c_1a",
"width": 7,
"width_tablet": 12,
"settings": { "vertical_align": "center", "padding": "none" },
"blocks": [
{ "id": "b_k92", "type": "heading", "data": { "text": "Welcome", "level": 1, "align": "left" } },
{ "id": "b_k93", "type": "text", "data": { "html": "<p>Short intro.</p>" } },
{ "id": "b_k94", "type": "button", "data": { "label": "Contact us", "link": { "type": "page", "page_id": 4 }, "style": "primary" } }
]
},
{
"id": "c_1b",
"width": 5,
"blocks": [
{ "id": "b_k95", "type": "image", "data": { "media_id": 12, "alt": "Our team", "rounded": true } }
]
}
]
}
]
}
Invariants#
| Rule | Enforced by |
|---|---|
version is an integer; the current format is 1 |
SchemaValidator |
sections is an array; an empty array is legal (an empty page) |
validator |
Every id is unique within the document, 2–32 chars of [a-z0-9_] |
validator, editor generates them |
| A section has 1–6 columns | validator, editor caps the UI |
| Column widths in a section sum to 12 | validator; the editor rebalances on add/remove |
blocks may be empty (a spacer column) |
validator |
A block has id, type, data; nothing else |
validator strips unknown keys |
type must exist in BlockRegistry; unknown types render nothing publicly and show a warning card in the editor |
renderer + editor |
| No nesting of sections inside blocks | validator (the global block is the only exception, and it inlines at render time, never at save time) |
Trees never contain URLs for media — only media_id |
field type coercion |
Why one JSON document#
A page is always read whole and written whole. Storing sections and blocks as rows would add joins
to the hot path, make version snapshots expensive, and make undo/redo a transactional problem. One
JSON document makes a version an exact snapshot, makes autosave one UPDATE, and keeps the editor
state and the stored state identical. The cost — you cannot query "all pages using block X" in SQL —
is paid with a small usage index rebuilt on publish (see 4.8).
4.2 Section settings reference#
| Key | Type | Values / default | Effect |
|---|---|---|---|
background |
select | none (default), light, dark, primary, muted, image, gradient |
token-based background |
background_media_id |
image | null | used when background: image |
overlay |
number | 0–100, default 0 | dark overlay percentage over a background image |
padding |
select | none, small, medium (default), large, xlarge |
vertical padding from spacing tokens |
width |
select | container (default), wide, full |
max width |
gap |
select | none, sm, md (default), lg |
gap between columns |
vertical_align |
select | top (default), center, bottom |
column alignment |
mobile_stack |
toggle | true | stack columns below the mobile breakpoint |
mobile_reverse |
toggle | false | reverse column order when stacked |
anchor |
text | null | id attribute, used by in-page anchor menu items |
css_class |
text | null | extra classes (Advanced tab) |
hidden_on |
multi | [], any of mobile, tablet, desktop |
responsive visibility |
is_global |
toggle | false | set when the section is a global block reference |
global_block_id |
number | null | which global block to inline |
Column settings: vertical_align, padding, background, css_class, hidden_on, plus
width_tablet and width_mobile (default 12).
4.3 The block folder contract#
app/Blocks/Heading/
├── schema.php required — definition and fields
├── view.blade.php required — public output
├── preview.png required — 320x200, shown in the block picker
├── editor.blade.php optional — custom editor UI, only if generated fields cannot express it
└── block.css optional — appended to the theme stylesheet when the block is used
schema.php returns an array:
<?php
// Heading block: one line of text at a chosen size.
// Everything the admin form shows comes from the "fields" list below.
return [
'type' => 'heading', // unique slug, snake_case, matches the data.type value
'name' => 'blocks.heading.name', // translation key
'description' => 'blocks.heading.description', // translation key
'icon' => 'heading', // icon name from the admin icon set
'category' => 'basic', // basic | media | content | layout | navigation | form | advanced
'position' => 10, // order inside the category
'contexts' => ['page', 'part', 'mega'], // where the block may be used
'roles' => null, // null = everyone; ['admin'] restricts it (HTML block uses this)
'fields' => [
[
'key' => 'text',
'type' => 'text',
'label' => 'blocks.heading.text',
'default' => 'Your heading',
'rules' => ['required', 'string', 'max:255'],
'tab' => 'content', // content | style | advanced
],
[
'key' => 'level',
'type' => 'select',
'label' => 'blocks.heading.level',
'options' => [1 => 'H1', 2 => 'H2', 3 => 'H3', 4 => 'H4'],
'default' => 2,
'rules' => ['required', 'integer', 'between:1,4'],
'tab' => 'content',
],
[
'key' => 'align',
'type' => 'select',
'label' => 'blocks.heading.align',
'options' => ['left' => 'Left', 'center' => 'Center', 'right' => 'Right'],
'default' => 'left',
'tab' => 'style',
],
],
];
view.blade.php receives exactly three variables and nothing else:
{{-- $data: validated block data. $block: id and type. $ctx: site, locale, page, device --}}
<h{{ $data['level'] }} class="kp-heading kp-align-{{ $data['align'] }}">
{{ $data['text'] }}
</h{{ $data['level'] }}>
Contract rules for a block view:
- It never queries the database directly for content it was not given. If a block needs data (Post
List, Menu Slot), the schema declares a
resolverclass and the renderer calls it before rendering, so the view stays a template. - It never echoes raw user HTML except through
{!! $data['html'] !!}on a field whose type isrichtextorhtml— those are sanitised at save time, andhtmlis Admin-only. - It outputs classes from the
kp-namespace and token-driven utilities. No inline colours. - It must render acceptably with all-default data, because the block picker inserts defaults.
Resolvers (blocks that need data)#
'resolver' => \App\Blocks\PostList\PostListResolver::class,
A resolver implements resolve(array $data, RenderContext $ctx): array and returns extra view
variables ($resolved). Resolvers run before caching, so the resolved values are baked into the
cached HTML. A block whose output must change per visitor (for example a logged-in account menu)
declares 'cacheable' => false, which makes the whole page dynamic — use it sparingly; the Login
block and the Search block with live results are the only built-ins that do.
4.4 Field types#
| Type | Stores | Editor control | Validation defaults |
|---|---|---|---|
text |
string | single-line input | string, max:255 |
textarea |
string | multi-line input | string, max:5000 |
richtext |
sanitised HTML string | TipTap editor (bold, italic, link, lists, H2–H4) | sanitised allow-list |
image |
{ "media_id": int, "alt": string } |
media picker + alt prompt | media_id exists in media |
gallery |
[{ "media_id": int, "alt": string }] |
multi-select media picker, drag to reorder | each id exists, max 60 |
link |
{ "type": "page\|post\|category\|url\|anchor\|phone\|email", ... } |
link picker | target exists or URL is valid |
color |
token name or hex | token swatches first, custom under Advanced | in token list, or /^#[0-9a-f]{6}$/i |
select |
scalar | dropdown or segmented control | in: the option keys |
toggle |
bool | switch | boolean |
number |
int or float | number input with min/max/step | numeric, plus declared bounds |
repeater |
array of sub-field groups | add/remove/reorder rows | each row validated against fields, max rows |
html |
raw HTML string | code textarea, Admin only | stored verbatim, escaped nowhere — gated by role |
Shared field keys: key, type, label, help, default, rules, tab, placeholder,
condition (show this field only when another field has a value, e.g.
'condition' => ['background', '=', 'image']), and for repeater: fields, min, max,
row_label.
The link field value shapes:
{ "type": "page", "page_id": 12 }
{ "type": "post", "page_id": 44 }
{ "type": "category", "category_id": 3 }
{ "type": "url", "url": "https://example.com", "new_tab": true, "nofollow": false }
{ "type": "anchor", "anchor": "pricing" }
{ "type": "phone", "value": "+8801700000000" }
{ "type": "email", "value": "hello@example.com" }
A page/post/category link is resolved to a URL at render time, so renaming a page never
breaks a button. If the target is gone, the link renders as plain text and the page is listed in
Settings -> SEO -> Broken links.
4.5 Built-in blocks#
| # | Type | Category | Contexts | Notes |
|---|---|---|---|---|
| 1 | heading |
basic | page, part, mega | H1–H4, align |
| 2 | text |
basic | page, part, mega | richtext |
| 3 | image |
media | page, part, mega | media_id, alt, caption, link, rounded, ratio |
| 4 | button |
basic | page, part, mega | label, link, style (primary/secondary/outline/text), size, icon, full-width |
| 5 | video |
media | page, mega | YouTube/Vimeo/self-hosted; lazy iframe facade, no autoplay with sound |
| 6 | gallery |
media | page, mega | grid/masonry/carousel, columns, lightbox |
| 7 | form |
form | page, part | picks a forms row; stub in Phase 1, real in Phase 4 |
| 8 | post_list |
content | page, mega | resolver; filters by category/tag/author, grid or list, pagination |
| 9 | accordion |
content | page, mega | repeater of title + richtext, single or multi open |
| 10 | html |
advanced | page, part | Admin only, raw HTML |
| 11 | menu_slot |
navigation | part, mega | renders a chosen menu; the heart of the header |
| 12 | logo |
navigation | part | light/dark/mobile variants, height, link to home |
| 13 | search |
navigation | part, mega | icon, inline or modal; cacheable: false when live results are on |
| 14 | language_switcher |
navigation | part, mega | flags or codes, dropdown or inline |
| 15 | social_icons |
navigation | part, page | reads site options, or an override repeater |
| 16 | announcement_bar |
layout | part | dismissible (localStorage), scheduled start/end, link |
| 17 | newsletter |
form | part, page | wraps a form with a single email field |
| 18 | back_to_top |
layout | part | position, offset, icon |
| 19 | spacer |
layout | page, part, mega | height per device |
| 20 | divider |
layout | page, part, mega | style, width, colour token |
| 21 | icon_list |
content | page, mega | repeater: icon + label + link — the mega-menu workhorse |
| 22 | global |
advanced | page | references a global_blocks row; inlined at render |
Blocks 19–22 are additions to the original list, justified in ADR-0004. Nothing else may be added to core without an ADR — everything else is a block pack.
Context rules#
page— pages, posts, landing pages, templatespart— headers, footers, top bars, announcement barsmega— mega-menu panels
The editor only offers blocks whose contexts include the current context. A tree containing an
out-of-context block is rejected by the validator, so an imported template cannot smuggle a
menu_slot into a blog post.
4.6 BlockRegistry#
interface BlockRegistry
{
public function all(): array; // type => definition
public function get(string $type): ?array;
public function forContext(string $ctx): array; // filtered by contexts + current user roles
public function viewPath(string $type): string;
public function flush(): void; // after adding a block folder
}
Discovery:
- Scan every path in
config('kodepress.block_paths')— by defaultapp/Blocks, plus one path per enabled plugin in Phase 5. - Each immediate sub-directory with a
schema.phpis a block. - Validate the definition (required keys, unique
type, field keys unique, referenced resolver class exists). An invalid block is skipped and logged — it never breaks the editor. - Cache the manifest (
kodepress.blocks.manifest) in the file cache. InAPP_DEBUG=truethe scan runs every request; in production it is cached untilphp artisan kodepress:blocks-refresh,kodepress:installor a deploy cache clear.
The admin editor never names a block type. Palette, forms, icons and categories are all driven by the manifest. This is what makes "add a block = add a folder" true, and it is asserted by a test that adds a temporary block folder at runtime and checks it appears in the palette.
4.7 Validation and sanitisation pipeline#
Every save (autosave, publish, template import, plugin-provided content) goes through the same pipeline:
raw tree
-> structure check (version, ids, section/column/block shape, width sum)
-> type check (each block type exists and is allowed in this context)
-> field coercion (apply defaults, drop unknown field keys, cast types)
-> field validation (rules from schema.php, through Laravel's validator)
-> sanitisation (richtext allow-list; html passes through only for Admin)
-> stored
The richtext allow-list: p, br, strong, em, u, s, a[href,title,target,rel], ul, ol, li, h2, h3, h4,
blockquote, code, pre, img[src,alt,width,height], figure, figcaption, table, thead, tbody, tr, th,
td, span[class], hr. Everything else is stripped. href values must be http, https, mailto,
tel or a root-relative path. on* attributes, <script>, <style>, <iframe> and
javascript: URLs are removed unconditionally — including inside the HTML block for non-Admins,
who cannot use it at all.
Autosave runs the same validation but tolerates failures: an invalid draft is still saved (so the
user never loses work) with a draft_invalid flag, and Publish is blocked with a message naming the
block and field at fault.
4.8 Usage tracking#
On publish, the tree is walked once and the results are written to the file cache (not the database):
| Index | Used for |
|---|---|
page -> media_ids |
"this image is used on 3 pages" before deleting a media file |
page -> menu_ids |
nothing in core (menu changes clear all pages), but plugins can narrow it |
page -> global_block_ids |
clearing only the affected pages when a global block changes |
page -> form_ids |
warning before deleting a form that is in use |
block type -> page count |
the Dashboard block-usage panel, and a safety check before removing a plugin |
The indexes are derived data. They can be rebuilt at any time with
php artisan kodepress:reindex-usage, and a missing index degrades to "clear everything", never to
a wrong answer.
4.9 Adding a block — the whole procedure#
mkdir app/Blocks/Testimonial- Write
schema.php(typetestimonial, fields:quoterichtext,authortext,roletext,photoimage,styleselect). - Write
view.blade.phpusingkp-classes and tokens. - Add
preview.png(320x200). - Add translation keys
blocks.testimonial.*tolang/bn/blocks.phpandlang/en/blocks.php. php artisan kodepress:blocks-refresh- The block appears in the palette. No admin code was touched.
- Add a Pest test: insert a page containing the block, publish, assert the public HTML contains the quote.
4.10 Format changes#
The tree format is versioned by content.version. A format change requires:
- an ADR describing the change,
- a bump of the
versionconstant, - an upcaster in
App\Services\Blocks\Upcasters\VersionNToN1applied on read, so oldpage_versionsrows (which are never rewritten) keep rendering, - a test that renders a stored v1 document after the bump.
Old versions are never migrated in place. A restored five-year-old version must still render.
KodePress documentation · generated from the Markdown sources by
tools/build-docs-site.py · internal preview, not indexed.