zenstruck/dashboard-bundle
A Symfony bundle from zenstruck for adding dashboards to your application. It provides building blocks to assemble dashboard pages that surface your app's data in one place, following standard Symfony conventions. Suited to admin panels and internal tools.
Reality check first. zenstruck/dashboard-bundle is a Symfony2 bundle, not a Laravel package. Its last release was 2013-12-11, it has 3 stars, 0 dependents, and a composite score of 0.175. It targets PHP 5.3/5.4, Symfony ~2.2, and Twig 1.x. You cannot composer require it into a Laravel app: it registers through AppKernel/Bundle classes, wires services via Resources/config/*.xml, and depends on Symfony 2.x EventDispatcherInterface and DependencyInjection contracts that Laravel does not satisfy.
So the only realistic "first step" is to read it as a design spec, then reimplement the two ideas it actually delivers — a config-driven admin menu and a widget grid — in Laravel idioms.
Where to look (5–10 min read):
ZenstruckDashboardBundle.php # bundle entry point
DependencyInjection/Configuration.php # the config tree = your config/dashboard.php
DependencyInjection/*Extension.php # service wiring = your ServiceProvider
Resources/config/*.xml # container tags = Laravel container tags
Resources/views/ # Twig layout + widget partials = Blade components
README.md # the YAML config example
First use case. Copy the config shape into a Laravel config file and stand up a DashboardServiceProvider that resolves a list of widget classes. That is 90% of the package's value and takes an afternoon:
// config/dashboard.php
return [
'menu' => [
['label' => 'Users', 'route' => 'admin.users.index', 'icon' => 'users', 'can' => 'viewAny,App\Models\User'],
['label' => 'Orders', 'route' => 'admin.orders.index', 'icon' => 'cart', 'can' => 'viewAny,App\Models\Order'],
],
'widgets' => [
\App\Dashboard\Widgets\NewUsersWidget::class => ['priority' => 10],
\App\Dashboard\Widgets\RevenueWidget::class => ['priority' => 20],
\App\Dashboard\Widgets\RecentOrdersWidget::class => ['priority' => 30],
],
];
Then render with a Blade layout that loops config('dashboard.menu') for the sidebar and the resolved widgets for the grid.
The bundle's architecture maps cleanly onto Laravel constructs. Use this table as your translation key:
| Symfony bundle idiom (2013) | Laravel equivalent |
|---|---|
Configuration.php tree + YAML |
config/dashboard.php + mergeConfigFrom() |
| XML service definitions | ServiceProvider::register() + $this->app->bind() |
Service tags (dashboard.widget) |
Container tags + $this->app->tagged() |
| Twig widget partials | Blade components / @include |
| Event dispatcher hooks | Laravel events, or a WidgetCollection |
| Menu builder service | A plain Menu value object injected into the layout |
1. Widget contract + tagged collection. This is the core pattern worth stealing:
namespace App\Dashboard;
interface Widget
{
public function label(): string;
public function render(): \Illuminate\Contracts\View\View|\Illuminate\Support\HtmlString;
public function priority(): int;
public function canView(\App\Models\User $user): bool;
}
// App\Providers\DashboardServiceProvider
public function register(): void
{
$this->mergeConfigFrom(base_path('config/dashboard.php'), 'dashboard');
$this->app->singleton(WidgetCollection::class, function ($app) {
$map = $app['config']->get('dashboard.widgets', []);
return new WidgetCollection(
collect($map)->map(function ($opts, $class) use ($app) {
return $app->make($class, is_array($opts) ? $opts : []);
})->all()
);
});
}
2. Ordering by priority, ties broken deterministically. The original used a priority integer; do the same but sort stable on [priority, class] so two widgets with priority 10 don't reorder between requests:
return collect($widgets)
->sortBy(fn (Widget $w) => sprintf('%05d-%s', $w->priority(), $w::class))
->values();
3. Permission-gated menu + widgets. Rather than the bundle's all-or-nothing menu, gate each entry. This is the single biggest day-to-day improvement over the original:
{{-- resources/views/layouts/admin.blade.php --}}
<aside class="w-56 shrink-0">
@foreach (config('dashboard.menu') as $item)
@if (! isset($item['can']) || Gate::allows(...explode(',', $item['can'])))
<a href="{{ route($item['route']) }}"
class="{{ request()->routeIs($item['route'].'*') ? 'bg-gray-800' : '' }}">
{{ $item['label'] }}
</a>
@endif
@endforeach
</aside>
4. Failure isolation. A single widget throwing must not blank the dashboard. Wrap render, log, and degrade:
public function render(WidgetCollection $widgets, Request $request)
{
$visible = $widgets->filter(fn (Widget $w) => $w->canView($request->user()));
$rendered = $visible->map(function (Widget $w) {
try {
return ['widget' => $w, 'html' => $w->render()->render()];
} catch (\Throwable $e) {
report($e);
return ['widget' => $w, 'html' => view('dashboard.widget-error', ['widget' => $w])];
}
});
return view('dashboard.index', compact('rendered'));
}
5. Cache per user, not per role. Widget queries are the expensive part; a Cache::remember keyed on user_id avoids leaking data across users while keeping the hit rate reasonable:
$html = Cache::remember("dash:{$user->id}:{$w::class}", now()->addMinutes(5),
fn () => $w->render()->render());
6. Extension points for your own project. Rather than a service tag, expose a dashboard.register hook in a service provider so feature modules can push widgets/menu items without editing config:
// in a feature module's ServiceProvider::boot()
Dashboard::menu('Billing', route: 'admin.billing.index', icon: 'card');
Dashboard::widget(BillingSummaryWidget::class, priority: 15);
That is the "plugin" behaviour the Symfony bundle got from DIC tags, expressed the Laravel way.
7. When not to build it. If this is a real admin panel, use Filament (->widgets([...]) on the page class is the exact same concept, with auth, caching, and a resource layer included), Laravel Nova, or Backpack. Reimplementing this bundle's three screens is defensible only when you already have a bespoke Blade admin and just need the config-driven grid.
Do not attempt to install it. Composer will fail to resolve on PHP 8.x: symfony/symfony:~2.2, Twig 1.x, and the Bundle/AppKernel registration model all conflict. Even with --ignore-platform-reqs you'd be shipping an unpatched 2013 dependency tree into production. There is no route from this package to a modern Laravel app; treat the tarball as source-of-inspiration only.
The vendor/repo names disagree. Packagist is zenstruck/dashboard-bundle, the canonical repo is kbond/ZenstruckDashboardBundle. Older tutorials reference zenstruck/dashboard — same code, different era. If you Google for docs, expect German blog posts from 2012 and Symfony 2.1–2.3 API references.
Abandonment signals to weigh. 3 stars, 0 dependents, no releases in 12 years, no security policy, no PHP 8 support. Any bug you hit is yours to fix; any CVE in its transitive deps is your responsibility. That alone justifies the reimplementation.
Event dispatcher is not a bridge point. Laravel's dispatcher implements Illuminate\Contracts\Events\Dispatcher (and PSR-14 in newer versions), not Symfony 2.x's EventDispatcherInterface. Do not try to shim it — you'll burn a day on signature mismatches before giving up.
Config-tree quirks to carry over (carefully). The bundle's YAML used implicit defaults per widget; Laravel's mergeConfigFrom only merges one level deep. If your dashboard.widgets array is associative, publishing an overriding config file will replace rather than merge. Do the merge yourself:
$widgets = array_replace_recursive(
require __DIR__.'/../../config/dashboard.php',
config('dashboard', [])
);
Ordering ties are a real bug source. Two widgets at the same priority will swap positions across deploys if you sort only on priority(). Always add a stable secondary key (class name or explicit order).
N+1 in widgets is the number-one performance trap. Ten widgets each running User::count(), Order::where(...)->count(), Order::latest()->take(5)->get() is 30 queries before the page paints. Batch with a single DashboardMetrics service injected into widgets, and cache the result for the request or for 5 minutes.
Cache keys collide across roles. If you cache on role instead of user_id, admins and support staff will see each other's data. Key on the user (or on a hash of the gate decisions) — never on the role name.
Widget render failures should be visible, not silent. Log with report($e) and render a placeholder card labelled with the widget name; a silently missing widget is far harder to debug than a red box.
Debugging in the Symfony original (if you're reading a fork in a legacy app): bin/console debug:container --tag=dashboard.widget lists registered widgets, and YAML config errors surface as InvalidConfigurationException at kernel boot, not at render time.
Debugging your Laravel port: php artisan about for config cache state, app(WidgetCollection::class) in tinker to check resolution, and php artisan config:clear — a stale bootstrap/cache/config.php makes config('dashboard.*') edits appear to do nothing, which is the most common "it's broken" report you'll get from a teammate.
Extension point to keep open. Whatever you build, expose widget registration through a facade or a dashboard.widget container tag rather than hardcoding the list. The moment a second developer wants to add "signups this week", you want them adding one class and one line, not editing a controller.
How can I help you explore Laravel packages today?