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

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:

  1. 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 resolver class and the renderer calls it before rendering, so the view stays a template.
  2. It never echoes raw user HTML except through {!! $data['html'] !!} on a field whose type is richtext or html — those are sanitised at save time, and html is Admin-only.
  3. It outputs classes from the kp- namespace and token-driven utilities. No inline colours.
  4. 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#

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:

  1. Scan every path in config('kodepress.block_paths') — by default app/Blocks, plus one path per enabled plugin in Phase 5.
  2. Each immediate sub-directory with a schema.php is a block.
  3. 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.
  4. Cache the manifest (kodepress.blocks.manifest) in the file cache. In APP_DEBUG=true the scan runs every request; in production it is cached until php artisan kodepress:blocks-refresh, kodepress:install or 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#

  1. mkdir app/Blocks/Testimonial
  2. Write schema.php (type testimonial, fields: quote richtext, author text, role text, photo image, style select).
  3. Write view.blade.php using kp- classes and tokens.
  4. Add preview.png (320x200).
  5. Add translation keys blocks.testimonial.* to lang/bn/blocks.php and lang/en/blocks.php.
  6. php artisan kodepress:blocks-refresh
  7. The block appears in the palette. No admin code was touched.
  8. 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:

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.