amjadiqbal / laravel-sql-dashboard
Read-only SQL dashboard for Laravel (Blazer for Laravel): saved SQL queries with typed variables, result tables with CSV export, line, bar and pie charts, dashboards, scheduled checks that alert by mail or Slack, team sharing and an audit log. Runs only on a read-only connection, one SELECT at a tim
Package info
github.com/amjadiqbal/laravel-sql-dashboard
pkg:composer/amjadiqbal/laravel-sql-dashboard
Requires
- php: ^8.3
- ext-json: *
- ext-pdo: *
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/mail: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
- symfony/process: ^7.2|^8.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel SQL Dashboard: read-only SQL queries, charts and alerts for Laravel
A self-hosted SQL dashboard for your Laravel app, in the spirit of Rails' Blazer. Your team saves SQL queries with typed variables, sees the results as a table and a line, bar or pie chart, downloads them as CSV, puts several on a dashboard, and gets an email when a scheduled check returns rows or crosses a threshold (Slack too, untested; see below). Every run is in an audit log with who ran it.
It is built to be safe to leave switched on: queries run only on a separate read-only connection, one
SELECT or WITH statement at a time, inside the database's own read-only transaction, with a row limit and
a statement timeout on MySQL, MariaDB, PostgreSQL and SQLite. A connection that can write is refused. Variables
are always bound as parameters. Until you define the gates, the dashboard opens only when
app()->environment('local') is true; every other environment gets a 403.
Docs and demo: https://amjad.com.pk/laravel/packages/laravel-sql-dashboard
Features
- Saved queries with a name, description and owner, shared with the team or kept private.
- Typed variables: write
{since}or{status}in the SQL and pick a type (text, whole number, number, date, date and time, yes/no, one of a list). Values are checked against the type and bound as parameters. Dates accepttoday,yesterdayor-7 days, so a saved query or check always looks at a fresh window. - Results as a table with a CSV export (formula cells neutralised so a spreadsheet can't run them).
- Charts picked from the result's shape: a date then numbers draws a line, a label then numbers draws bars,
a column named
piedraws a pie. Or choose line, bar, pie or table per query. Drawn as SVG on the server: no JavaScript library, no CDN. - Dashboards of several queries, with one form for the variables they share.
- Scheduled checks every 5 minutes, hour or day: fail when the query returns any row, or when a value in the first row crosses a threshold. Mail alerts when a check starts failing, passes again or errors, and Slack alerts (untested, see below).
- Audit log of every run, export, check and refused attempt: who, when, the SQL, the variables, rows, time and outcome.
- Gates for viewing, running and editing, closed by default outside
local.
Installation
composer require amjadiqbal/laravel-sql-dashboard php artisan migrate
Requires PHP 8.3+ and Laravel 12 or 13. The tables it creates (sql_dashboard_*) hold saved queries,
dashboards, checks and the audit log, on your default connection (or SQL_DASHBOARD_STORAGE_CONNECTION).
1. Add a read-only connection
The dashboard never runs on your app's own connection. Create a database user that can only read, add a
connection for it in config/database.php, and point the dashboard at it:
// config/database.php, in 'connections' 'dashboard' => [ 'driver' => 'mysql', // or mariadb, pgsql 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE'), 'username' => env('DASHBOARD_DB_USERNAME'), 'password' => env('DASHBOARD_DB_PASSWORD'), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', ],
SQL_DASHBOARD_CONNECTION=dashboard
MySQL / MariaDB
CREATE USER 'dashboard'@'%' IDENTIFIED BY 'a-long-password'; GRANT SELECT, SHOW VIEW ON your_app.* TO 'dashboard'@'%';
PostgreSQL
CREATE ROLE dashboard LOGIN PASSWORD 'a-long-password'; GRANT USAGE ON SCHEMA public TO dashboard; GRANT SELECT ON ALL TABLES IN SCHEMA public TO dashboard; ALTER DEFAULT PRIVILEGES FOR ROLE your_migration_role IN SCHEMA public GRANT SELECT ON TABLES TO dashboard;
SQLite: open the same file read-only.
'dashboard' => [ 'driver' => 'sqlite', 'database' => database_path('database.sqlite'), // PHP 8.3: [PDO::SQLITE_ATTR_OPEN_FLAGS => PDO::SQLITE_OPEN_READONLY] 'options' => [Pdo\Sqlite::ATTR_OPEN_FLAGS => Pdo\Sqlite::OPEN_READONLY], ],
Then check it:
php artisan sql-dashboard:verify
Connection: dashboard
Driver: MySQL / MariaDB
Limits: 15s timeout, 1000 rows
Ready: the connection is read-only and SELECT 1 returned 1 in 9 ms.
2. Decide who can use it
Open /sql-dashboard. While app()->environment('local') is true you are let in; in every other environment
every page is a 403 until you define the three gates, for example in AppServiceProvider::boot():
use Illuminate\Support\Facades\Gate; Gate::define('viewSqlDashboard', fn ($user = null) => $user?->is_admin || $user?->is_analyst); Gate::define('runSqlDashboardQueries', fn ($user = null) => $user?->is_admin || $user?->is_analyst); Gate::define('editSqlDashboardQueries', fn ($user = null) => (bool) $user?->is_admin);
| Gate | Lets someone |
|---|---|
viewSqlDashboard |
open the dashboard and see saved queries and dashboards |
runSqlDashboardQueries |
run saved queries and dashboards, download CSV |
editSqlDashboardQueries |
write SQL, manage dashboards and checks, read the audit log |
Writing SQL also needs a signed-in user, whatever your gate returns: raw SQL never comes from a guest.
3. Run the scheduler for checks
Checks need Laravel's scheduler, which most apps already run:
* * * * * cd /path-to-your-app && php artisan schedule:run >> /dev/null 2>&1
The package schedules sql-dashboard:run-checks every minute (each check runs at its own interval) and
sql-dashboard:prune-audit daily. For Slack, create an incoming webhook and set
SQL_DASHBOARD_SLACK_WEBHOOK_URL. The webhook only comes from your environment, never from the browser.
Slack delivery is implemented and tested only against a faked HTTP client, not a real Slack workspace yet;
please open an issue if it doesn't arrive.
Using it
Queries. Press New query, write SQL, press Run (or Ctrl/⌘ + Enter) to see the result, then Save. Untick Share with the team to keep a query private.
SELECT day, count(*) AS orders, sum(total) AS revenue FROM orders WHERE status = {status} AND day >= {since} GROUP BY day ORDER BY day
Each {variable} gets a type, a label, a default and (for lists) its options. Here {status} could be a list
of paid, refunded, failed and {since} a date defaulting to -30 days. A {variable} inside quotes is
plain text; the editor tells you. Variables stand for values only, never for table or column names.
Charts. The first column decides: a date gives a line per number column (or per label, for
date, label, number); a label gives bars. Name the number column pie
(SELECT country, count(*) AS pie FROM customers GROUP BY country) for a pie chart. Up to 500 points and 8
series are drawn.
Dashboards. Pick queries to show together. Variables with the same name share one input at the top.
Checks. On a query, press Add check. A check runs the query with each variable's default value (so every variable needs one) and fails when:
- any row is returned: write the query to find bad data, e.g.
SELECT id, email FROM users WHERE email_verified_at IS NULL AND created_at < {week_ago} - a value crosses a threshold in the first row, e.g.
SELECT count(*) AS failed FROM failed_jobsabove0.
You get one alert when it starts failing, one when it passes again, and one if it errors, not one every run.
Audit log. Audit log lists every run with who, when, from where (page, editor, dashboard, export, check, console), the SQL, the variable values, rows, milliseconds and the outcome, including refused attempts.
Security
The dashboard runs SQL typed into a browser. It protects your data with three checks: a dialect-aware tokenizer (not a full SQL parser), the database's own read-only transaction, and a check that the database user cannot write. A write to your tables is stopped by the read-only transaction and by the user's privileges, each on its own. Side effects that a read-only transaction does not cover (reading server files, locks that outlive the transaction, opening another connection) are stopped by the tokenizer's deny-list, backed by the user check refusing roles that have those powers. It is not a sandbox: see the limits below.
- The statement allow-list (a tokenizer). It reads SQL the way your database does (strings, quoted
names, nested PostgreSQL comments, dollar quotes, MySQL's
--and#rules) and checks that it is oneSELECTorWITHstatement. It refusesINSERT,UPDATE,DELETE,MERGE, DDL,INTO(includingINTO OUTFILE), locking reads (FOR UPDATE,FOR SHARE), a second statement after a semicolon, writes hidden in a CTE (WITH x AS (DELETE ... RETURNING *) SELECT ...), raw?,$1or:nameparameters, MySQL executable comments (/*! ... */), PostgreSQLU&"..."escaped names, and a deny-list of functions with side effects (set_config, advisory locks,nextval,pg_read_fileand the other server-file functions, large objects,dblink,GET_LOCK,LOAD_FILE,load_extensionand more), however the name is written: bare,"quoted",`quoted`,[quoted]or schema-qualified. Where text could be read two ways, it refuses rather than guesses. It is a deny-list, not an allow-list of safe functions, because an allow-list would refuse your own functions and extensions; the next two checks are what make a missed name harmless. - The database's own read-only mode. MySQL and MariaDB run each query in
START TRANSACTION READ ONLY, PostgreSQL inBEGIN READ ONLYbehindDECLARE ... CURSOR(which only accepts a query), and SQLite opens the file read-only withquery_onlyon, in a separate process (on PHP 8.5, SQLite's authorizer also allows only reads). The database itself refuses a write, even one the tokenizer missed; the test suite proves this by sending writes straight to this layer with an admin login. After each query the dashboard releases any advisory (PostgreSQL) orGET_LOCK(MySQL) lock the session took. - A user that cannot write or reach outside the database. Before running, the dashboard checks the
connection's user. On MySQL and MariaDB every privilege in
SHOW GRANTSmust be a read privilege (SELECT,SHOW VIEW, ...;FILE,PROCESS,SUPERand roles are refused). On PostgreSQL the role must not be a superuser, must not be a member ofpg_read_server_files,pg_write_server_files,pg_execute_server_programorpg_signal_backend, must not be able to executedblinkor the server-file functions, must not haveUSAGEon a foreign server, and must not own, or holdINSERT,UPDATE,DELETEorTRUNCATEon, any table. On SQLite the connection must open the file read-only. Otherwise nothing runs. A read replica (super_read_only, or a PostgreSQL standby) skips the write checks but not the others. The dashboard also refuses the connection it stores its own data on.
And around them:
| Limit | MySQL | MariaDB | PostgreSQL | SQLite |
|---|---|---|---|---|
| Statement timeout | max_execution_time |
max_statement_time |
statement_timeout, lock_timeout |
runs in a child process that is killed |
| Row limit | sql_select_limit + unbuffered fetch |
same | server-side cursor, fetched in batches | stops reading |
| Size limit | stops reading at 16 MB | same | same | same |
Variables are bound as parameters by PDO, never put into the SQL. CSV exports neutralise formula cells.
Alert e-mails contain up to alerts.rows_in_alert (10) result rows: limit who can receive them with
alerts.mail_domains.
Private queries and dashboards are invisible (404) to everyone but their owner. Session settings the
dashboard changes are put back after each run.
What it does not do:
- Hide columns. Anyone who can write SQL can read every table the read-only user can, so grant that user only the tables (or views) your team should see.
- Know your server's own add-ons. MySQL loadable functions (
CREATE FUNCTION ... SONAME) can be called by any user and cannot be seen from a read-only one; the tokenizer refuses the well-knownsys_execfamily, but don't install such functions on a server the dashboard reads. - Protect shared work from editors. Anyone who passes the
editSqlDashboardQueriesgate can edit or delete every shared query, dashboard and check, as in Blazer. Keep that gate for people you trust with the SQL; private queries stay private to their owner.
Configuration
php artisan vendor:publish --tag=sql-dashboard-config
| Key | Default | |
|---|---|---|
connection |
SQL_DASHBOARD_CONNECTION |
the read-only connection queries run on |
storage_connection |
default connection | where queries, dashboards, checks and the audit log live |
limits.timeout |
15 |
seconds before a statement is stopped |
limits.max_rows, limits.export_max_rows |
1000, 10000 |
rows shown in the browser, rows in a CSV export |
path, domain, middleware |
sql-dashboard, none, ['web'] |
where the pages live; the gates always run after your middleware |
schedule |
true |
register run-checks (every minute) and prune-audit (daily) |
alerts.slack_webhook_url |
SQL_DASHBOARD_SLACK_WEBHOOK_URL |
Slack incoming webhook (untested against real Slack) |
alerts.mail_domains |
[] (any) |
e.g. ['acme.com'] to keep alert mail inside the company |
alerts.rows_in_alert |
10 |
result rows included in an alert e-mail |
intervals |
5 minutes, hourly, daily | the check schedules people can pick |
audit.retention_days |
90 |
null keeps the audit log forever |
Commands: sql-dashboard:verify, sql-dashboard:run-checks [--check=ID] [--force] and
sql-dashboard:prune-audit [--days=N]. php artisan about shows the version, path and connection.
Testing
composer test # unit and feature tests, including real SQLite runs scripts/integration.sh # real MySQL 8.4, MariaDB 11 and PostgreSQL 16 in Docker: the security suite scripts/ci-local.sh # PHP 8.3-8.5 x Laravel 12-13, Pint, PHPStan, then the integration run
Need this built into your app, or something custom?
I build reporting, dashboards and data tooling for Laravel apps, from the SQL behind a KPI to alerting on bad data, and I build Laravel packages. If you want this set up for your team, or need something it doesn't do, get in touch.
Support
- 🐛 Bug or feature request: open an issue
- 🔒 Security issue: please report privately via GitHub's security tab
Need this customised, or something built?
I'm available for custom development, package integration, and technical consulting.
More packages
Part of a family of open-source packages. See all of them.
| laravel-mysql-advisor | Read-only MySQL and MariaDB health checks with the fix SQL |
| laravel-redis-advisor | Redis health checks for Laravel |
| kiln | Deploy-time OPcache control for Laravel |
| laravel-driver-js | Product tours and onboarding for Laravel |
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.md. Inspired by Blazer for Rails; this is an independent Laravel package with its own code.
