Skip to main content

Capture modes: pretend and shadow

Before SQLens can lint a migration, it has to get the SQL the migration will run. There are two ways to obtain it, and they answer different questions. Knowing which one you are in — and why a run sometimes says it cannot answer at all — is most of understanding the lint suite.

Pretend mode​

Pretend mode is the default. It runs each pending migration through Laravel's own pretend path: the migration's schema and query builder calls are intercepted and the SQL they would produce is recorded, but nothing is executed. A table "created" under pretend does not exist afterwards. This is fast, needs no throwaway database, and is what the single-file fast path uses.

What pretend mode cannot do is the important part, because it is structural, not a bug:

  • It runs the PHP around the SQL for real. Pretend intercepts database statements, not the rest of the method. A migration that sends a notification, makes an HTTP request, or dispatches a job would do those things for real during a pretend run — from a command whose whole purpose is to look at a migration without running it. SQLens refuses to let that happen: a migration that would reach outside the database is flagged by the static pre-scan and never pretend-executed.
  • Every query it intercepts answers empty. A SELECT under pretend returns no rows. So a migration whose SQL depends on a query result — a chunk() loop, an if ($query->exists()) branch, a backfill — captures only a fragment of what it really runs, and the missing part is invisible. These migrations are flagged too, rather than captured as if they were complete.

Both of those are why a pretend run sometimes reports undetermined instead of a result: pretend is honest about what it structurally cannot see.

A migration that declares a $connection of its own is the third case. migrate runs it on that connection, so a verdict drawn from the connection a pretend run captures on would be about a database the migration never touches. Such a migration is not run and is reported undetermined with the reason migration_on_another_connection; lint the connection it names to judge it. A migration that sends a query to another connection from inside its method, through DB::connection('…') or Schema::connection('…'), is reported the same way: the capture refuses that query before it runs, in both modes, and the finding names the connection it was sent to.

A migration that writes into the query log itself, through DB::logQuery(), is the fourth case. A pretend run reads each statement the way the database driver receives it and holds Laravel's pretend log to the same statements, one for one; a query the migration logs by hand breaks that pairing, so which text belongs to which statement is not known. Such a migration is reported undetermined with the reason pretend_log_diverged, and shadow mode reads it.

Shadow mode​

Shadow mode is the answer when pretend cannot give one about the schema. It runs the migration for real against a disposable shadow database, then reads the resulting schema back from the catalog. Because the migration actually executes, an introspection guard such as Schema::hasColumn() gets a real answer and the full SQL is observed: a migration whose only pre-scan hits are such guards runs in shadow mode.

The shadow database holds the schema and none of the data, and it is still not a place for effects outside a database. So a migration that branches on query results, sends something outside the database, or calls code the pre-scan cannot follow is held back in shadow mode too, and its finding says so.

Shadow mode costs more — it provisions and tears down a database — and it runs only behind the production guard, because running a migration for real is exactly the thing a safety tool must never do to the wrong database. It is the truth mode you reach for when a pretend run tells you it could not decide.

It is also the one mode that needs more than read-only access. Before you turn it on, see Shadow mode: setup, privileges, topologies for the dedicated least-privilege role it uses, the pooler and read/write-split rules, and why it never clones your live data.

Which mode to use​

Start in pretend mode; it covers the overwhelming majority of migrations. When a run reports an undetermined result whose reason points here, re-run that migration in shadow mode to get the real answer — or change the migration so pretend can capture it (move a backfill into a job, drop an unnecessary introspection guard). See Understanding undetermined for the full set of ways to resolve one.

The two modes share every downstream stage — the same canonicalization, the same rules — so a rule never behaves differently depending on how the SQL was captured. There is deliberately no separate "fast but approximate" path.