metrixinfo/nova-iframe
Easily embed external or internal web pages inside Laravel Nova with this simple iframe component. Add an iframe field to resources or a dashboard card, configure the URL, height, width, and other display options, then render trusted content alongside your admin data.
Install, then immediately pin. This is a 7-star, zero-dependent, single-maintainer micro-package. Treat it like a snippet you're vendoring, not a library you're adopting.
composer require metrixinfo/nova-iframe
Then tighten the constraint in composer.json and commit composer.lock in the same commit:
"require": {
"metrixinfo/nova-iframe": "1.0.*"
}
Read four things before you write a single line of resource code, in this order:
vendor/metrixinfo/nova-iframe/composer.json — check the laravel/nova constraint. If it says ^3.0 and you're on Nova 4/5, stop now and price the inline rebuild (it's ~50 lines of Vue and a field class — often faster than fighting it).vendor/metrixinfo/nova-iframe/src/ — confirm the class name and namespace. Micro-package READMEs go stale on casing constantly (Iframe vs IFrame vs IframeField).vendor/metrixinfo/nova-iframe/dist/js/field.js — this compiled bundle is the API documentation. Grep it for the meta keys it actually reads:
grep -oE '\.(url|height|sandbox|allow|referrerpolicy)\(' vendor/metrixinfo/nova-iframe/dist/js/field.js
What's missing from that list is what you'll have to add yourself.curl -sI https://dashboard.example.com | grep -iE 'x-frame-options|content-security-policy'
DENY, SAMEORIGIN, or a restrictive frame-ancestors means the package cannot help you and no amount of Nova work will fix it. Find out in ten seconds, not two hours.Also check whether the package auto-registers its asset through a service provider. If it doesn't, wire it into app/Providers/NovaServiceProvider.php:
use Laravel\Nova\Events\ServingNova;
use Laravel\Nova\Nova;
public function boot(): void
{
parent::boot();
Nova::serving(function (ServingNova $event) {
Nova::script('nova-iframe', __DIR__.'/../../vendor/metrixinfo/nova-iframe/dist/js/field.js');
});
}
First use case. One read-only, independently-authenticated dashboard on one resource's detail view only. Feature-flag it, rollback is deleting one line.
use Metrixinfo\NovaIframe\Iframe; // verify namespace via src/ first
public function fields(NovaRequest $request): array
{
return [
// ...existing fields
Iframe::make('Grafana')
->onlyOnDetail()
->withMeta([
'url' => $this->resource->grafanaEmbedUrl(),
'height' => 720,
]),
];
}
An iframe in an index table fires N third-party requests per page load and will visibly degrade the admin. Pair onlyOnDetail() with hideFromIndex(); if the field is genuinely valuable in a list context, use a link column instead.
Iframe::make('Status Board')
->onlyOnDetail()
->hideFromIndex()
->withMeta([
'url' => $this->resource->statusBoardUrl(),
'height' => 560,
]),
fields()Three benefits: the resource stays readable, the URL is unit-testable in isolation, and you get one chokepoint for an allowlist check.
// app/Models/Project.php
public function grafanaEmbedUrl(): ?string
{
if (! $this->dashboard_slug) {
return null;
}
$base = rtrim(config('services.grafana.embed_base'), '/');
$url = "{$base}/d/{$this->dashboard_slug}?kiosk";
$host = parse_url($url, PHP_URL_HOST);
abort_unless(in_array($host, config('services.grafana.allowed_hosts', []), true), 500);
return $url;
}
Return null for unconfigured records. A null src renders as a 400px empty box, which reads as "broken" to an operator. Better: gate the field itself.
Iframe::make('Grafana')
->onlyOnDetail()
->canSee(fn () => $this->resource->grafanaEmbedUrl() !== null)
->withMeta(['url' => $this->resource->grafanaEmbedUrl(), 'height' => 720]),
If the embedded origin is yours and requires auth, don't count on the Nova session cookie surviving into the frame — third-party cookie blocking is the default in several browser configurations and the behaviour is inconsistent. Generate a short-lived signed URL server-side and validate it in your own middleware:
'url' => URL::temporarySignedRoute(
'internal.dashboard.embed',
now()->addMinutes(10),
['project' => $this->resource->id]
),
The frame is now independently authenticated and cookie policy stops being a variable.
An embed failure must never block an operator from finishing the task. Cheapest version — a sibling link field:
Text::make('Dashboard', fn () => $this->resource->dashboard_slug
? '<a href="'.$this->resource->grafanaEmbedUrl().'" target="_blank" rel="noopener">Open in new tab ↗</a>'
: '—')
->asHtml()
->onlyOnDetail(),
Put this in the review checklist, not the backlog. Half of the incidents this field generates are "frame is blank, user is stuck."
canSee() works exactly as it does on any other field, so authorisation lives where it always does:
Iframe::make('Support Console')
->onlyOnDetail()
->canSee(fn (NovaRequest $request) => $request->user()->can('viewSupportConsole', $this->resource))
->withMeta(['url' => $this->resource->supportEmbedUrl(), 'height' => 560]),
An iframe on an update form as a live preview of the page you're editing is legitimate. An iframe on a create form is not: it participates in nothing. Never put it in a ->rules() chain or mark it required — Nova will happily try to validate a field that submits no value.
The realistic long-term shape is fork-and-own. If you need sandbox, allow, referrerpolicy, or a loading skeleton, extend the field and swap the component:
// app/Nova/Fields/SafeIframe.php
namespace App\Nova\Fields;
use Metrixinfo\NovaIframe\Iframe;
class SafeIframe extends Iframe
{
public function component(): string
{
return 'safe-iframe'; // your own registered Vue component
}
}
Register resources/js/components/SafeIframe.vue via nova.mix.js / Nova::script(). Because the underlying package is trivial, this is a one-to-two-day job — budget it up front rather than discovering the need during a Nova major upgrade.
A blank frame is almost never a package bug. Debug in this exact order:
Refused to display ... in a frame because it set 'X-Frame-Options' or violates the following Content Security Policy directive: "frame-src ...".url meta is empty or null.curl -sI <target> — check x-frame-options, content-security-policy, and content-security-policy-report-only.If it's CSP, the fix is deployment config — add the origin to your frame-src (Nova's CSP middleware, your web server headers, or config/). Plan for that in the same PR or you'll spend an afternoon blaming Composer.
Mixed content. An http:// embed inside an HTTPS Nova admin is silently blocked. Normalise to https in the URL builder and fail loudly (exception or log) when you can't — a silent fallback to a blocked frame is worse than an error.
Validate the scheme server-side. If the URL derives from record data (a user-supplied slug, a stored link), filter_var($url, FILTER_VALIDATE_URL) plus a host allowlist. Then confirm the compiled bundle escapes it — grep for v-html and raw attribute interpolation in dist/js/field.js. Unescaped src binding is an injection vector, and javascript:/data: URIs are the payload.
Missing sandbox / allow. Most micro-packages omit these. Add them at your layer: sandbox="allow-scripts allow-same-origin allow-popups" and referrerpolicy="no-referrer" for third-party origins. Important: never combine allow-scripts with allow-same-origin on an untrusted origin — that combination lets the framed page remove its own sandbox, which is functionally no sandbox at all. This is the security review deliverable and it outlives the package.
Fixed height breaks layouts. Verify how the component applies height before shipping. A hardcoded inline style on a 900px dashboard in a 400px frame is a guaranteed support ticket. If the package only accepts an integer, either fork the component or accept that you'll be tuning per-field.
Nova 4+ / Vue 3. Nova 4 moved to Vue 3 and changed asset registration conventions. A package with 7 stars and zero dependents has very likely not tracked that. Check the bundle: grep -E 'new Vue|Vue\.component' dist/js/field.js. Vue 2 idioms mean the field won't register on Nova 4+ — symptoms are a blank render or a console error about unknown components. Go straight to the inline rebuild.
No remount on record switch. Not an issue in standard Nova detail views (each page is a fresh mount), but if you reuse the component inside a custom tool or a drawer that swaps records, bind :key to the record ID or the frame shows stale content silently.
Forms serialise nothing. The iframe contributes zero data on store/update. It is contextual decoration. Guard against required, rules(), or anyone assuming write-back works.
Pin, name an owner, write one test. Commit composer.lock, record the owner in an ADR or README, and add a single feature test asserting the detail page contains the expected src:
public function test_project_detail_embeds_dashboard(): void
{
$project = Project::factory()->create(['dashboard_slug' => 'abc123']);
$this->actingAs(User::factory()->admin()->create())
->get("/nova/resources/projects/{$project->id}")
->assertOk()
->assertSee('dashboard.example.com/d/abc123', false);
}
That test is your five-minute signal on the next Nova major upgrade, instead of a production incident. Budget roughly half a day per Nova major for re-validation.
Make the link-out comparison explicitly, and accept it as an outcome. If the measurable win (time-to-answer, context switches) is marginal, Text::make(...)->asHtml() opening in a new tab delivers most of the value with zero dependency, zero CSP configuration, and zero security review. Deciding that after a one-resource spike is a success, not a failure — write it down so it doesn't get relitigated.
How can I help you explore Laravel packages today?