Confirm the platform matrix before installing: PHP 8.1+, Laravel 10+, Nova 4 or 5. The package ships pre-compiled assets, so there is no npm install / npm run dev step — a real win on teams that don't maintain a Node toolchain in CI.
composer require <vendor>/nova-html-field # confirm exact name in the README
There is no mandatory publish step. Publish only if you want to override the field's view or ship a purifier config file.
Requirements to verify on the host: the dom/libxml extensions (HTMLPurifier dependencies) and a writable cache directory for purifier definitions — this is the single most common first-run failure (see Gotchas).
Where to look first, in order:
src/Html.php — the field class. Read content(), html(), when()/unless() to learn exact signatures, whether html() receives the request/resource, and where sanitization is applied.HTMLPurifier_Config instance, or a config key.resources/js/components/Field.vue — check whether output is rendered with v-html (it will be) and whether the component also renders in the index row.First use case — a static, read-only panel on a detail page:
use Vendor\NovaHtmlField\Html;
public function fields(Request $request): array
{
return [
Html::make('Sync Notes')
->content('<p class="text-gray-500">Synced from the CRM every 15 minutes.</p>')
->onlyOnDetail(),
];
}
Note the onlyOnDetail(): an HTML field has no meaningful value to display in an index column, and leaving it visible there is almost never what you want. Treat onlyOnDetail() as the default until proven otherwise.
Static vs. dynamic content. Use content() for copy that lives in code (help text, disclaimers, links to internal docs). Use html() for anything from the database or a third-party feed:
Html::make('Description')
->html(fn () => $this->resource->rendered_description)
->onlyOnDetail(),
Because html() is a closure, it is not evaluated during Nova's field serialization for other screens unless the field is visible there — but do not rely on that as a performance guarantee (see Gotchas).
Presentation conditions vs. authorization. These are two different tools and mixing them up causes subtle bugs:
// Authorization: does this user get to see it at all?
Html::make('Internal Notes')
->html(fn () => $this->resource->internal_html)
->canSee(fn (Request $request) => $request->user()->isAdmin()),
// Presentation: content exists but isn't relevant right now
Html::make('Deprecation Notice')
->content('<p>This record type is retired.</p>')
->when(fn () => $this->resource->isDeprecated()),
Use Nova's native canSee() for policy-driven visibility and when()/unless() for content-state conditions. If a condition is security-relevant, put it in canSee().
Custom purifier configuration. Reach for this when legitimate content disappears (embedded videos, tables, inline styles). Prefer a published config file over inlining arrays so all fields share one policy:
Html::make('Body')
->html(fn () => $this->resource->body_html)
->config([
'HTML.Allowed' => 'p,br,strong,em,ul,ol,li,a[href|title|target],h2,h3,table,tr,td,th',
'HTML.SafeIframe' => true,
'URI.SafeIframeRegexp' => '%^https://(www\.)?(youtube\.com/embed/|vimeo\.com/)%',
'Cache.DefinitionImpl' => null, // or a writable path; see Gotchas
]),
Keep HTML.Trusted off and never add script to the allowlist — the whole value proposition of the field is that the default is safe.
Markdown → HTML pipelines. Convert first, purify second. Do the Markdown conversion in an accessor (rendered_description) and hand the field the finished HTML; that keeps the conversion logic testable outside Nova and lets you cache the result.
DRY helper for recurring panels. Help panels multiply fast across resources. Wrap the common shape once:
// app/Nova/Fields/HelpPanel.php
public static function make(string $html): Html
{
return Html::make(' ')->content($html)->onlyOnDetail()->hideWhenUpdating();
}
Testing. Split the concern in two: (a) unit-test the purifier behavior through the package's service or a small sanitize() wrapper, asserting <script> and onerror= are stripped and allowed tags survive; (b) feature-test the Nova detail endpoint with Laravel's HTTP test helpers and assert the sanitized fragment appears in the payload. Config regressions are the most likely failure mode, and they only show up in tests that exercise a real payload.
Cache.DefinitionImpl to null (slower per request) or point Cache.SerializerPath at a writable, writable-by-the-web-user path such as storage/app/htmlpurifier.CSS.AllowedProperties belongs in your config rather than relaxing HTML.Trusted.Cache::remember keyed on model + updated_at) or store a pre-sanitized column at write time.html() is evaluated relative to when()/canSee(). If the closure touches relationships, eager-load them in the resource's fields()/query — otherwise a detail screen turns into an N+1.{!! !!} Blade usage are still exposed. Sanitize at every output boundary, or sanitize on save and treat the column as trusted.onlyOnDetail(); if you genuinely need a snippet, render a trimmed plain-text summary instead.Nova::script/Nova::style registration runs. Stale published assets left over from an earlier version are a frequent cause after upgrades; clear them and re-publish.resources/js files.config() array, or a passed HTMLPurifier_Config object wins, and whether inline values merge with or replace the file. This determines whether per-field overrides are safe.config/, a sanitize() helper for other output paths, and a cached accessor on the model. Those three cover nearly every day-to-day need without touching package internals.How can I help you explore Laravel packages today?