interaction-design-foundation/nova-unlayer-field
A Laravel Nova field that embeds the Unlayer drag-and-drop editor, letting you design and edit email or page templates directly inside Nova resources and save the generated HTML and JSON.
Prerequisites, in order of likelihood to block you:
composer.json require block and its published dist/ before you spend a day on integration.Install and wire up:
composer require interaction-design-foundation/nova-unlayer-field
php artisan vendor:publish --provider="InteractionDesignFoundation\NovaUnlayerField\FieldServiceProvider"
Read config/ after publishing — that is the file that tells you which editor options, templates, and Unlayer identifiers the field actually honours. Where docs and source disagree, the source is a few hundred lines; read it.
Migration + model, the shape you want on day one:
Schema::table('email_templates', function (Blueprint $table) {
$table->json('design')->nullable(); // re-editable source
$table->longText('html')->nullable(); // the deliverable
$table->unsignedInteger('schema_version')->default(1);
$table->timestamp('rendered_at')->nullable();
});
protected $casts = [
'design' => 'array',
'rendered_at' => 'datetime',
];
First use case — resist scope creep. Create a dedicated EmailTemplate Nova resource with exactly one Unlayer field on the form and a read-only HTML/text view on the detail screen. Do not bolt this onto your existing pages or posts resource. One resource, one migration, one config entry is the whole adoption cost — keep it that way until the workflow is proven.
The two-artifact contract. Treat design as the editable source of truth and html as a disposable build product. Never let the editor rehydrate from HTML — fidelity loss is real and one-way. Always persist schema_version next to them so a future Unlayer SDK change doesn't silently re-render old content into something different.
One Nova field, two columns. Nova fields map to one attribute by default. The field emits both design JSON and rendered HTML, so you have to split the payload. Either the package handles it (check for an option controlling which attribute receives what) or you wrap it:
public function fields(Request $request)
{
return [
Unlayer::make('Design')
->onlyOnForms()
->fillUsing(function ($request, $model, $attribute) {
$payload = $request->input($attribute); // object {design, html}
$model->design = $payload['design'] ?? null;
$model->html = $payload['html'] ?? null;
}),
Text::make('HTML', 'html')
->onlyOnDetail()
->asHtml(), // render in a sandboxed iframe, not raw
];
}
Preview without breaking the admin. Never {!! $template->html !!} straight into a Nova detail view — Unlayer output includes <style> blocks and full-document HTML that will fight Nova's own CSS. Serve it from a route:
Route::middleware(['nova'])->get('/nova/templates/{template}/preview', function (EmailTemplate $template) {
return response('<iframe sandbox="allow-same-origin" srcdoc="'
. e($template->html) . '" style="width:100%;height:90vh;border:0"></iframe>');
});
Render and sanitize as a queued job, not on the request. The editor's HTML is client-submitted, i.e. untrusted. On save, dispatch RenderTemplate that runs HTMLPurifier with a config that keeps <style>, media queries, and inline CSS (mews/purifier needs explicit allowlisting; defaults will gut an email), derives plain text, and stamps rendered_at. Cache the output; re-render only when design changes.
Version history is a separate table, and you almost certainly need it. Unlayer has no undo across sessions and Nova form saves overwrite in place. A template_versions append-only table (template_id, design, html, created_by, created_at) costs an hour and saves a content team from a bad Friday.
Duplicate + test-send as Nova Actions. Both are trivial and both are the first things editors will ask for:
(new DuplicateTemplate)->confirmText('Copy this template?'),
(new SendTestEmail)->fields(fn () => [Text::make('To')]),
Gate by policy, not by menu. ->canSee(fn ($request) => $request->user()->can('design-templates')). The Unlayer editor is a heavyweight third-party script; you do not want it loaded for every admin who opens the resource index.
Options and templates come from config. Brand colours, fonts, merge-tag sets, and locked layouts belong in config/nova-unlayer-field.php, not scattered through resources — that's the extension point you'll touch most often, and it's the thing to review in a PR rather than deep edits to the Vue component.
Content-Security-Policy is the #1 silent failure. A blank editor in production with a clean console is almost always CSP: Unlayer loads scripts from its own CDN and renders inside an iframe, so script-src, frame-src, connect-src, img-src, and style-src 'unsafe-inline' all need allowlisting. Do this in staging before you tell anyone the feature exists. Have a fallback: a textarea "edit raw HTML" field behind a feature flag, so authors aren't blocked when the CDN is.
Pin the version, expect to fork. Zero dependents, single-org maintenance, tiny surface. The sensible operating model is a vendored fork you can patch yourself. Budget a recurring task on every Nova major upgrade — the Vue component is the thing that will break, and it will break at the worst time. Keep a note in the repo: "if upstream goes quiet, src/resources/js is N lines, here's how to rebuild it."
Client-side HTML cannot be trusted. Whatever ships must go through sanitization server-side, and the sanitizer must be configured for email markup (tables, inline styles, media queries). If your purifier strips <style> you will ship unstyled emails and blame the editor.
Editor preview ≠ inbox. Outlook and Gmail will not match Unlayer's canvas. Build a test-send to a seed list into the workflow early; do not let content authors discover this at launch. You also own link rewriting, UTM tagging, unsubscribe headers, and tracking pixels — the field does none of it.
Images live on Unlayer's CDN by default. Uploads resolve to vendor URLs. If the plan lapses, your archive breaks. If that matters, configure your own asset storage/S3 in the Unlayer project and verify existing templates after any plan change.
Payload size. Design JSON for a long landing page can be hundreds of KB; HTML similar. Watch MySQL max_allowed_packet and PHP post_max_size on save, and use longText/LONGTEXT, not text (64KB) — the 64KB truncation is silent and only shows up on your richest templates.
No autosave. Nova saves on submit. A lost session means a lost design. If authors complain, the cheapest fix is a version row written on every save (above) plus an explicit "save draft" pattern — not a custom autosave implementation.
Rotated keys fail loudly at load, quietly at export. The editor may open fine while export returns stale or empty HTML. Add a validation rule rejecting saves where design is non-empty but html is empty, and alert on it.
Nova compatibility spike checklist: install on a branch → open the field → load an Unlayer template → save → assert both columns populated → render the HTML in an iframe → run it through the sanitizer → confirm the sanitized output still looks right in a real inbox. Two hours, and it tells you whether this package is a fit or whether you should reimplement the ~200-line embed yourself (which is a genuinely reasonable outcome here).
How can I help you explore Laravel packages today?