Turn a handful of SQL queries into one self-contained HTML file.
No CDN. No <script>. No build step. No dashboard server to keep alive. Just a file you
can email to a director, attach to a ticket, or archive for an auditor — and it will still
open correctly on a laptop with no network in five years.
Reporting tools are excellent until the report has to leave the building.
A BI dashboard behind SSO cannot be emailed. A React dashboard needs a build and a host. A CSV export loses every bit of framing that made the numbers mean something. And a chart library loaded from a CDN quietly becomes a blank rectangle the day that CDN is blocked by a corporate proxy — which is exactly the day the client opens it.
So: run the queries, inline everything, hand over one file.
┌── deals.sql ──┐
│ KPI │ Dashboard::make($pdo, 'Sales')
│ trend │ ──▶ ->add(Kpi …) ──▶ sales.html (8 KB, opens anywhere)
│ breakdown │ ->add(LineChart …)
└───────────────┘ ->writeTo('sales.html')
composer require neeraj-patel/sql-dashboard-builderuse NeerajPatel\SqlDashboard\Dashboard;
use NeerajPatel\SqlDashboard\Widgets\{Kpi, Table, BarChart, LineChart};
$pdo = new PDO('mysql:host=localhost;dbname=crm', $user, $pass);
Dashboard::make($pdo, 'Sales Performance')
->subtitle('All regions · financial year to date')
->add(Kpi::of('Open pipeline', "SELECT SUM(amount) AS value FROM deals WHERE status='open'")
->format('crore'))
->add(Kpi::of('Won this month', "
SELECT SUM(CASE WHEN month = :now THEN amount END) AS value,
SUM(CASE WHEN month = :prev THEN amount END) AS previous
FROM deals WHERE status = 'won'
", ['now' => '2026-09', 'prev' => '2026-08'])
->format('crore')
->accent('#0ca30c'))
->add(LineChart::of('Won value by month', "
SELECT month AS label, SUM(amount) AS value
FROM deals WHERE status='won' GROUP BY month ORDER BY month
")->format('crore'))
->add(Table::of('Product performance', "
SELECT product, COUNT(*) AS deals, SUM(amount) AS value
FROM deals GROUP BY product ORDER BY value DESC
")
->formats(['deals' => 'number', 'value' => 'crore'])
->bars(['value']))
->writeTo(__DIR__ . '/reports/sales.html');Run the bundled example to see the output without wiring up a database:
composer install
php examples/sales-dashboard.php
# Wrote examples/output/sales.html (8.2 KB)| Widget | Expects | Notes |
|---|---|---|
Kpi |
one row with value, optionally previous |
renders the headline number and an up/down delta |
Table |
any result set | column headings, number formats, in-cell data bars |
BarChart |
label, value |
horizontal bars as inline SVG |
LineChart |
label, value, ordered oldest first |
trend line with optional area fill |
Every widget takes bindings, which are passed straight to a prepared statement:
Kpi::of('Won in region', 'SELECT SUM(amount) AS value FROM deals WHERE region = :region',
['region' => $region]);number · inr (Indian grouping — 12,34,567) · crore (12.35 Cr) · compact
(auto Cr / L / K) · percent
Widgets drop into a 12-column grid. Kpi takes 3 columns, everything else defaults to 6
and accepts ->width('third' | 'half' | 'full'). Below 900px it collapses to a single
column, and there is a print stylesheet so a widget never splits across a page.
Everything inlined. The promise is one portable file, so there is no chart library and
no web font — charts are hand-drawn SVG and the CSS ships in a <style> block. The example
dashboard with four KPIs, two charts and two tables is 8 KB.
Escape at the boundary. Report data is customer-entered text: company names with &,
addresses with <. Every value goes through htmlspecialchars on its way into the page,
and there is a test that proves a <script> tag stored in the database comes out inert.
Queries run in sequence. A report build is not the place to hold a dozen connections open. This is designed to run from a small cron slot, so it walks the widgets one at a time.
Dark mode by media query, not by toggle. No JavaScript means no toggle — the page
follows the reader's system setting through prefers-color-scheme.
export SQLDASH_DSN="mysql:host=localhost;dbname=crm"
export SQLDASH_USER=reporting
export SQLDASH_PASS=secret
vendor/bin/sqldash reports/sales.php public/sales.htmlWhere reports/sales.php returns a callable:
<?php
return fn (PDO $pdo) => Dashboard::make($pdo, 'Sales')->add(/* … */);Pair it with a nightly cron entry and the report rebuilds itself.
composer test14 tests, 32 assertions — covering rendering, deltas, data bars, empty result sets, escaping, number formatting and file output.
PHP 8.1+ with PDO. Works with any PDO driver — the examples and tests use SQLite, and it is used against MySQL in anger.
MIT — see LICENSE.