guanguans/laravel-dump-sql
Laravel package that reveals the real SQL behind your query builder and Eloquent queries. It adds dumpSql() and ddSql() macros so you can print or dump fully interpolated SQL, bindings included, plus helpers for logging queries—making database debugging faster and clearer.
Install (dev-only, this is a debugging aid):
composer require guanguans/laravel-dump-sql --dev
The service provider is auto-discovered and registers dumpSql() / ddSql() macros on Illuminate\Database\Query\Builder and Illuminate\Database\Eloquent\Builder. No config file, no migrations, no assets — just macros.
Where to look first
src/ServiceProvider.php — where the macros are registered.Macros support class (or trait) — this is where the binding-interpolation lives; read it before trusting the output.First use case — the everyday "why is this query empty/wrong" loop. Instead of the usual dance:
$q = User::where('status', 'active')->whereIn('role', ['admin', 'editor']);
[$sql, $bindings] = [$q->toSql(), $q->getBindings()];
// now manually substitute ?'s ... ugh
you just do:
User::where('status', 'active')->whereIn('role', ['admin', 'editor'])->dumpSql();
// select * from `users` where `status` = 'active' and `role` in ('admin', 'editor')
Drop it in tinker, copy the string into your DB client, and run it.
Inline debugging on the builder chain — call dumpSql() before the terminal call (get, first, paginate). It returns the builder, so the chain still works:
$users = User::with('posts')
->where('created_at', '>', now()->subMonth())
->dumpSql() // prints SQL, continues
->get();
Hard stop with ddSql() — same thing but dd()s the SQL. Useful in a failing test or a controller you're poking at:
Order::whereBetween('total', [100, 500])->ddSql();
Verify what a scope / macro actually generates — this is the highest-value daily use. Any time you're unsure whether a local scope is adding the right whereNotNull or orWhere grouping, dump it:
Order::query()->active()->thisMonth()->dumpSql();
Assert SQL shape in tests — grab the string and assert on it:
$sql = Order::where('status', 'paid')->dumpSql(); // returns builder in this pkg; use toSql()/getBindings or capture output
$this->assertStringContainsString('`status` = \'paid\'', (string) $sql);
(If dumpSql() only returns the builder here, wrap with output buffering, or prefer toSql() for assertions — see gotchas.)
Log full statements in a local environment — pair with DB::listen() when you want every query, not just one:
DB::listen(fn ($q) => logger()->debug(
vsprintf(str_replace('?', "'%s'", $q->sql), $q->bindings)
));
This package is the one-shot version; the listener is the "all queries" version.
Copy-paste into your SQL client — the primary workflow. Run the dumped SQL in TablePlus/DataGrip/psql and check row counts or the EXPLAIN plan. This is where the package saves the most time versus hand-substituting bindings.
Nested / eager-loaded queries — dump the base builder, then dump individual relations by calling methods directly:
$q = User::with('posts.tags');
$q->dumpSql();
$q->getRelation('posts') // inspect the relation builder separately if needed
Laravel 10.15+ already has this built in. Illuminate\Database\Query\Builder ships toRawSql(), dumpRawSql(), and ddRawSql() — the exact same idea. If you're on a recent Laravel, prefer the native methods and skip the dependency. Check method_exists($builder, 'toRawSql') before reaching for the package.
It's naive string interpolation, not real parameter binding. Strings get wrapped in quotes, but escaping of edge cases (embedded quotes, backslashes, binary blobs, DateTime objects, JSON columns) is best-effort. Never run package output against production to "check" something — the dumped SQL is for humans and your dev DB only.
dumpSql() returns the builder, not a string. It prints and returns $this. If you want the string to assert on or log, use ->toSql() + ->getBindings() or capture the dumped output. Don't try to (string) $builder->dumpSql().
Add it with --dev. There is no reason for this to be in a production composer install. Keep it in require-dev and rely on Laravel's auto-discovery only in local/testing.
Watch the SQL dialect. It mirrors Laravel's grammar, which mirrors your configured connection. If your default connection is sqlite but you're debugging against MySQL, the dumped quoting will be "double" vs `backtick` and won't paste cleanly into the other engine. Set DB_CONNECTION to what you actually ship against.
Bindings won't reflect runtime state for now()-style calls. where('created_at', '>', now()) interpolates the current timestamp frozen at the moment of dumpSql(). Re-running the SQL later in your client can therefore return different rows than the app did. Keep that in mind when reproducing bugs.
Complex bindings render awkwardly. whereIn with subqueries, whereJsonContains, and raw expressions often produce output that isn't valid copy-paste SQL — the placeholder is the outer binding while the fragment is opaque. For those, fall back to toSql() + reading the bindings array by eye, or enable Laravel's DB::listen() and grab $query->sql/$query->bindings separately.
No extension points worth caring about. It's macros — nothing is configurable. If its interpolation is wrong for your case, fork the one class or (better) wrap toRawSql()/a small custom macro in your own AppServiceProvider::boot() and control the formatting yourself.
Debugging a "blank" dump. If dumpSql() prints nothing, confirm (a) the macro is actually registered (Builder::hasMacro('dumpSql')), (b) you're calling it on a Builder, not a relationship proxy or Collection, and (c) your output isn't swallowed by an output-buffering layer (Octane, some test harnesses, or a custom dd override). In Octane, restart workers after installing so the macros register.
How can I help you explore Laravel packages today?