monicahq / laradb
A read-only database browser for Laravel: browse your tables and their rows from a single page. Supports MySQL, PostgreSQL and SQLite.
Requires
- php: ^8.1
- composer-runtime-api: ^2.0
- ext-pdo: *
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/view: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.16
- orchestra/testbench: ^8.14|^9.0|^10.0|^11.0
- phpstan/phpstan-deprecation-rules: ^1.1|^2.0
- phpstan/phpstan-phpunit: ^1.3|^2.0
- phpstan/phpstan-strict-rules: ^1.5|^2.0
- phpunit/phpunit: ^10.5|^11.0
This package is auto-updated.
Last update: 2026-08-28 11:58:47 UTC
README
LaraDB — A read-only database browser for your Laravel application.
Have you ever had this problem?
- You develop locally a project, and need to inspect the database.
- You are too lazy to setup connection in TablePlus.
- You wish you had a way to quickly inspect the database.
LaraDB is a package that gives you a route to inspect the local db in your browser.
Install it, open /db, and get your tables on the left and their rows on the
right — a small phpMyAdmin, without the server, the login screen, or the
write access.
Works with MySQL / MariaDB, PostgreSQL and SQLite.
The page ships its own CSS and JavaScript.
⚠️ Read this before you install it
LaraDb renders the contents of your database in a web page. Every row of every table, to anyone who can reach the URL.
- Install it with
composer require --dev. A production deploy runningcomposer install --no-devthen never ships the viewer at all. - It is disabled outside the
localenvironment by default. Turning it on anywhere else is an explicit decision you have to make. - Never expose it without authentication and authorisation. The default
middleware stack is
['web', 'auth'], which is the bare minimum — any logged-in user passes it. Add a gate (see Who can reach it). - Do not enable it on a database holding personal or otherwise sensitive data unless access is strictly controlled. There is no column masking, no redaction and no audit log in v1.
- It never writes: the routes are
GETonly and the package issues nothing butSELECTstatements. That protects your data from corruption, not from being read by the wrong person.
Installation
Install it as a development dependency:
composer require --dev monicahq/laradb
The service provider is auto-discovered. In a local environment, that is all
you need: visit /db.
Publish the config to change anything:
php artisan vendor:publish --tag=laradb-config
The views can be published too, if you want to restyle them:
php artisan vendor:publish --tag=laradb-views
Usage
Simply go to /db URL in your project, and the screen will shown. You can
configure this URL - see section below.
Configuration
config/laradb.php:
| Key | Default | What it does |
|---|---|---|
enabled |
null |
null means "only in local". Set true/false (or LARADB_ENABLED) to decide explicitly. |
route_prefix |
'db' |
Where the viewer is mounted. |
middleware |
['web', 'auth'] |
The middleware stack applied to both routes. |
connection |
null |
The connection to browse, as named in config/database.php. null uses the default one. |
per_page |
25 |
Rows per page. |
max_cell_length |
120 |
Long values are truncated to this many characters, full value in the tooltip. 0 disables it. |
Each has an environment variable: LARADB_ENABLED, LARADB_ROUTE_PREFIX,
LARADB_CONNECTION, LARADB_PER_PAGE, LARADB_MAX_CELL_LENGTH.
Who can reach it
Three separate things decide that. They are a stack, not alternatives: each is a place the viewer can be stopped, and they fail independently. The warning at the top of this file names them; here is how to set each one.
1. Whether it is installed at all. composer require --dev keeps LaraDb
out of a production build entirely — composer install --no-dev on deploy and
there is no package, no service provider, no route to protect. Nothing else
here is as strong, because nothing else here can be misconfigured.
2. Whether the routes are registered. enabled is null by default, which
means "only in local". A staging or production box has no /db even if the
package did end up in the build. Turning it on anywhere else is a deliberate
act, and it is the point at which layer 3 stops being optional:
LARADB_ENABLED=true
3. Who gets past the middleware. This is the one you have to write. The
default stack is ['web', 'auth'], which only proves the visitor is someone —
every logged-in user of your application passes it, including the test account
you made six months ago. Put an authorisation check on top:
// app/Providers/AppServiceProvider.php use Illuminate\Support\Facades\Gate; public function boot(): void { Gate::define('viewLaraDb', function ($user) { return $user->is_admin; // whatever "may read the database" means here }); }
// config/laradb.php 'middleware' => ['web', 'auth', 'can:viewLaraDb'],
The stack applies to the whole route group, so the page, the HTML fragment and the JSON endpoint are all behind the same gate. A user who fails it gets a 403.
Emptying this setting does not turn the gate off — null and [] both fall
back to ['web', 'auth']. Laravel drops a route group's middleware key when
it is not set, so the alternative would be publishing the whole database
unauthenticated because a config value was blank. If you genuinely want the
viewer reachable without logging in, ask for it: ['web'].
What is not one of the three
route_prefix moves the viewer; it does not hide it. A URL is not a secret: it
turns up in access logs, browser history, Referer headers and whatever error
tracker you have installed, and it is one shoulder-surf from being public. Set
it because /db collides with a route of your own, or to keep LaraDb out of the
way — not to a random string you then count as a layer of defence.
Routes
Both routes are named and GET only.
| Route | Name | Returns |
|---|---|---|
GET /db |
laradb.index |
The full page. ?table= selects a table, ?page= a page. |
GET /db/tables/{table} |
laradb.table |
The rows of one table, as an HTML fragment. |
Both also accept ?column= and ?value=, which narrow the table to the rows
whose column equals that value — this is what following a foreign key resolves
to. The column has to be one a foreign key actually points at, or the request
is a 404; the value is always bound, never interpolated. ?from= is the label
the chip shows, and is ignored unless it names a real foreign key.
The fragment endpoint also speaks JSON, with ?format=json or an
Accept: application/json header:
{
"table": "users",
"columns": [
{"name": "id", "type": "integer", "nullable": false, "primary_key": true, "default": null, "foreign_key": null},
{"name": "account_id", "type": "integer", "nullable": true, "primary_key": false, "default": null, "foreign_key": "accounts.id"}
],
"rows": [{"id": 1, "account_id": 3, "name": "Ada"}],
"page": 1,
"per_page": 25,
"total": 42,
"last_page": 2,
"sql": "SELECT * FROM \"users\" LIMIT 25 OFFSET 0",
"duration_ms": 0.42,
"filter": null
}
Using the core without Laravel
The reading side depends on nothing but PDO, so it works anywhere:
use LaraDb\DriverFactory; $pdo = new PDO('sqlite:database.sqlite'); $driver = DriverFactory::fromPdo($pdo); foreach ($driver->listTables() as $table) { echo $table->name, "\n"; } $page = $driver->getRows('users', page: 2, perPage: 25); // And the row a foreign key points at: $account = $driver->getRows('accounts', 1, 25, new RowFilter('id', '3'));
DriverInterface is the package's public contract:
// Reading public function listTables(): array; // TableInfo[] public function getColumns(string $table): array; // ColumnInfo[] public function getRowCount(string $table, ?RowFilter $filter = null): int; public function getRows(string $table, int $page, int $perPage, ?RowFilter $filter = null): TablePage; public function name(): string; // Describing — everything the chrome is built from public function serverVersion(): ?string; public function databaseName(): ?string; // the db name, or the sqlite file public function sizeInBytes(): ?int; public function indexCount(): ?int; public function metadata(): array; // engine settings, in display order public function getForeignKeys(string $table): array; // column => "table.column" public function foreignKeyTargets(): array; // table => the columns keys point at public function describe(): DatabaseInfo; // all of the above, gathered once public function queryCount(): int;
The describing half is nullable throughout, and deliberately so: reading a system catalogue is a privilege. A connection whose user cannot do it gets a working viewer with a quieter header, never a 500.
foreignKeyTargets() is what decides whether a RowFilter is allowed:
filtering on a column it does not list raises UnknownColumnException, which
the controller turns into a 404.
What each engine reports for metadata():
| Keys | |
|---|---|
| SQLite | page, journal, enc, fk, schema |
| MySQL | engine, charset, collation |
| PostgreSQL | enc, collation, schema |
Any change to DriverInterface is a breaking change and gets a major version
bump.
Testing
composer test # Pint, PHPStan and PHPUnit composer test:unit # PHPUnit only
The unit and feature suites run on in-memory SQLite and need nothing installed.
The MySQL and PostgreSQL suites skip themselves unless a server is reachable;
point them at one with LARADB_MYSQL_DSN / LARADB_PGSQL_DSN (plus the
matching _USERNAME and _PASSWORD). CI runs them against real services on
every push.
Contributing
Bug reports and pull requests are welcome. Please keep Pint and PHPStan green.
Changelog
Releases and their notes are generated by semantic-release from the commit history. See the releases page.
License
MIT. See LICENSE.
