webx-ui / module-blocks
The block constructor for the WebX UI admin panel: block types made entirely in the panel — fields, Blade, CSS, script — and the renderer that prints an entity's content from them.
Requires
- php: ^8.3
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/filesystem: ^13.0
- illuminate/http: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- illuminate/validation: ^13.0
- illuminate/view: ^13.0
- webx-ui/localization: ^0.18.0
- webx-ui/mcp: ^0.18.0
- webx-ui/module-admin: ^0.18.0
- webx-ui/module-auth: ^0.18.0
- webx-ui/routing: ^0.18.0
Requires (Dev)
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Access to this section is access to deployment. A block type is Blade, and Blade is PHP:
whoever can save a block can run anything the application can. Give the permission to the people
you would give a shell to, and turn webx-blocks.editing off on a site whose types arrive by
import.
The block constructor for the WebX UI admin panel. A block type is made entirely in the panel — its fields, its Blade template, its styles and its script — and stored in the database; an entity's content is a tree of such blocks, and this package prints it. Pages, articles and products add one trait and know nothing else about blocks.
Status: the rendering half, the bundles of styles and scripts, the preview and the panel's API
(this README). The MCP tools follow; the plan is
docs/architecture/WEBX_UI_MODULE_BLOCKS.md.
Requirements
- PHP 8.3+
- Laravel 13
Install
composer require webx-ui/module-blocks php artisan migrate
Three tables: blocks (the type), block_versions (one immutable snapshot per save) and
block_bundles (the glued CSS and JS of every set of types that has appeared on a page together).
An entity with blocks
Schema::table('pages', function (Blueprint $table) { $table->blocks(); // `blocks` json — the tree the site prints $table->draft(); // `draft` json and `published_at`, from webx-ui/module-admin }); class Page extends Model { use HasBlocks, HasDraft, HasVersions; } $page->blocks; // [{ key, type, values }, …] $page->renderBlocks(); // HtmlString $page->blockTypes(); // ['hero', 'section', 'text'] — nested ones included
Or, from anywhere a site renders content of its own:
{!! Blocks::render($blocks, $page) !!}
A node is { key, type, values }: key identifies the instance and survives a drag, type is a
block's slug, values are keyed by the ids of the block's fields. A container block holds other
blocks in one of its values — a list of nodes of the same shape.
A block type
$block = Block::create(['slug' => 'hero', 'title' => 'Hero', 'group' => 'content']); $block->saveVersion([ 'schema' => [['id' => 'title', 'type' => 'wx-input', 'label' => 'Title']], 'template' => '<section data-wx-block="hero" class="b-hero"><h1>{{ $title }}</h1></section>', 'styles' => '.b-hero { padding: var(--wx-space-32) }', 'sample' => ['title' => 'Welcome'], ]); $block->publish();
Every saveVersion() writes a numbered snapshot and points the draft at it; only what changed has
to be sent. publish() renders the version on its sample values first and refuses, with the
template's line, when that throws — one typo would otherwise take down every page the block stands
on. The published version is what the site prints; a draft on top of it changes nothing until it
is published too.
Inside the template:
- the schema's fields are variables —
$title— filled from the values andnullwhere the content has nothing, so a field added later does not break the pages written before it. A variable the schema does not declare is what the publish check refuses; - a value is what the field type makes of it, not the raw row:
wx-mediastores{ path, alt, title }and the template reads$image['url']as well, worked out when the block is printed. A type nobody registered —wx-blocksabove all — arrives as it is; $block—key,type,version, andvalue('project-name')for a field whose id is not a variable name;$entity— the model being rendered, ornullwhen there is none (the sample check, a controller rendering blocks on their own): write$entity?->title;@blocks('content')prints the blocks held in that field, one level deeper, up towebx-blocks.max_depthlevels.
Put data-wx-block="{slug}" on the root element: the script runtime and the panel find the
block by it.
What happens when a block fails
Every block renders inside its own try/catch. On the live site a failure goes to the exception handler and the block is left out, the rest of the page intact. In the preview it is a notice in the block's place with the message and the template's line. A block whose type is no longer published is left out and noted in the log.
Styles and scripts
<head> @webxBlocks {{-- the stylesheet and the script of every block the page rendered --}} </head>
The types on a page and their versions, in sort then slug order, make a hash; the hash names
a row of block_bundles with the glued CSS and JS, written the first time that set is seen and
served by /blocks/{hash}.css and /blocks/{hash}.js with a year-long immutable cache. A page
pulls only what stands on it, pages with the same set share the file, and publishing a version
changes the hash only where the block stands. A page without blocks prints nothing.
The directive is evaluated where it stands — with @extends and with components the content
renders before the layout, so the head knows what is below it. @webxBlocks('styles') and
@webxBlocks('scripts') split the two tags between the head and the end of the body; a set that
weighs no more than webx-blocks.bundles.inline_below bytes is printed inline instead.
A block's script is the body of an initialiser, run once per instance:
// the script field of the block "hero" const Swiper = await webx.use('swiper') // whatever the site's own bundle provided new Swiper(el, values.options)
The runtime rides in the bundle. It finds every [data-wx-block="hero"] and calls the function
with the element and the values it carries in data-wx-values (JSON, {} when absent —
data-wx-values="{{ json_encode($block->values) }}" in the template); webx.mount(root) picks up
what is new inside a subtree, which is what the panel calls after replacing a block. The bridge
to the site's build is webx.provide('swiper', Swiper) on the site's side and await webx.use('swiper') in the block: use waits, so the order the two files load in does not
matter — but webx has to exist when the site calls provide. A site that needs it before its
own entry prints @webxBlocks('runtime') in the head first; the copy inside the bundle then
steps aside.
Housekeeping: webx:blocks:bundles --prune drops the bundles glued from versions no longer
published, --warm writes the bundle of every entity of the models listed in
webx-blocks.entities ahead of the first visitor, and webx:blocks:clear forgets the cached
types and the compiled templates.
The registry
BlockTypes::all(); // enabled types with a published version, in sort order — the picker BlockTypes::find('hero'); // the published version, disabled or not — what the site renders BlockTypes::draft('hero'); // the draft, or the published one when there is none — the preview
Cached (webx-blocks.cache) and forgotten whenever a block or a version is saved, published or
deleted.
Preview
Preview::url($page, adminId: 7); // https://example.test/_preview/page/12?token=…
A signed link, good for an hour (webx-blocks.preview.ttl), that shows the draft of an entity
as the page it will be. The route does what the address registry would do for the real address
— find the type, load the entity, hand both to the type's handler with a Resolution — and the
handler answers with the same view it answers the site with. What differs: the entity carries
its draft over its columns (withDraft()), the block types render at their drafts, every block
is wrapped in the marker comments the panel finds it by, and the response is no-store and
noindex. The token opens one entity and nothing else; a bad or expired one is a 403.
A handler tells a preview from a visit with PreviewGrant::of($request), and that is where an
unpublished entity is a 404 to everybody else. The prefix is closed to the registry, so no page
can take the address.
The panel
The section lives in @webx-ui/module-blocks on the front end; this package answers it under
{api_path}/blocks — the types, the catalogue the constructor reads, a version per save, publish
with the check on the sample and on every page the block stands on, render on sent values, usage,
history and restore. Permissions blocks.view and blocks.manage; webx-blocks.editing off makes
every write a 403 whatever the permission says. webx-blocks.provides lists what the site's bundle
hands to blocks through webx.provide(), for the editor and for an agent.
Export and import
php artisan webx:blocks:export # resources/blocks/{slug}.json, one per published type php artisan webx:blocks:export hero --draft php artisan webx:blocks:import # back in, as drafts; a version only where content differs php artisan webx:blocks:import --publish # …and publish what passes the checks php artisan webx:blocks:import --dry-run
A block does not travel through git on its own; the files do. Each is the row's settings and one
version's content, flat and pretty-printed for a diff. Import checks a file by the rules the panel
checks a save with, writes a version only when the content differs from the one being edited, and
with --publish runs the publish checks — a type that fails stays a draft and the exit code says so.
MCP
With webx-ui/mcp — it comes with this package — the section is also a set of tools
for an agent: blocks_list, blocks_get, blocks_create, blocks_update, blocks_publish,
blocks_render, blocks_get_content, blocks_set_content, blocks_preview_url. The same doors
the panel uses, with mcp as the source in the history; every tool that changes something takes
dry_run: true. Scopes blocks:read and blocks:write are the abilities of the token
php artisan webx:mcp:token issues. Before writing, an agent reads blocks://guidelines,
blocks://catalog, blocks://fields and blocks://site; the prompt design_block packages the
loop of create, render, fix, report.
Config
php artisan vendor:publish --tag=webx-blocks-config — groups, editing, nesting depth, where the
compiled templates go, cache, the bundles' path prefix and inline threshold, the entities to warm,
the preview's path, lifetime and middleware.
License
MIT.