Formatting your SQL
php artisan sqlens:format # rewrite
php artisan sqlens:format --check # answer whether it would, and exit non-zero if so
php artisan sqlens:format --diff # show what would change, and write nothing
--check is not a dry run. A dry run says what would happen and leaves somebody to read it; a
check answers whether anything would and exits non-zero when the answer is yes. That is the
difference between a report and a verdict, and a pipeline needs the second.
--diff shows the work; --check passes judgment
The two non-writing modes answer different questions, and the difference is the exit code:
| writes | prints | exit code | |
|---|---|---|---|
| (no flag) | yes | which files it rewrote | clean unless something was undetermined |
--diff | no | a unified diff per changed file | untouched — it is a view, not a verdict |
--check | no | which files would change | non-zero when anything would |
So --diff is what you run to decide whether to run the write, and --check is what a pipeline
runs to fail a build. They compose: --check --diff prints the diff and returns the verdict.
--diff deliberately does not move the exit code. A command somebody ran to look at something
should not fail their build — and a second, undocumented gate is exactly the kind of surprise that
gets a tool switched off.
The output is an ordinary unified diff, with a/ and b/ paths, so git apply and patch -p1
take it as-is. Two details it gets right because they are otherwise invisible: a file gaining its
final newline is shown (with the usual \ No newline at end of file marker), and so is a CRLF
file being normalized to LF — both are changes the write really makes.
What the run could NOT use, and --strict-tools
auto picks the best backend that is actually installed, and falls back to the built-in core when
the better one is absent. That fallback is correct — it is why the core exists — but it is never
silent: the run names what it passed over and what installing it would buy.
sqlens:format: TOOL.PGFORMATTER.MISSING — `pgformatter` is not installed, so this run used a
different backend. It would have added the best available PostgreSQL formatting, …
That matters because the output is committed. A machine without pgFormatter formats with something else than the machine that has it, and the next run on the other machine rewrites every file. A run that told you nothing left you to discover which of the two you were on.
--strict-tools turns that loss into a failure, for the pipeline that wants the stronger promise:
php artisan sqlens:format --check --strict-tools # fail unless the preferred backend is present
It exits with the undetermined-in-strict-mode code before formatting anything — a run that is
going to fail should not first rewrite two hundred files with the wrong backend. --no-strict-tools
forces it off for a run, and without either flag the configured sqlens.strict_tools decides.
"Strict" wins if you pass both: there is no reading of "strict and not strict" that is true, and the
stricter mistake surfaces a missing tool instead of quietly formatting with less than you asked for.
Three backends behind one seam
| Backend | Dialects | Notes |
|---|---|---|
pgformatter | PostgreSQL | the best there is for Postgres, when pg_format is installed |
sqlfluff | PostgreSQL, MySQL | the only external one that covers MySQL |
php | both | built in, always present, no install step |
auto picks the best AVAILABLE one, in that order, and is the default. Availability is checked
once per run, not per file — a backend that is not installed is skipped, and the built-in core takes
over.
You name one with --backend=, and its sibling --dialect= decides which SQL grammar the run
reasons about:
php artisan sqlens:format --backend=pgformatter # auto | php | pgformatter | sqlfluff
php artisan sqlens:format --dialect=pgsql # auto | pgsql | mysql
Both override the configured value for that run, and auto on either is the shipped default —
the connection's driver decides the dialect, and availability decides the backend.
Naming one is a promise that it is installed. A named backend that cannot run is a refusal, not a quiet fallback:
sqlens:format: format_tool_missing — the backend you named is not installed on this machine,
and nothing is substituted for a backend you named
That looks unhelpful for exactly one second and saves a day later. A silent substitution produces output you did not ask for, and the next machine — the one where the binary is installed — rewrites every file in the repository.
The same applies to a backend that does not handle your dialect: pgFormatter is written for PostgreSQL's grammar, and pointed at MySQL it mangles backtick-quoted identifiers while appearing to work.
.sql files only — and that is a scope decision, not a gap
⚠️ A Laravel migration is a PHP file. Running a SQL formatter over one does not format the SQL
inside its heredoc: it reads PHP as SQL and rewrites the whole file as though it were a statement.
That is not a bad diff, it is a destroyed migration — reported as reformatted.
So the scanner takes .sql files and nothing else, and it refuses a .php file even when you name
it explicitly with --path. That is not an override you can take: it is the outcome above, asked
for by accident.
Formatting SQL inside a heredoc needs a PHP parser, a way to find the heredocs that hold SQL, and a way to write the formatted text back at the original indentation. It is a real feature and it is deliberately not in 1.x.
Generated schema dumps are left alone too. database/schema/*.sql is written by schema:dump
from the database, in whatever shape the dumper produces — reformatting one makes the next dump a
large diff against a file nobody edits, visible on every deploy.
What the formatter will never do
The built-in core promises four things: consistent keyword casing, consistent indentation, one clause per line, commas where you asked. It is deliberately narrow, and everything it does not do is a decision:
- It never touches a string literal.
where note = 'select from where'keeps every byte — upper-casing the inside of a literal changes what a comparison matches, silently, on a statement that still looks correct. - It never touches a quoted identifier.
"select"is a column somebody named badly; the quoting is what makes it exact. - It never touches a dollar-quoted body.
$$ … $$and$tag$ … $tag$hold data — most often a whole function body in PL/pgSQL, Python or JavaScript — and a formatter that reflowed one would produce a file that looks formatted and holds destroyed code. A$1placeholder and an identifier likea$bare correctly not read as tags. - It never touches a comment, and never lets one end up in front of code. A
--comment appended to a formatted clause comments out everything that followed it on that line. - It leaves a word it does not know exactly as written. The keyword list is closed. An
unrecognized keyword keeps your casing, which is at worst inconsistent — a list that guessed would
upper-case a column called
stateand produce a diff nobody asked for.
And it is idempotent: formatting formatted output returns the same bytes. Without that, --check
and a write run disagree about whether a file is clean, and every commit rewrites every file.
A file it cannot format is REPORTED, never skipped
undetermined: database/migrations/2026_01_01_000000_x.php: format_unparsable — …
An undetermined file moves the exit code in both modes. In --check that is obvious; in a write
run it matters more, because the tree now holds a mix of formatted files and one nobody could
format — and a clean exit would say otherwise.
A backend that cannot express one of your style options reports that too, rather than ignoring it. pgFormatter has no leading-comma mode; asked for one, it says so instead of silently giving you trailing commas that the next backend would rewrite.
The style
// config/sqlens.php
'format' => [
'backend' => 'auto',
'dialect' => 'auto',
'style' => [
'indent' => 4,
'uppercase_keywords' => true,
'leading_commas' => false,
'line_width' => 100,
],
],
Four options, and the fewness is the point: every option is a decision two people will disagree
about forever, and a formatter's value comes from ending that argument rather than parameterizing
it. leading_commas is the one purely aesthetic entry, and it is there because it is the one people
actually argue about — leading commas make a git diff of an added column one line instead of two.
line_width needs a backend that wraps to a column
The built-in core breaks lines on structure, not on a column — one per selected expression, one per clause, one per parenthesis level. That is deliberate: a break at column 80 lands wherever the character count happens to fall, which for SQL is usually the middle of an expression.
So the core cannot honor line_width, and it says so rather than ignoring it. Set it to
anything other than the shipped default and a core run answers:
format_style_not_expressible — this backend cannot express: line_width
Use the pgformatter or sqlfluff backend if you need a column bound. Leaving line_width at its
default is the ordinary case and changes nothing.
⚠️ It did not always say so. Until this was fixed the core read the option, folded it into the style fingerprint, and then ignored it — so a project that set it saw a report claiming a different style and files that came back byte for byte identical. A dropped option with no signal is the same class of harm as a dropped finding.
It runs with no database at all
That is the point, not a fallback. sqlens:format is most useful in a fresh checkout, in a
pre-commit hook, on a machine with nothing installed — and a formatter that needed a connection to
reformat a text file would be unusable in exactly those places.
With auto the dialect follows the configured connection. Where there is none, the run says so and
formats anyway:
sqlens:format: format_dialect_unknown — no connection named a driver, so the dialect could not
be resolved. A dialect-neutral backend formats anyway; name --dialect if you want a
dialect-specific one.
What it loses is the dialect-specific backends: with the dialect unknown, only a backend that answers for both is allowed to run. Choosing pgFormatter against a guess would format for the wrong grammar, and the output is committed.
An engine this package does not support is named, never mapped onto its nearest neighbor:
format_dialect_unsupported — the connection uses `sqlite`, which this package supports on no
suite … applying them to another engine would produce advice that is confident, specific, and
about a different product.
--dialect short-circuits all of it. With it, the run reads no connection at all.
Files are written safely, or not at all
Writes go through a temporary file in the same directory, then a rename. Two reasons, both about destroying work:
file_put_contents()opens the file before it has the bytes, so a process killed in between leaves an empty file where a migration was. Over a whole directory, one interrupted run can empty dozens.- The temporary file is a sibling and not in
/tmp, becauserename()is atomic only within one filesystem — a cross-device rename silently degrades into copy-then-delete, which is the non-atomic write this avoids.
A file whose content would not change is not rewritten at all. An unnecessary write updates the
mtime, restarts every watcher, rebuilds every cache keyed on it, and shows up in git status as a
modification with an empty diff.