Skip to main content

Documentation

No results found.
Features

Code blocks: HTML, PHP, and Livewire

WebProCMS ships three "code" escape-hatch blocks in the page builder for editors who need to drop in something the visual blocks don't cover: an HTML block (paste raw markup, embeds, Alpine, <script>), a PHP block (run serve...

WebProCMS ships three "code" escape-hatch blocks in the page builder for editors who need to drop in something the visual blocks don't cover: an HTML block (paste raw markup, embeds, Alpine, <script>), a PHP block (run server-side PHP once at render), and a Livewire block (author a full, stateful, interactive Livewire component inline). All three live in the "Add item" picker inside any row and are admin-gated where they execute code.


The problem

A visual page builder covers the common cases — headings, images, grids, forms — but there is always a long tail: a third-party embed, a custom calculation, a small interactive widget. Without an escape hatch the editor is stuck filing a developer ticket for every one of those. With the wrong escape hatch (a single "raw code" box) you either can't run server logic, can't keep client state, or you open a security hole. The right answer is three blocks split by execution lifecycle, each with exactly the plumbing its lifecycle needs.

The three blocks

The blocks are not three flavours of the same thing — they sit at three different points in the request lifecycle, and that's what decides which one to reach for.

Block Runs Keeps state? Caching Gate
HTML In the browser (raw markup → DOM) Client-side only (Alpine/JS) Cached body is fine — same for everyone everyone
PHP On the server, once at render No — output is static Output is cacheable as-is admin
Livewire On the server, interactive across round-trips Yes — full component state Coexists with caching (page's own setting is respected) — Direct bakes one shared initial render, Island hydrates per-visitor admin
  • HTML owns everything client-side. Alpine (x-data) and <script> tags are already HTML, so there is no separate "Alpine block" or "JS block" — paste them into the HTML block and they run. Use it for embeds, iframes, custom markup, and client-only interactivity.
  • PHP runs server-side PHP once when the page renders and prints the result. It's the right tool for a one-off computed value or a snippet of server logic whose output is then static. It does not keep state and exposes no interactive surface.
  • Livewire is the stateful sibling of the PHP block. The editor authors a complete Livewire single-file component (state + methods + markup) inline, and it mounts on the live page with full server-driven interactivity — wire:click, wire:model, validation, the lot.

All three blocks share the same source mode dropdown: Inline (author a one-off) or From snippet (pick a reusable page-builder snippet of the matching type — HTML block → HTML snippets, PHP block → PHP snippets, Livewire block → Livewire snippets). Unlike the Livewire block, the HTML and PHP blocks render a chosen snippet's content directly (no materialised file, no cache opt-out) — the snippet content is resolved at render, and because these blocks don't disable caching, that read only happens at cache-build time.

Consent gating for HTML/PHP snippet blocks. On the live render the chosen snippet's output is passed through CookieConsent::gateSnippet() using the snippet's consent_category, so a tracker dropped into a page-builder HTML/PHP block is consent-gated exactly like an injected snippet (it no-ops for the necessary category or when Cookie Consent is off, and is skipped in the editor preview so authors still see the content). The snippet edit/create form therefore shows the Consent dropdown for HTML/JS/PHP. Livewire snippets carry no consent category — gating an interactive component via the inert-<template> clone would break its hydration, and a Livewire component isn't a tracker — so the Consent field is hidden entirely when the type is Livewire.

The HTML block

x-dl.html echoes the saved markup verbatim ({!! $rawHtml !!}). Because it is a raw echo (not compiled Blade), pasted Blade/<livewire:> tags are not executed — it is purely a client-side surface. It supports a wrapper class, element ID, and custom attributes like every other block. When empty it shows a "paste HTML here" placeholder inside the editor/preview only. In From snippet mode it renders the selected HTML snippet's content.

The PHP block

x-dl.php evaluates the saved source with ShortcodeProcessor::evaluatePhpCode() (output-buffered eval) and prints the captured output. It runs once per render; the result is just HTML, so it caches like any other content. Admin-gated, because it runs arbitrary server code. In From snippet mode it evaluates the selected PHP snippet's content.

The Livewire block

The Livewire block lets an admin write a real Livewire component in the page builder — the same single-file syntax the CMS uses for its own pages:

<?php new class extends \Livewire\Component {
    public int $count = 0;
    public function increment() { $this->count++; }
}; ?>
<div>
    <button wire:click="increment">Clicked {{ $count }} times</button>
</div>

Save the page and the counter is live on the site — clicking the button round-trips to the server and re-renders, with state preserved, no developer involvement.

The block has two source modes (a dropdown in the editor sidebar):

  • Inline — author a one-off component right in the block (the example above).
  • From snippet — pick a reusable Livewire snippet from a dropdown. The same component can then be dropped onto many pages, and editing the snippet updates every placement at once.

Reusable Livewire snippets

A Livewire component can be saved once and reused everywhere via the Snippets system (Dashboard → Snippets). This adds two things to that system:

  • A Livewire snippet type — the snippet content is a Livewire single-file component.
  • A Page builder snippet placement — a general placement meaning "not auto-injected into pages; selectable inside a page-builder code block." (Direct Livewire snippets always use it; HTML/PHP snippets may opt into it too; an island Livewire snippet may instead choose body_end to be injected site-wide — see Render mode.) Because the injection pipeline only emits the head / scripts / php-top placements (plus island Livewire at body_end), a page-builder snippet renders nowhere until a block selects it.

Every install ships an example "Counter (example)" Livewire snippet so the feature is usable out of the box.

Snippet-backed components materialise to one shared file per snippet (inline.snippet-{id}), re-written whenever the snippet is saved; each block placement still mounts its own instance, so per-placement state stays isolated.

Why a real on-disk file (the core design decision)

Livewire's compiler is file-path bound, and an anonymous new class gets a different generated class name on every request — so compiling the stored source as a string at request time breaks Livewire's snapshot checksum and the component fails to hydrate on the first interaction.

The fix is to materialise the authored source to a real on-disk Livewire single-file component at save time — the same "compile content to a file" pattern the CMS already uses for page sidecars, baked class strings, and design-library previews. InlineLivewireSyncer writes each block's source to resources/views/livewire/inline/{hash}.blade.php, where hash = sha1(rowSlug:prefix). That directory is in Livewire's component_locations, so the file resolves by name (inline.{hash}) with no manual registration. Because the file path is stable, the compiled class is stable across requests, and hydration "just works."

The block's render surface, x-dl.livewire, derives the same name from the block's slug + prefix and mounts the component with @livewire(...) on the live page. In the editor preview and design-library preview it renders a labelled placeholder instead of mounting — keeping the editor stable and out of Livewire-in-iframe hydration edge cases.

Render mode: Direct vs Island

A Livewire snippet has a render mode (set on the snippet in Dashboard → Snippets). Neither mode forces the page's caching off — the page's own "Cache response" setting is respected. The mode chooses how the component sits in the (possibly cached) HTML:

Mode How it renders On a cached page
Direct (default) Mounts the component inline at render Its initial render is baked into the shared cached body — identical for every guest until they interact
Island Renders a lazy placeholder that hydrates client-side after load Each visitor hydrates their own fresh render after load
  • Direct mounts inline (@livewire($name, [], $key)). Interactions round-trip and re-render normally, even on a cached page. Because the initial render is frozen into the one shared cached body, use direct for widgets whose initial state is universal (e.g. a counter starting at 0). For content whose mount() is visitor-specific (a greeting, cart contents, time-sensitive data), either use Island or turn the page's caching off via the editor's Cache response toggle. Inline (one-off) blocks are always direct.
  • Island mounts lazily (@livewire($name, ['lazy' => 'on-load'], $key)). Only a static placeholder bakes into the HTML; the real component hydrates per-visitor after page load — so its initial render is always fresh for each guest, even on a cached page. This is the right mode for visitor-specific widgets and for site-wide injection (below).

Why both work on a cached page (no 419). A cached body is shared byte-for-byte across all guests — so a baked-in CSRF token would be wrong for everyone but the visitor who built the cache. This is solved upstream: Spatie ResponseCache's CsrfTokenReplacer (in the replacers list) stores a placeholder where csrf_token() appeared and substitutes the fresh per-visitor token on every cache hit, so Livewire's data-csrf is correct for each guest. The Livewire snapshot checksum is app-key based (not session based), so it validates for everyone; the lazy island placeholder additionally carries no per-session state. No custom token endpoint or JS shim is needed. (Logged-in users skip the response cache entirely, so this only concerns guests.)

Site-wide islands: injected body_end snippets

An island snippet can also be injected, not just placed in a code block. Set a Livewire snippet to Island mode and choose the Scripts (before </body>) placement, and it is mounted (as a lazy island) on every public page — or, with a Page Path, only on the matching page — while those pages stay response-cached. This is the path for a site-wide interactive widget such as a live-chat bubble or announcement bar.

The injection happens in layouts/public.blade.php: the snippet loop emits @livewire('inline.snippet-{id}', ['lazy' => 'on-load'], ...) just before @livewireScripts for each active livewire + body_end + island snippet (guarded by snippetExists()). Direct-mode Livewire snippets can't reach body_end (the edit form only offers it for island, and a defensive guard plus the injection's render_mode filter enforce it) — injecting a direct mount site-wide would bake one shared initial render into every cached page, whereas a site-wide widget (livechat, banner) needs the per-visitor freshness that island's lazy hydration gives. head / php_top placements never apply to Livewire, and consent gating does not apply (gating wraps output in an inert <template> clone, which a Livewire component can't hydrate from).

Security

The Livewire block is admin-gated, the same trust model as the PHP block — the source is arbitrary admin-authored PHP and markup. One thing to keep in mind that differs from the render-once PHP block: a Livewire component additionally exposes its wire: methods to anonymous public visitors, so any method the admin writes is a public surface. Author accordingly.

Garbage collection & multi-server

Materialised component files are kept in sync at save time. Orphans left by deleted blocks or trashed pages are swept by the inline-livewire:gc command (scheduled daily via LazyCron; purely file-based, no DB). For load-balanced fleets the inline component files are runtime-written structural files in the same class as page blades — track them in git and deploy them alongside routes/web.php and the page files (see multi-server.md).

What lives where

Path Purpose
resources/views/components/dl/html.blade.php HTML block — raw markup echo.
resources/views/components/dl/php.blade.php PHP block — eval server-side, print output.
resources/views/components/dl/livewire.blade.php Livewire block render surface — mounts the materialised component on live, placeholder in preview.
app/Support/DlSchemas/{Html,Php,Livewire}.php Field schemas (source field + wrapper classes/id/attrs) for each block.
app/Support/InlineLivewireSyncer.php Materialises inline and snippet-backed Livewire source to on-disk SFCs; syncPage(), materialize(), materializeSnippet(), gc().
app/Enums/SnippetType.php / SnippetPlacement.php Add the Livewire type + PageBuilder placement (isInjected() = false).
app/Enums/SnippetRenderMode.php Direct | Island render mode for Livewire snippets.
resources/views/pages/dashboard/snippets/⚡{create,edit}.blade.php Snippet form — Livewire type + Page-builder placement; Render mode dropdown (island unlocks the body_end placement); consent hidden for page-builder.
resources/views/layouts/public.blade.php Injects island + body_end Livewire snippets site-wide as lazy mounts before @livewireScripts.
database/migrations/*_seed_example_livewire_counter_snippet.php Idempotently seeds the built-in "Counter (example)" snippet.
app/Console/Commands/InlineLivewireGcCommand.php inline-livewire:gc — prunes orphaned inline component files.
app/Support/Rows/RowItemLibrary.php Catalogues the three blocks in the "Add item" picker (PHP + Livewire gated min_role => admin).
resources/design-library/items/{html,php,livewire}.blade.php Insert snippets for each block.
resources/views/livewire/inline/{hash}.blade.php Runtime-written materialised Livewire components (one per block instance).

Marketing angle

Three escape hatches, each matched to what it does: paste an embed or custom markup (HTML), run a one-off server calculation (PHP), or build a fully interactive server-driven widget right inside the page builder (Livewire) — no developer, no deploy. The Livewire block is the standout: an editor writes a real component with state and click handlers and it's live on the site after a save, with the CMS handling the hard parts automatically — stable hydration across requests and turning off full-page caching for that page so the component always works. Powerful blocks stay admin-only, so the capability is there without handing every editor a loaded gun.