laravel/doctor
Laravel Doctor diagnoses common configuration, environment, and infrastructure problems in your Laravel application. Run php artisan doctor to see each failing check, then apply safe, deterministic fixes with --fix or follow the remediation steps for issues that can't be repaired automatically.
Install as a dev dependency — Doctor is a developer/CI tool, not a runtime dependency:
composer require laravel/doctor --dev
Nothing else is required to start; the service provider registers a doctor Artisan command automatically.
First run, on the project you're currently debugging:
php artisan doctor
Run it before you start guessing at a broken app. The default suite covers the failure modes that produce confusing, non-obvious symptoms: missing .env/APP_KEY, unwritable storage, missing storage:link, unreachable database/cache/queue drivers, uncached config in production, missing Composer dependencies, and debug mode mismatched to the environment. Each result is one diagnostic with one status (pass, notice, warn, fail, skip, error), plus remediation text or a fix.
First use case — new-machine onboarding. After git clone + composer install, run php artisan doctor --fix. The deterministic first-party fixes cover exactly the things a fresh clone gets wrong: missing .env, missing APP_KEY, missing public storage link, unwritable storage directories. Anything that can't be repaired safely (like a failed asset build) is reported with steps instead.
Second step — control the exit code. By default the command exits non-zero on fail or error only. Warnings do not fail. To make CI strict:
php artisan doctor --fail-on=warn
Publish the config once you care about which diagnostics run:
php artisan vendor:publish --tag=doctor-config
This gives you persistent only/except selectors and the environments map — the two things you'll almost certainly need to tune for a real project.
Put Doctor at the end of your setup docs or a composer setup script. Order it after composer install and key:generate so it validates the result rather than racing your setup steps:
composer install
cp .env.example .env
php artisan doctor --fix
php artisan migrate
--fix never prompts, so it's safe in a script — but it also silently skips choice-based fixes (see Gotchas).
Two useful shapes:
# Strict: warnings break the build too
php artisan doctor --fail-on=warn --bail
# Report-only: never mutate, never fail
php artisan doctor --fail-on=never
--bail stops at the first fail/error, which keeps CI logs short while you're cleaning up a backlog. Drop it once the suite is green so one run surfaces every regression.
For machine consumption, switch to the JSON or GitHub output format and note that --fix is rejected with these formats by design — machine-readable reports never mutate the app. That's the right split: --fix in local bootstrap, read-only in CI.
Doctor's environment modes make it genuinely useful outside local dev. Anything not listed in the environments config is treated as production, so a staging or QA run is held to production expectations (debug off, async queues, cached config/bootstrap). Run it after deploy as a smoke check:
php artisan doctor --only=configuration,security,queue --fail-on=warn
Group selectors let you slice by concern. Selectors accept class names, groups, packages, and package wildcards, and multiple values can be comma-separated or repeated:
php artisan doctor --only=storage
php artisan doctor --only=StorageIsWritable
php artisan doctor --only=laravel/doctor
php artisan doctor --except='laravel/*'
php artisan doctor --except=laravel/doctor
Treat the published config as the team's opinion and the CLI flags as the developer's. The interaction is worth internalizing:
only is a persistent allowlist; --only at runtime narrows it further (it cannot re-add something the config excluded)except and --except are unionedSo a common shape is: config excludes noisy environment-specific diagnostics that only one team member needs, and CI adds --only for a focused job.
make:diagnostic scaffolds into app/Doctor/Diagnostics:
php artisan make:diagnostic HorizonIsRunning --fixable
This is the highest-leverage pattern. Anything your team checks manually on every deploy — Horizon running, a required queue connection, a feature flag present in config, an external service reachable — becomes one class with one check() method returning a result. The messages() registry keeps copy in one place and gives each outcome a stable machine-readable code (class-slug.message-key) that you can assert on or alert on.
A diagnostic is deliberately shaped like an Artisan command: $name and $group as properties, defaults derived from the class name. Branch on mode when a fact is only wrong in some environments:
use Laravel\Doctor\EnvironmentMode;
public function check(): DiagnosticResult
{
if (config('queue.default') !== 'sync') {
return $this->pass('async');
}
if (EnvironmentMode::current()->isProduction()) {
return $this->warn('sync-in-production');
}
return $this->pass('sync-local');
}
Implement Laravel\Doctor\Contracts\Fixable, mark failures with ->fixable(), and pass EnvironmentMode to constrain where the repair may run:
return $this->fail('missing')->fixable(EnvironmentMode::Local);
The contract between Doctor and your fix() is narrow and worth respecting: only explicit fail results marked ->fixable() from a diagnostic implementing Fixable are ever auto-repaired. warn, notice, skip, and error are never touched. That means a --fix run can never silently change a passing system.
Keep fix() idempotent and verify the outcome before reporting success — the README's key example re-reads config('app.key') after Artisan::call() and returns fixFailed(...)->withDetails(...) if it didn't take. Do the same: call the tool, then re-assert.
Use Link::docs() for Laravel docs and Link::to() for everything else. Docs links are versionless in human output and automatically receive the .md variant in JSON intended for agents:
Message::make(
summary: 'Queued jobs run synchronously in production.',
remediation: 'Configure a background queue driver.',
)->link(Link::docs('queues', 'connections-vs-queues'));
Put the why in remediation and the how much do you care in the status. Interpolate run-time values with {placeholder} tokens supplied at the return site rather than building strings in the message registry.
--fix skips anything requiring a decision. It never prompts, so choice-based fixes (e.g. "which of your configured cache stores actually works?") are downgraded to ordinary failures carrying their remediation text. If the interactive flow matters, run php artisan doctor without --fix and answer the prompt.
--fix is rejected with JSON and GitHub formats. This is intentional — machine-readable reports are read-only. Scripts that pipe output to JSON must not also pass --fix; do the fixing in a separate CLI-format invocation.
Unlisted environments are treated as production. Defaults cover local, production, staging; anything else is held to the strictest expectations. A developer with APP_ENV=dev will get production-mode warnings (debug mode, sync queues, uncached bootstrap) until they add it:
'environments' => [
'local' => ['local', 'dev'],
'production' => ['production', 'staging', 'qa'],
],
An unknown mode name, a non-array environment list, or an environment listed under both modes throws at resolution time — a startup-time config error, not a diagnostic failure, so it won't appear in the report.
Debug mode "failing" is often correct, not a bug. Because mode changes the verdict and not just the message, the same state can pass locally and warn in production. Uncached bootstrap files warn in production but pass locally; present caches pass in production but emit a notice locally, since a stale cache is a common cause of "my change isn't showing up." Read the status against the mode before assuming a false positive.
--only cannot escape the configured allowlist. Configured only is an allowlist and runtime --only narrows it. If a diagnostic never appears in CI, check the config before blaming the selector syntax.
--bail only stops on fail/error. Warnings, notices, passes, and skips don't halt the run — so with default flags, --bail may run the whole suite.
--fail-on and exit codes matter more than the visual output. Default: fail and error fail the build. --fail-on=warn adds warnings. --fail-on=never reports without failing. Wire this explicitly in CI rather than relying on the default.
A throwing diagnostic becomes error, not a crash. That's convenient but can hide a broken custom diagnostic behind a red line in a long report — if your own diagnostic never prints its expected message, look for error and inspect the captured detail.
Message codes are an API. Codes derive from class and message key (e.g. application-key-is-set.missing). Renaming the class or a message key silently breaks anything asserting on them. If you build alerting on codes, treat renames as a breaking change.
It's a dev dependency, so it's absent from --no-dev installs. composer install --no-dev in your deploy pipeline removes the doctor command entirely. Run Doctor in a CI stage that installs dev dependencies, or explicitly composer require it in the stage that needs it — don't put it in a production post-deploy hook and expect it to exist.
Fixes touch the filesystem and .env. Doctor will create .env, generate keys, append to .gitignore, and repair storage permissions. Commit or stash before running --fix so you can review exactly what changed, and never run it against production data.
Order and shared state. Diagnostics run in-process against your booted application. A custom diagnostic that mutates config, files, or connections can change the verdict of later ones. Prefer read-only checks, and if you must write, gate it behind a mode.
Diagnostics should be fast and side-effect-free. They run on every doctor invocation including CI. Avoid network calls with long timeouts; if you must probe an external service, keep the timeout short and return skip when the dependency is irrelevant to the current environment.
Extension points beyond Fixable are thin by design. The lever you have is the messages() registry plus Link::docs()/Link::to() for remediation, and EnvironmentMode::current() for verdict branching. When you need more behavior, encode it in the status you return — resist reaching for output formatting.
Check php artisan doctor --help for the exact format flag in the version you install before baking it into scripts; the named formats (agent, json, GitHub) are documented, but the flag spelling is the kind of thing that shifts between releases.
How can I help you explore Laravel packages today?