Building your app

Landing widgets

An embedded app can contribute blocks to the tenant's public landing-page builder: your widget appears in the editor's "Your apps" category (only for tenants that installed your app), the tenant drags it onto their public site, and visitors see it like any platform block — without the tenant writing a single line of code. You declare the widget in the manifest and ship one Livewire component; the platform provides the rest:

  • The editor card, carrying your app's name and icon.
  • The block settings form, generated automatically from your settings_schema declaration — you write no editor Blade at all.
  • Block behaviour (move / copy / columns / show-hide) comes from the builder itself; the widget is stored inside the page document as one bridge block of type partner-widget — installing a new app adds no block class to the platform.
  • Security, free and mandatory: the runtime context on the public page is built by the platform, not by you.

Embedded apps only. A landing widget renders in-process inside okta-web as a Livewire component; an app hosted elsewhere has nothing to render there. Declaring landing_widgets with any other integrationType fails the version publish.

Read the security model before you build. It decides what your widget can reach at all — people and money data will never reach your widget on a public page, whatever scopes your installation holds.

Declaring widgets in the manifest

{
  "integrationType": "embedded",
  // ... rest of the manifest ...
  "landing_widgets": [
    {
      "key": "admission_register",
      "label": "التسجيل في القبول",
      "label_en": "Admission Registration",
      "description": "نموذج تقديم طلب قبول من موقع الجهة العام.",
      "description_en": "Admission application form on the tenant public site.",
      "icon": "clipboard",
      "category": "conversion",
      "livewire_component": "my-app-landing-register",
      "interaction": "static",
      "scopes": ["education.grades.read"],
      "cache_ttl": 300,
      "settings_schema": {
        "fields": [
          { "key": "title", "type": "text", "label": "العنوان", "label_en": "Title",
            "localized": true, "default": "سجّل الآن", "min": 1, "max": 80 },
          { "key": "show_deadline", "type": "toggle", "label": "إظهار آخر موعد", "default": true }
        ]
      },
      "preview": { "thumbnail_url": "https://cdn.example.com/widgets/register.png" }
    }
  ]
}

Fields

landing_widgets is an array of at most 10 widgets per app.

Field Required Value
key ✓ ^[a-z][a-z0-9_]{1,39}$ — unique within the app. The global identifier is the composite <module-slug>:<key>; stored blocks on tenant pages reference the widget by it. Changing it after publish = a new widget and every old block loses its reference — name it well in the first release.
label / label_en label ✓ Card name in the editor (2–60 chars). label_en is used per locale and falls back to label.
description / description_en — Short text under the card (≤ 300 chars).
icon — Icon name (≤ 40 chars, default puzzle). An unknown value falls back to the default icon with no error.
category — The card's editor group: hero | content | media | conversion | layout (default conversion).
livewire_component ✓ Livewire component alias, flat form only. An alias containing :: is refused at publish — Livewire 4 never resolves it anyway (the same trap as the App Control Panel).
interaction — static (default) or interactive. Interactive is a much heavier contract — see Interactive widgets.
scopes — Read scopes the render needs at runtime (≤ 20), each ending strictly in .read — any other verb fails the publish. This is a restriction, not a grant: what you declare here enters the read-leg intersection, and what you do not declare never reaches the widget even when the installation holds it.
public_writes — Write scopes the widget performs on behalf of an anonymous visitor (≤ 10), each ending strictly in .write. Three publish-time conditions: the shape; every entry being an active scope marked public_writable in the okta-web catalog (checked against the live catalog — and refused rather than accepted unchecked if the catalog cannot be reached); and interaction=interactive — a static widget is served from cache with no round trip in which a write could ever happen.
cache_ttl — Seconds the rendered HTML is cached on the public page (0–86400, default 300). Ignored entirely for interactive widgets — a cached body would replay one visitor's form state to another.
settings_schema — The declarative settings form — next section.
preview.thumbnail_url — Card thumbnail. Absolute https URL only.

The settings form: settings_schema

{ "fields": [ { /* field */ }, ... ] }   // at most 20 fields

The types are a closed vocabulary — exactly eight: text, textarea, number, toggle, select, color, image, link.

There is no html type and there never will be (declaring it is refused at publish with an explicit message). That is a security decision, not a gap: every setting value passes through Blade escaping at render time, so a tenant admin (or whoever compromises their account) cannot inject script into the public page through your widget's settings. If you need rich output, build it in your component from structured fields.

Field properties:

Property Applies to Description
key all ^[a-z][a-z0-9_]{0,39}$, unique within the widget.
type all One of the eight types.
label / label_en all Field title in the inspector (label required, ≤ 120 chars).
localized all true = the editor writes the value for the open locale only; false/absent = it mirrors the value to both locales. Your component always receives single-locale settings — the locale of the page being served — and never needs to know which fields are localized.
default all Initial value on insert. Checked against the type: a toggle accepts only a bool, a number only a number, and a select's default must be one of its declared options.
help / help_en all Help text under the field (≤ 300 chars).
min / max text, textarea, number Length bounds for text, value bounds for numbers. On other types they are silently dropped.
options select [{"value": "...", "label": "...", "label_en": "..."}] static list (≤ 50 options, unique values). A select without options fails the publish.
options_endpoint select Reserved and currently refused. The shape (<provider>:<name>) is validated, then the declaration is explicitly refused at publish while no resolver exists on the platform — a readable publish error today beats a field that silently degrades into a text box in every tenant's editor. Use static options.

An example covering all eight types:

{ "fields": [
  { "key": "title",    "type": "text",     "label": "العنوان", "localized": true,
    "default": "سجّل الآن", "min": 1, "max": 80 },
  { "key": "intro",    "type": "textarea", "label": "المقدمة", "localized": true, "max": 400 },
  { "key": "max_rows", "type": "number",   "label": "عدد الصفوف", "default": 5, "min": 1, "max": 20 },
  { "key": "show_fees","type": "toggle",   "label": "إظهار الرسوم", "default": false },
  { "key": "layout",   "type": "select",   "label": "التخطيط",
    "options": [
      { "value": "grid", "label": "شبكة", "label_en": "Grid" },
      { "value": "list", "label": "قائمة", "label_en": "List" }
    ],
    "default": "grid" },
  { "key": "accent",   "type": "color",    "label": "لون التمييز", "default": "#0ea5e9" },
  { "key": "banner",   "type": "image",    "label": "صورة الترويسة" },
  { "key": "policy",   "type": "link",     "label": "رابط سياسة القبول", "localized": true }
]}

Two points govern how storage works:

  • The block is stored per locale inside the page document, and non-localized fields are duplicated across both locales with the same value — there is no "shared" store.
  • A block inserted before you added a new field in a later release will not carry its key. Therefore: every settings read in your component goes through ?? $default, always.

Writing the component

The widget is an ordinary Livewire component inside your app, under the full Embedded code contract — being on a public page is no exemption:

  1. Data exclusively through App\Services\PartnerApi\* — the policy scanner fails the build on anything else, and every service checks its scope internally, so whatever is not in the context is refused automatically.
  2. Never assume a user. On the public page auth()->user() is always null. Code that assumes a session breaks there even though it works in the editor preview.
  3. Developer settings work: GetAppSetting / GetAppSettings run inside the widget like any runtime context — reading your own app's store only, so you can change widget behaviour without publishing a new version.
  4. UI to platform standards: <x-…> components and semantic tokens — the widget renders inside the tenant's page with their theme and brand, and hard-coded colours break that.
  5. Assume failure: an exception from your component on the public page shows no error page — it produces a silent void where the widget was (see below).

A static widget (static) renders once and its output is cached for cache_ttl seconds: any wire:click or wire:model in a static blade freezes into dead attributes in cached HTML. Right for display: lists, stats, cards, links. Its component receives exactly settings + locale at render time and nothing else — anything it needs about the tenant it reads from the active ModuleContext like every other PartnerApi service.

The security model (two asymmetric surfaces)

The two render surfaces are not symmetric, and the difference between them is the whole design:

  • Editor preview: the tenant admin is signed in, so the context resolves the usual way (the same path as the rest of your app's pages). The preview sees what your app normally sees.
  • Public page: the visitor is entirely anonymous — no user, no session. The platform rebuilds the runtime context per request from the tenant alone (resolved from the domain), and the context's scopes are the union of two sets, each an independent three-way intersection:
READ  = (granted to the install) ∩ (catalog public_surface)  ∩ (widget scopes)
WRITE = (granted to the install) ∩ (catalog public_writable) ∩ (widget public_writes)

context = READ ∪ WRITE

They are deliberately NOT one path with a merged flag: read and write answer different questions ("may a stranger see this?" versus "may a stranger cause this?"), and a single path would let a future edit to one silently move the other. The verb filter runs twice on each leg: a .write inside scopes[] is not a read declaration whatever the manifest says, and a mis-marked catalog row cannot ride a grant into the context.

The write leg carries two extra conditions: a platform-wide interactive flag checked inside the resolver itself, and a public write ticket that is only minted after the barriers pass (see Interactive widgets). No ticket = an empty write set — which is what every read path gets, by default, forever.

The governing principle: what never enters the context needs no guard. No new permission check, no extra middleware, no deny-list to maintain — a deliberately poor context, and the same existing guard that protects every PartnerApi service refuses everything outside it.

The practical consequence you must know before you build: student, guardian, employee and financial scopes are never marked public_surface or public_writable. Their exclusion is structural, not policy: it is not a middleware rule someone could miss — they simply never enter the context, so every call to them is refused as if they were never granted. A widget calling ListStudents on a public page will fail always, whatever you declare in scopes. If your widget concept needs people data on a public page, the concept itself needs rethinking.

Today's public_surface list (may grow by platform decision, very conservatively):

  • countries.directory.read — public reference data (country list).
  • countries.education_levels.read — the reference ladder of education stages.
  • education.grades.read — the tenant's own grade levels (institutional structure a school already publishes on its site — no person and no record in it).

The scopes and public_writes fields in your declaration are the third leg of their intersections, and they work in your favour: declare the minimum, and a vulnerability in your widget's code can only reach what you declared — even when your installation holds more.

Failure is a silent void — and cached

A widget failure on the public page — an exception, a refused scope, an alias that does not resolve, an uninstalled app — produces a silent void where the block was, and never takes the tenant's site down. A static widget's failure is also cached for 30 seconds to prevent hammering: a crashing widget is not re-executed for every visitor. Turn that around for development: your fix may not show up immediately because of the failure cache, and you will think "the widget just doesn't work" while the real cause is a swallowed exception — check the okta-web logs, and do not use repeated page refreshes as a test.

On the other surface — an interactive widget's round trip — failure is not silent: a request that cannot be attributed to a widget the tenant actually owns is refused with 403. Silence for the page, candour for the round trip.

Interactive widgets

A widget that accepts input from an anonymous visitor (a submission form, say). A much heavier contract than flipping the field, and every sentence of it is enforced by code, not by review.

The base class is mandatory — and CI enforces it

Your component must extend App\Support\LandingWidgets\PublicWidgetComponent — a class the platform owns. The landing-widget-base-class rule in the policy scanner fails the build for any class under app/LandingWidgets/ in your app that does not extend it: a plain Livewire\Component there is not "a widget without the extras" — it is a public write path with no context derivation and no barriers, failing open instead of closed. (An intermediate base class of your own that extends PublicWidgetComponent is a legitimate shape — opt it out on the declaration line with // partner-policy:allow=landing-widget-base-class.)

Why: /livewire/update remembers nothing

The first paint is made by the platform while it holds the tenant, the install and the context. But every wire:click after it goes to /livewire/update — a route that knows no landing pages and no tenants; all it has is the snapshot the browser sent. So the base class re-derives the full context on every hydrate from live data: real tenant → the plan still includes widgets → the interactive surface is on → the widget is still in the tenant's current catalog → declared interactive → context minted through the intersections. #[Locked] on the identity properties does real work but is not the guard: a forged tenantUlid resolves against live data and is refused because that tenant has not installed your app. Even the settings carried on the snapshot are re-clamped to the widget's current schema before any use.

mount(), boot(), dehydrate() and exception() are all final on purpose. Your hook is mounted(), which runs once after the context is up. And never store the context on a property — per-request values are remade on every hydrate.

At render time your component receives — in addition to settings — the identity triple moduleSlug / widgetKey / tenantUlid plus widgetLocale, because it will rebuild the context from them on every round trip. (The choice is made by inspecting the resolved class, not the declared interaction field.)

Writes: withPublicWrite() and the three barriers

Every write on a visitor's behalf goes exclusively through:

public function submit(): void
{
    $this->validate([...]);

    $this->withPublicWrite('submit', function (): void {
        app(\App\Services\PartnerApi\...\CreateSomething::class)(...);
    });
}

In order, all of them in the base class so you cannot decline one:

  1. honeypot — a hidden field the platform owns (see the partial below). Filling it = a bot.
  2. throttle — per (IP × tenant × module × widget × action); the default is 5/minute, or maxPerMinute per call. throttle() is also available to you standalone, to protect an expensive read.
  3. captcha — only active when the platform has a provider bound and the tenant switched it on (or the platform forces it globally). The platform ships no provider by default, so the barrier is absent rather than blocking a tenant with a check that cannot run.

A refusal is not an exception you handle: the callback simply does not run, withPublicWrite returns null, and $widgetBarrierMessage carries a translated sentence the partial shows the visitor.

After all three pass — and only there — the public write ticket is minted, and the context is re-derived with it so the write leg enters; the callback runs; then a finally re-derives without the ticket — the write clearance lasts exactly as long as the write, and the following render runs with a read-only context. Nowhere else in the platform mints the ticket, so there is no path to a public write that skipped a barrier.

The form-guard partial — a mandatory include

@include('landing-builder::blocks.partner-widget.partials.form-guard')

Once, inside your component's root element. It renders the honeypot, the guard marker and the refusal message. Enforcement comes from the HTML actually rendered: the base class inspects its own render output and derives the guard state from it — a form without the partial arrives at the next write with a permanently failing honeypot barrier, and there is no way around it. The misleading symptom when you forget it: the form shows and appears to work, and every write returns null with a barrier message.

The deployment trap: SESSION_DOMAIN — know it before you ship

An interactive widget = a POST with a session and CSRF, and the cookie only returns to the server if it covers the served host's domain:

  • SESSION_DOMAIN unset: every tenant domain (custom or subdomain) gets its own session. This is the arrangement that works.
  • SESSION_DOMAIN pinned to the platform domain: a tenant serving its page from example.com gets no session cookie at all — the page renders, the form shows, and the first click is a 419 in front of a visitor who cannot report it.

The platform detects the second case rather than "fixing" it (a platform-wide cookie domain is usually set deliberately): it suppresses the interactive widget on that host and shows a polite card instead of a form that ends in 419, with a warning line in the okta-web logs. Not your bug and not your code's — a deployment precondition. Test on a custom domain if your tenants use them.

The only public_writable scope today

education.admission_applications.write — an admission application submitted by a visitor on the tenant's own public site. A mailbox: things go in and never come out — it has no .read counterpart at all (a public read would expose other applicants' submissions to any visitor), and it is marked is_dangerous, so the install-grant screen paints it in danger colours on purpose.

Extending the list is a platform decision, never requested through the manifest, and governed by three non-negotiable rules: intake only (creating a new inbound record about the visitor themselves — no update, no touching an existing record), no people, no money, and never a delete. Have a use case? Bring it to the platform team.

Lifecycle

  1. Publish: you add landing_widgets to your version's manifest and publish it. Validation happens on acceptance (shapes, limits, integrationType=embedded, public_writes markings against the live catalog) — a non-conforming block refuses the version.
  2. Install: the tenant installs your app from the store and grants scopes as usual.
  3. Appearing in the editor: the tenant's widget catalog is derived per request from its installations — installed apps' widgets only.
  4. Insert: the tenant admin drags the widget in, and the inspector shows the settings form generated from settings_schema. The preview renders with the admin's context.
  5. Page publish: on the public site the widget renders with the anonymous context (the union of the two intersections). A static widget's output is cached for cache_ttl seconds (the cache key carries a settings fingerprint — editing the settings and republishing shows immediately); an interactive widget renders live on every request.
  6. Uninstall: the widget disappears from the editor catalog immediately. Blocks already placed on published pages lose their reference and fall into the same failure path: a silent void, no error on the tenant's site. (The tenant deletes the orphan block from its editor whenever it wants.)
  7. Version update: the catalog reflects the installed version's manifest. A new settings field shows up in old blocks only through your code's default (?? $default); deleting a widget or changing its key orphans old blocks exactly like an uninstall.

Registering the alias and the component

// In your app's ServiceProvider::boot() — the flat alias form is mandatory.
use Livewire\Livewire;

Livewire::component(
    'my-app-landing-register',
    \Modules\MyApp\Livewire\Landing\RegisterWidget::class,
);
<?php

namespace Modules\MyApp\Livewire\Landing;

use Livewire\Component;

class RegisterWidget extends Component
{
    /** settings_schema values for the served locale, as the editor stored them. */
    public array $settings = [];

    public function render()
    {
        // Reads exclusively via App\Services\PartnerApi\* — the service
        // checks the scope internally and refuses what is not in the context.
        $grades = app(\App\Services\PartnerApi\Education\Grades\ListGrades::class)(
            onlyActive: true,
        );

        return view('my-app::livewire.landing.register-widget', [
            'grades' => $grades->data,
            'title' => $this->settings['title'] ?? 'سجّل الآن',
        ]);
    }
}

That is all for a static widget. An interactive one extends PublicWidgetComponent instead of Component, lives under app/LandingWidgets/, and writes through withPublicWrite() as in its section above.

Common errors

Symptom Cause Fix
Widget never renders Alias contains :: Flat form in livewire_component and in Livewire::component() alike
"Just doesn't work" during development Silent failure + 30s failure cache Check the okta-web logs; do not use page refreshes as a test
Works in preview, empty on the public site A scope not marked public_surface or not declared in scopes The preview runs with a full admin context; test with the poor context before publishing
Buttons unresponsive on the site A static widget with wire:* in it Interactivity requires interaction=interactive and its full contract
Every write refused with a barrier message Missing @include(... form-guard) Once, inside the root element
419 on the first click on a custom domain SESSION_DOMAIN pinned to the platform domain A deployment precondition — see the SESSION_DOMAIN trap
A setting value shows as literal text All settings values are escaped at render Intended — never HTML through settings

Pre-publish checklist

  • integrationType=embedded and the landing_widgets block ≤ 10 widgets.
  • Every key matches ^[a-z][a-z0-9_]{1,39}$, unique within the app — and its name is final.
  • livewire_component in the flat form (no ::), registered with Livewire::component() in boot().
  • scopes is the minimum the render actually needs — not a copy of the installation's scope list.
  • No people/finance scope called from the public render path — it will never be marked public_surface.
  • settings_schema ≤ 20 fields, types from the eight, and every settings read behind ?? $default.
  • A static widget has no wire:* in its blade.
  • Interactive: extends PublicWidgetComponent, under app/LandingWidgets/, and every write inside withPublicWrite().
  • Interactive: @include('landing-builder::blocks.partner-widget.partials.form-guard') once inside the root element.
  • All public_writes are marked public_writable in the catalog and interaction=interactive.
  • Interactive tested on a custom tenant domain if your tenants use them.
  • The render tested with the poor context (no user, scopes only), in both page locales, and with an empty data state.
  • preview.thumbnail_url is a working https URL, and the card reviewed in the editor.

View the whole guide on one page · ↧ Raw Markdown