Diagnoses PhoenixKit installation, migration, and runtime issues.
Runs a comprehensive suite of checks covering database connectivity, pool configuration, PgBouncer detection, migration state, lock conflicts, and application configuration. Prints a clear pass/fail report with actionable remediation steps.
Usage
$ mix phoenix_kit.doctor
$ mix phoenix_kit.doctor --prefix=auth
$ mix phoenix_kit.doctor --exit-code
Options
--prefix- Database schema prefix. When omitted, resolves fromconfig :phoenix_kit, :prefix, then"public"— the same resolutionmix phoenix_kit.update/--statususe, so a prefixed install is diagnosed against the schema it actually lives in.--exit-code- Exit non-zero when any check FAILED. Without it this task prints "N failures" and still exits 0, so a deploy script that runs it cannot act on the result — the same silent successmix phoenix_kit.status --exit-codeexists to remove. Warnings never fail the run; they are advisory by construction and several fire on healthy installs. Off by default so deploys that run this purely for its report keep passing.
Checks Performed
- Repo Detection — Can we find and start the Ecto repo?
- DB Connectivity — Can we execute a simple query?
- Pool Configuration — Pool size, checkout timeout, queue settings
- PgBouncer Detection — Is PgBouncer between app and PostgreSQL?
- Migration State — PhoenixKit version (COMMENT), schema_migrations alignment
- Module Schema Versions — Modules owning their own chain, vs what their code expects
- Schema Drift — Columns a migration should have added but the DB lacks
- Pending Migrations — Migration files not yet recorded in schema_migrations
- UUID Column Types — Detects varchar uuid columns that crash Ecto on startup
- UUID Primary Keys — Detects primary keys that are not the expected uuid type
- NULL UUIDs in FK Sources — Detects NULL uuids that cause infinite backfill loops
- Orphaned FK References — Detects orphaned rows behind an existing FK
constraint, whether it is already VALID (a trigger-bypassed write, a
bulk load, direct catalog surgery) or still NOT VALID (would block its
own VALIDATE), and existing constraints still sitting NOT VALID with
nothing currently blocking them — V176 validates those in place; this
just tells you before it does. Two boundaries on what "checked" means
here: discovery reads
pg_constraint, so a relationship with no FK constraint declared at all is outside this check's scope and is not examined; and discovery matches both the owning table and the referenced table to the schema being checked (--prefix), so a FK whose referenced table lives in a different schema is outside scope too, even though the owning table itself was checked - Schema-Declared Relations Without a DB FK — Every
belongs_toPhoenixKit's own Ecto schemas declare, cross-referenced againstpg_constraintfor a matching foreign key. Reports the COUNT found with no DB-level FK — informational, not a failure: some are intentional (a federated/soft reference cannot carry a FK across an optional module boundary, see V179/V180). This is the complement to check 12's own stated gap above: check 12 only ever sees a relationship that already HAS a declared FK constraint; this one finds relationships Ecto declares that never got one. Derived entirely from what the schema itself declares (owner_keyon thebelongs_to), never guessed from a column name — so it also cannot see a soft reference that isn't declared as abelongs_toat all (e.g. a plain field, or a polymorphic*_uuid/*_typepair). - Lock Conflicts — Any blocked or long-running queries?
- Orphaned Connections — Idle-in-transaction or stuck connections
- Oban Configuration — Queues and plugins that consume pool connections
- Oban Cron Queues — Does every crontab worker have its queue configured?
- PhoenixKit Supervisor — What's running (update_mode vs full)?
- Child Start Order — Does the Repo start before PhoenixKit/Oban in application.ex?
- Update Mode — Is update_mode active?
- daisyUI Version — Is the host's vendored daisyUI recent enough?
- User Dashboard (deprecated) — Is the host still on the retired dashboard?
- Sitemap Discoverability — Is the sitemap actually reachable?
- Crawler Visibility — noindex on a production-looking host, or a staging-looking host left indexable
- Demo Auth Pages — Are the demo auth routes still exposed?
- Manifest Repair (dry-run) —
PhoenixKit.Migrations.Repair.verify/1runs read-only against the generatedPhoenixKit.Migrations.ExpectedSchemamanifest as an additional, non-fatal check (never:fail). Passes and says so if the manifest has been removed or overridden away in this checkout. - Git Hooks — does
core.hooksPathpoint at a directory that has an executablepre-commitin it? Any directory qualifies —.githooksis only the convention this checkout happens to track. Only runs inside a checkout of phoenix_kit itself (that convention is a phoenix_kit-repo thing, not something installed into a consuming host app) — silently skipped otherwise.
Summary
Functions
Reports crontab entries whose queue this node does not run.
The process exit status --exit-code should produce: 1 when any check
failed, 0 otherwise.
The "Git Hooks" verdict, as a pure function of what could actually be observed.
The "Integration Key" verdict, rendered from one complete report.
Whether dir has an executable pre-commit in it.
Functions
Reports crontab entries whose queue this node does not run.
Public so it can be unit-tested directly against config keyword lists, for
the same reason as exit_code/1: it is the pure decision inside a task whose
run/1 needs a live app and a database.
The process exit status --exit-code should produce: 1 when any check
failed, 0 otherwise.
Public because run/1 is not a unit-test seam (it starts the app and needs a
real database) — this is the pure decision behind the flag, in the same shape
as Mix.Tasks.PhoenixKit.Status.exit_code/2 and
Mix.Tasks.PhoenixKit.Repair.exit_code/1.
Only :fail gates. A :warn is advisory by construction — several fire on
perfectly healthy installs (a capped pool under update_mode, an unreadable
application.ex) — and gating on them would make the flag unusable, which is
how a task ends up back at "reports a problem and exits 0".
The "Git Hooks" verdict, as a pure function of what could actually be observed.
Public for the same reason exit_code/1 is: run/1 is not a unit-test seam,
so the decision is tested on its own.
The point of the three-way inputs is that this check must be able to say "I could not tell" instead of guessing. A check that reports "hook not installed" when it merely failed to look is worse than no check: it is confidently wrong, and it sends the reader to fix something that is not broken.
That distinction is not free, and the obvious implementation gets it wrong:
git config --get core.hooksPath exits 1 both when the key is unset and
when the current directory is not a git repository at all (verified, not
assumed). So repository-ness is probed separately, and only inside a
repository is exit 1 read as the fact "not configured".
A second trap, independent of the first: :hooks_path holding anything
other than the literal string .githooks does not mean the hook is broken.
core.hooksPath names a directory, and a user is free to point it anywhere
— any directory with an executable pre-commit in it works exactly the way
.githooks does; .githooks is only the name this checkout's own
convention happens to use. Treating one specific value as the only correct
answer reports a perfectly working setup as broken, and its "fix" tells the
reader to point at a location that has nothing to do with why. So this
checks the property that actually matters — an executable pre-commit at
wherever core.hooksPath points — never string equality with .githooks.
:repo—{:ok, common_dir}when git answered,:unknownotherwise (not a repository, git missing, anything else).:hooks_path—{:ok, value}|:unset(a fact) |:unknown(a gap).:pre_commit_executable— whether the directory:hooks_pathnames has an executablepre-commitin it::yes|:no|:unknown(only meaningful when:hooks_pathis{:ok, _}; seepre_commit_executable?/1).:tracked?— whether.githooks/pre-commitexists in this checkout.:shadow—{:ok, path}for a leftover hook in the common hooks dir (worktrees do not have their own),:none, or:unknown.
@spec integration_key_result( PhoenixKit.Integrations.Encryption.key_report(), boolean() ) :: {:pass | :warn | :fail, String.t()}
The "Integration Key" verdict, rendered from one complete report.
Takes the report and a display choice, and nothing else. That is the whole point of the signature: this check previously received the advice, a fingerprint note and a storage location as separate arguments, each computed without reference to the others, and three consecutive rounds of fixes each produced a message that contradicted itself — a fingerprint note beside "no key resolved at all", a storage location for a key stored nowhere, a claim of a weaker key where none existed.
There is no longer an argument through which those pieces could disagree: the fingerprint and the tier that produced it are one term inside the report, and they are absent together when there is no key.
Public as a test seam, for the same reason exit_code/1 is: the defects lived
in this rendering, where tests over the diagnosis could not reach them.
@spec pre_commit_executable?(String.t()) :: :yes | :no | :unknown
Whether dir has an executable pre-commit in it.
The property git_hooks_verdict/1 actually cares about, regardless of what
the configured directory is named or where it lives — core.hooksPath
works identically for any directory that has this file, .githooks is not
special to git itself.
Public for the same reason exit_code/1 is: a test seam over a filesystem
fact, so git_hooks_verdict/1 above can stay a pure function of an
already-decided map instead of doing its own I/O.
A relative dir is resolved the same way git resolves a relative
core.hooksPath: against the top of the working tree (see githooks(5)),
which is also where this task itself runs.