Building a Power BI Lite in a Day (and Renaming Everything Twice)
The Starting Point
Client needed a reporting module. Not a trivial one — "Power BI lite, but user-focused." The spec they handed me was comprehensive: whitelisted data sources, dashboards with drag-drop widgets, scheduled email exports, tokenised public share links, chart types covering bar/line/pie/gauge/KPI/table. The whole nine yards.
The spec assumed Laravel 11 with Livewire 3 and Tailwind. My reality: Laravel 12 with nwidart/laravel-modules v12, no Livewire installed, Blade views with ~550 lines of inline custom CSS in the layout file, vanilla JS and fetch() for everything, Chart.js loaded via CDN in blade templates, and vendor libraries served from public/vendor/ because production has no npm build step reliably running.
So step one was: spec vs reality check. No way I'm bolting Livewire onto an app that has zero Livewire. No way I'm rewriting the styling to use Tailwind utility classes when the entire app uses .card, .modal-overlay, .tab-btn custom classes. The question became: can I deliver the functionality from the spec using the app's existing patterns?
Yes, turned out.
What Actually Happened
Phase 1: Read the spec, ignore half of it
I spent the first hour mapping the spec's assumptions against what the app actually looks like. The spec wanted ApexCharts + GridStack, and those are fine — they're plain JS libraries. But every single UI pattern from the spec assumed Livewire components. I had to mentally translate every "Livewire component with reactive state" into "Blade view + vanilla JS class with AJAX endpoints."
Key decisions before writing a line of code:
- No Livewire. Use the app's existing
fetch()+ CSRF token pattern. - ApexCharts + GridStack served from
public/vendor/— curl them down, commit them, done. No npm build dependency for these. - Multi-tenancy: drop it entirely. The spec assumed
organisation_idscoping. This app is single-tenant for one customer. Just usecreated_by_user_idfor ownership. - Match existing CSS patterns. Every modal I built needed
.modal-overlay+.modal-contentwith the.activeclass toggle. Every form used.form-group+.form-control.
Phase 2: Database + models + registry
The core abstraction is a DataSourceRegistry. Developers register AbstractDataSource classes in a service provider. Each source declares whitelisted fields as FieldDefinition value objects. The whole thing syncs to a report_datasources table on boot via sync().
This is the security boundary. Users never type column names or SQL. They pick fields from a dropdown that's been explicitly exposed by a developer. The ReportRunner validates every field slug in a request against the whitelist before touching the query builder. No DB::raw() with user input. No whereRaw(). Operator whitelist (= != > < >= <= in not_in between like not_like null not_null). Aggregate function whitelist (count sum avg min max). Hard cap of 50k rows.
Tested it with an SQL injection attempt in a filter value — parameterised queries swallowed it cleanly, returned 0 rows, no error. Good.
I built 7 migrations and 6 Eloquent models. Three data sources to start: Accounts, Sites, Cases. Each with field definitions covering direct columns, relation columns (account_name via account.name), and count fields (sites_count via withCount).
Phase 3: Report builder UI
Most complex view in the module. A tabbed Blade page: Columns, Filters, Group & Aggregate, Sort & Limit, Preview. Left panel shows the whitelisted fields with type badges. Right panel has the config tabs.
The filter tree was the interesting bit. Recursive AND/OR with arbitrary nesting, rendered entirely client-side as DOM nodes. Each condition row is field select → operator select → value input, where the operator list is filtered by field type (dates get "before/after", enums get "in/not in", strings get "contains", etc.) and the value input adapts (date picker for dates, multi-select for "in" operators, text for strings).
Built it with a single JS class, no framework. Serialises back to JSON on save by walking the DOM tree. Works fine.
Phase 4: First rename — axios → fetch
Wrote the whole builder using axios.post() and axios.put(). Then realised halfway through testing that the app has no Vite-built app.js included in the layout. The existing codebase uses fetch() everywhere — there's no axios globally available in the browser. The app's bootstrap.js imports axios but app.js isn't even loaded from the layout (checked: no @vite directive anywhere).
So I did a bulk find-replace from axios.post(...) to fetch(url, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(...) }) across the builder, dashboard, and index views. The error handling shape is different too (no .response?.data?.error, instead unwrap r.json() with an ok flag). Pattern I ended up with:
fetch(url, { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload) })
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok) { alert(data.error || 'Failed'); return; }
// success handling
});
Not as clean as axios, but consistent with the rest of the app. And no new dependency.
Phase 5: Dashboards and widgets
GridStack for drag-drop layout, ApexCharts for visualisations. Downloaded both from CDN into public/vendor/:
curl -sL "https://cdn.jsdelivr.net/npm/[email protected]/dist/gridstack-all.js" \
-o public/vendor/gridstack/gridstack-all.js
curl -sL "https://cdn.jsdelivr.net/npm/[email protected]/dist/apexcharts.min.js" \
-o public/vendor/apexcharts/apexcharts.min.js
Matches the DHTMLX pattern already used elsewhere in the app. Production deployments don't need npm to work.
Widget types: table, KPI, bar, line, area, pie, donut, gauge. WidgetRenderer service transforms query results into the shape each widget type wants (categories + series for bar/line, labels + series for pie, percentage for gauge, etc.). Auto-detects which field is the category axis vs. value axis if admin doesn't specify.
First bug: widget modal showed unstyled inline at the bottom of the page instead of as an overlay. Took me a minute to figure out why. The .modal-overlay CSS isn't in the layout file — each page that uses a modal defines the modal CSS in its own @section('styles') block. That's the existing convention. Copied the CSS block to the dashboard view. Same issue hit again on the report builder. Copied it there too.
Lesson: when there's no global CSS framework, CSS lives where the component does. Annoying but consistent.
Phase 6: First rename — "Reports" → "Report Builder"
Client came back: "Report Builder shouldn't be visible to regular users, only admins. Make it admin-only. Then regular users should see a different navigation that exposes curated Report Pages that combine reports and dashboards."
So I needed three layers:
- Report Builder (admin-only, at
/report-builder/) — the creation tools - Report Pages (new concept) — admin-curated pages mixing report tables and dashboard widgets
- Reports nav dropdown (all users) — the consumer-facing menu with nested folders
The existing routes all used names like reporting.index, reporting.reports.show, reporting.dashboards.edit. I had to rename all of them to report-builder.*. Did a bulk sed across the views:
find Modules/Reporting/resources/views -name "*.blade.php" \
-exec sed -i '' "s/route('reporting\./route('report-builder./g" {} +
Plus the hardcoded AJAX paths in JavaScript:
find Modules/Reporting/resources/views -name "*.blade.php" \
-exec sed -i '' "s|'/reporting/api/|'/report-builder/api/|g" {} +
Then the client refreshed and got:
[2026-04-21 15:41:17] local.ERROR: Route [reporting.index] not defined.
(View: /app/resources/views/layouts/app.blade.php)
The layout was still referencing reporting.index. Except I'd already changed it. But Laravel was caching the compiled views. Ran php artisan optimize:clear and npm run build. Fixed.
Client refreshed again: same error. This happened twice. Both times the fix was the same cache clear. Once the cache was cold, everything worked.
Phase 7: The menu system and the flyout trap
Built a self-referential report_menu_items table with parent_id for unlimited nesting. Items are either folder (container) or page (links to a Report Page). Plus a type: dashboard added later for linking to dashboards directly.
Rendering unlimited-depth nested menus in a nav bar is more fiddly than it sounds. The existing CRM and WMS sub-navs are one level deep (click parent → sub-nav slides down with flat list). For nested folders I went with CSS-only flyout sub-menus: position: relative on parent <li>, position: absolute; left: 100%; top: 0 on child <ul>, display controlled by :hover. Recursive Blade partial does the rendering.
Phase 8: The toggle JS bug that nearly bit me
The existing navigation toggle JS was doing this:
function toggleCrmNav() {
var parents = document.querySelectorAll('.nav-parent');
var label = parents[0].querySelector('.nav-parent-label');
// ...
if (isOpening) {
parents[1].classList.remove('open'); // hardcoded index!
}
}
Two dropdowns (CRM and WMS), so parents[0] was CRM and parents[1] was WMS. It worked. But adding a third Reports dropdown would shift the indices — WMS would no longer be parents[1], Reports would be. The toggles would silently target the wrong elements.
Refactored to ID-based:
function closeAllNavs() {
['crmSubNav', 'wmsSubNav', 'reportsSubNav', 'dashSubNav'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('open');
});
// ...
}
function toggleNav(navId, parentId) { /* ... */ }
function toggleCrmNav() { toggleNav('crmSubNav', 'crmNavParent'); }
function toggleWmsNav() { toggleNav('wmsSubNav', 'wmsNavParent'); }
function toggleReportsNav() { toggleNav('reportsSubNav', 'reportsNavParent'); }
Added matching id="crmNavParent" etc. to the existing <li> elements. Mutual exclusivity now works for N dropdowns without relying on array order. This is the kind of bug that would have silently broken when nobody was watching.
Phase 9: Where do dashboards live?
Final pivot. Auto-categorisation had been working: adding a Dashboard via the Menu Builder automatically created a "Dashboards" folder under the Reports dropdown and placed the item there. Reports pages went under a "Reports" folder. Clean, organised.
Client: "Wait, dashboards shouldn't be under Reports. They should be under the top-level Dashboard nav item."
Problem: Dashboard was a flat link (<a href="/dashboard">Dashboard</a>), not a dropdown. Turned it into a dropdown that's only a dropdown when there are published dashboards — otherwise falls back to a flat link:
@if($dashboardMenuItems->count() > 0)
<li class="nav-parent" id="dashNavParent">
<span class="nav-parent-label" onclick="toggleDashNav()">
Dashboard <svg class="chevron">...</svg>
</span>
</li>
@else
<li><a href="{{ route('dashboard') }}">Dashboard</a></li>
@endif
When the dropdown opens: "Home" (system dashboard) first, then all published report dashboards. Auto-categorisation now routes dashboard menu items to top level (no folder needed), and report page items into a "Reports" folder under the Reports dropdown.
The dashboard viewer view already existed from the admin Report Builder. I added a route guard so non-admins view it via /reports/dashboard/{id} without seeing edit buttons or "Back to Dashboards" links:
@php $isPublicView = request()->routeIs('reports.dashboard'); @endphp
@if(!$isPublicView)
<a href="..." class="back-link">Back to Dashboards</a>
@endif
Same view, two different contexts. Widget AJAX URLs also branch by context:
const widgetDataBase = isPublicView
? '/reports/dashboard/' + dashboardId + '/widget/' + widgetId + '/data'
: '/report-builder/api/widgets/' + widgetId + '/data';
What Worked
Full module delivered:
- 3 data sources registered (Accounts, Sites, Cases; Asset Audits added later as an example)
- 12 controllers, 11 migrations, 9 Eloquent models (original reporting models + 3 new for pages/items/menu)
- 44 admin routes + 2 public routes for viewing published content
- Report Builder at
/report-builder/with report definition CRUD, dashboard CRUD, Report Page CRUD, Menu Builder - Published Pages at
/reports/{slug}(mix of tables and charts, read-only for non-admins) - Nav dropdown with unlimited nesting, auto-categorisation (report pages → "Reports" folder; dashboards → top-level Dashboard dropdown)
- CSV, XLSX, PDF exports for any saved report
- Scheduled reports via artisan command + cron expression parser + mailable
- Public tokenised share links for dashboards (no auth required)
- Admin settings page for toggling data sources on/off and enabling/disabling the module
The DataSourceRegistry pattern turned out to be genuinely extensible. Adding a new data source is:
- Write one class extending
AbstractDataSourcewithslug(),name(),modelClass(), and afields()array ofFieldDefinitionobjects. - Register it in the service provider with
$registry->register(new WhateverDataSource()).
The sync() call on boot upserts the DB row. New source appears in the builder dropdown and on the admin page automatically.
What I Learned
-
Cache-aware Laravel apps have delayed feedback loops. When you change route names, views that call
route('old.name')throwRouteNotFoundExceptionat render time, not boot time. If the view is cached, you don't see the error until the cache clears. Changed the route, worked for me, client got a 500.php artisan optimize:clearin your back pocket always. -
Hardcoded array indices in nav toggle JS are a time bomb.
querySelectorAllindex order is DOM order, which shifts every time you add an element. IDs are cheap. Use them. -
Nested flyout menus in CSS alone work fine up to 3-4 levels deep.
position: absolute; left: 100%; top: 0on the submenu,:hoveron the parent.has-submenu. No JS needed. Works for "unlimited nesting" in practice because nobody actually builds 6-level-deep menus. -
When there's no global CSS framework, accept that modal styles live with the modal. The copy-paste is annoying, but trying to centralise CSS after the fact means fighting the existing convention. Pick your battles.
-
Axios vs fetch: use whatever the app uses. The codebase had 0 axios usages and dozens of fetch calls. I wasted time writing the builder in axios only to rewrite it all in fetch. Read the codebase conventions before you write code in it.
-
"Power BI lite" is achievable in a session if the abstractions are right. The
DataSourceRegistry+ReportRunner+WidgetRenderersplit meant I could add features without touching the query engine. Dashboard → widget → chart was three distinct transformations, each testable on its own. -
Scope renames are not free. Renaming
reporting.*routes toreport-builder.*touched: routes file, controllers, views (every blade template), JavaScript (hardcoded AJAX URLs), sidebar partials, admin page links. Sed handled most of it but three caches had to be cleared and I had to find references I'd missed in JS string literals. Budget for this. -
A 2-dropdown nav can ship with hardcoded array indices. A 3-dropdown nav cannot. Every growth point exposes lazy assumptions from the earlier, simpler version.
Tech Notes
Stack:
- Laravel 12 with nwidart/laravel-modules v12 — 19 modules now including the new one
- Blade + vanilla JS +
fetch()— no Livewire, no Vue, no React - ApexCharts 4.5.0 from
public/vendor/apexcharts/— gauges viaradialBar, all other chart types out of the box - GridStack 10.3.1 from
public/vendor/gridstack/— drag-drop dashboard grid - PhpOffice/PhpSpreadsheet for XLSX exports, barryvdh/laravel-dompdf for PDF exports (both were already installed)
- Streaming
fputcsvfor CSV exports so large reports don't blow up memory
Patterns that scaled:
AbstractDataSourcewith afields(): arraymethod returningFieldDefinitionvalue objectsCache::remember("reporting:widget:{$id}:" . md5($filters), 300, fn() => ...)for widget data- Recursive Blade partials for self-referential menu trees (
@each('partial', $children, 'item')) - Route binding middleware stack for multi-context views (
route-builder.*for admin,reports.*for public) sharing the same Blade
Commands worth remembering:
php artisan optimize:clear— when route renames fail silently, this is always the first thing to runphp artisan module:enable Reporting— don't forget this aftercomposer dump-autoloadfinds the new modulecurl -sL "https://cdn.jsdelivr.net/npm/<pkg>@<ver>/dist/<file>" -o public/vendor/<pkg>/<file>— vendor libs without npmfind <dir> -name "*.blade.php" -exec sed -i '' "s/old/new/g" {} +— bulk rename across a module's views
Zero Focus Dev
Breaking things, learning fast, sharing it all.