laravolt/metabase
Laravel integration for Metabase, making it easy to embed analytics dashboards and questions in your app. Configure connections, generate signed embed URLs, and secure access with minimal setup.
The package is a thin signer: it turns a Metabase dashboard/question ID plus a set of filters into a short-lived signed embed URL you can drop into an <iframe>. Almost all of your day-to-day work happens in two places — config/metabase.php and the facade/helper that returns the URL.
Metabase side (do this first, it's the usual source of "blank iframe" support tickets):
MB_ENCRYPTION_SECRET_KEY.Laravel side:
composer require laravolt/metabase
php artisan vendor:publish --tag=metabase-config # confirm tag name in README
METABASE_SITE_URL=https://metabase.example.com
METABASE_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// routes/web.php
Route::get('/reports/revenue', fn () => view('reports.revenue'));
// resources/views/reports/revenue.blade.php
<iframe
src="{{ Metabase::url($dashboardId, ['tenant_id' => auth()->user()->tenant_id]) }}"
frameborder="0" width="100%" height="900" allowfullscreen
></iframe>
Look first at: config/metabase.php (site URL, secret, token TTL if exposed), then the facade's public methods — the surface area is genuinely small, so read the class rather than the docs. Finally check the Blade component/directive if the package ships one, since that's what most teams end up using in views.
Upgrade note for 0.4.2: nothing in your application code needs to change unless you were reaching into the token-building internals (see Gotchas). This is a drop-in swap of lcobucci/jwt → firebase/php-jwt.
Lock your filters in the token. The whole security model of static embedding is that filter values come from the signed JWT, not the URL. Pass tenant/role/customer IDs as locked parameters so a user can't edit the iframe query string and see another tenant's numbers:
Metabase::url($dashboardId, [
'tenant_id' => 'locked', // rendered but not user-editable
'date_range' => 'past30days', // editable in the Metabase UI
]);
Only expose as editable the parameters you're happy for a user to change.
Generate URLs at render time, never cache them. Embed tokens are short-lived (Metabase caps them, and the package will set an exp). Put URL generation in the controller/Blade render, not in a cached view fragment, a queued job payload, or an API response you store. If you need long-lived pages, expose a tiny refresh endpoint that returns a fresh URL and have the front end swap the iframe.src.
Multi-tenant / multi-workspace setups. Keep a single service class that resolves the secret per tenant and sets config before delegating to the package, rather than calling the facade from controllers directly:
final class TenantMetabase
{
public function url(Tenant $tenant, int $dashboardId, array $params = []): string
{
config(['metabase.secret_key' => $tenant->metabase_secret]);
return Metabase::url($dashboardId, $params + ['tenant_id' => $tenant->id]);
}
}
Bind the package's contract/instance in a service provider if it's concrete, so tests can swap it.
Testing. Because 0.4.2 uses firebase/php-jwt, you can now decode and assert on the generated token directly in feature tests without pulling in a builder API:
$url = Metabase::url(1, ['tenant_id' => 'acme']);
parse_str(parse_url($url, PHP_URL_FRAGMENT) ?? '', $frag); // or query, per Metabase's format
$claims = JWT::decode($frag['token'], new Key(config('metabase.secret_key'), 'HS256'));
$this->assertSame(1, $claims->resource->dashboard);
$this->assertSame('acme', $claims->params->tenant_id);
That's the cheapest regression test for "someone broke row-level security by dropping the locked param."
Composition. Dashboards rarely live alone — pair the iframe with a nav that keys off dashboard IDs in config, and keep dashboard IDs in config/ or an enum rather than magic numbers sprinkled across Blade files.
POST /api/embed/dashboard/:token (or the embed HTML response) and its status. Decode the token locally to check exp, resource, and params.exp in the past = blank frame. Clock skew between your app server and Metabase can bite you. Keep TTLs modest (minutes) and don't round down aggressively.frame-src/frame-ancestors on your page can block the embed. Metabase's MB_EMBEDDING_APP_ORIGIN must match your Laravel origin.firebase/php-jwt specifics. This is a patch bump but a real dependency swap:
Lcobucci\JWT\* because it was transitively installed, they'll break — firebase/php-jwt has no builder; it's JWT::encode($payload, $key, 'HS256') and JWT::decode($token, new Key($key, 'HS256')). The algorithm argument is now required on encode.firebase/php-jwt throws SignatureInvalidException / ExpiredException on decode rather than returning null — catch explicitly if you decode anywhere.lcobucci/jwt at a version another package needs; Composer will now resolve it independently, which can shift a constraint. Run composer why lcobucci/jwt after upgrading to confirm nothing else depended on the old pin.firebase/php-jwt has a PHP floor (8.x for recent majors) — verify against your CI matrix if you support older PHP.config:cache a tenant-specific secret. If you mutate config() per request (multi-tenant pattern above), that's fine at runtime, but never bake a per-tenant secret into a cached config file.Lcobucci and for any class extending the package's signer before upgrading.^0.4.2 rather than ^0.4 if you want to review future signing changes deliberately.How can I help you explore Laravel packages today?