A health-check runner that probes your project — PHP version, extensions, container wiring, code style, tests, database, spec drift — and reports back in two shapes: scannable text for you, and deterministic JSON an AI agent can act on without guessing.
When an agent (or a human) sits down in a fresh checkout and something is subtly wrong — the wrong PHP on PATH, a missing ext-redis, a Configuration that never got applied, stale vendor/ — the failure usually surfaces three steps later as an inscrutable boot error. By then the agent has burned context chasing a symptom instead of the cause. Doctor exists to front-load that diagnosis: run one command, get a list of everything that is wrong and the exact next action for each problem.
That last part is the difference between Doctor and a plain “lint everything” script. Every failing check carries an agent_action — a structured “do this next” block (run_command, edit_file, install_dep) — alongside human-readable detail. An agent reads the JSON, sees {"type": "run_command", "command": "composer install"}, and runs it. No prose parsing, no heuristics.
The package is deliberately small and contract-first. A check is a side-effect-free probe (CheckInterface); a check that can fix itself opts into FixableCheckInterface; sub-processes go through ProcessRunnerInterface so process-backed checks stay unit-testable; output goes through ReportRendererInterface so human and json are just two registered renderers. Everything else — the default check set, the env-derived requirements, the host-app hooks — is wiring you can replace.
What Doctor deliberately does not do: it does not mutate anything in run() (probing is read-only), it does not perform destructive fixes (fix() is contractually safe and non-destructive), and it does not invent its own quality tooling — cs_clean shells out to composer cs, phpstan_clean to composer stan, and so on. Doctor is the orchestrator and the reporter, not a re-implementation of the tools it drives.
You will usually want this as a dev dependency — it diagnoses your checkout, not your runtime. If you install the full framework, composer require univeros/framework already bundles it.
The package depends only on univeros/cli (the doctor command plugs into attribute-driven command discovery), univeros/configuration (the DoctorConfiguration wiring), and univeros/container (binding resolution). No PHP extensions beyond core PHP 8.3.
Attempt safe auto-fixes for any check that supports one, then re-report the post-fix state:
Terminal window
bin/altairdoctor--fix
The process exit code is the worst status observed: 0 for all-ok (or skipped), 1 if any check warned, 2 if any errored. That makes bin/altair doctor a drop-in CI gate.
Host-application boot is required for the host-aware checks.bin/altair only wires CLI discovery (CliConfiguration); it does not apply DoctorConfiguration on your behalf. The default check set — the env-derived php_version/extensions_loaded, the process-backed checks, and the container_boots / container_resolves / database_reachable hooks — is registered when your entry point applies DoctorConfiguration. A typical host entry looks like:
#!/usr/bin/env php
<?php
require__DIR__.'/../vendor/autoload.php';
use Altair\Cli\Application;
use Altair\Cli\Configuration\CliConfiguration;
use Altair\Container\Container;
use Altair\Doctor\Configuration\DoctorConfiguration;
Checks are side-effect-free.CheckInterface::run() is a read-only probe — it inspects PHP, reads composer.json, or shells out to a read-only sub-command (composer install --dry-run, db:migrate:status, git diff --exit-code). It must never mutate the project. Any remediation lives behind a separate method:
interface CheckInterface
{
publicfunctionname():string; // stable id: 'php_version', used by --only/--skip and dependsOn()
publicfunctiondependsOn():array; // names of checks that must pass first
publicfunctionrun():CheckResult;
}
Fixes are opt-in and contractually safe. A check that can remediate itself implements FixableCheckInterface, and fix() only runs under --fix:
publicfunctionfix():bool; // never destructive: no deletes, downgrades, force-pushes
}
When you pass --fix, the runner calls fix() on any non-ok fixable check, then re-runs run() so the report reflects the post-fix state. composer_deps, cs_clean, migrations_pending, and manifests_current are fixable; phpstan_clean, tests_passing, database_reachable, and the spec checks are not — a type error or a failing test needs a root-cause edit, not a mechanical re-run.
Dependency gating turns cascades into skips. Each check declares dependsOn(). When a prerequisite errored or was skipped, the runner reports the dependent as skipped rather than running it — there is no point running the PHPUnit suite when vendor/ is stale, or probing migrations when the database is unreachable. This keeps a single root failure from producing a wall of downstream noise.
Skipped is not a false pass. The host-aware checks (container_boots, container_resolves, database_reachable, determinism_check) report skipped when their host-supplied hook is absent, never ok. A library-only checkout with no database simply skips database_reachable — it does not claim the database is fine. skipped contributes 0 to the exit code, exactly like ok, but is visibly distinct in the report.
The JSON report is deterministic.Report::toArray() and CheckResult::toArray() emit a fixed key order, omit absent optional fields (no nulls, no stray keys), and carry no timestamps. The single varying field is duration_ms — the one timing value the framework’s determinism standard permits. Two runs with the same outcomes produce byte-identical JSON apart from that field, which is what makes Doctor safe to diff in CI and stable for agents that cache by content hash.
ProcessRunnerInterface keeps process-backed checks unit-testable. Every check that shells out takes a ProcessRunnerInterface rather than calling proc_open() directly. In production that is ShellProcessRunner (argv form, no shell, no injection surface); in tests it is a fake that scripts results per command. The check logic gets exercised without ever spawning composer.
$result->agentAction; // ?AgentAction — the structured next action
$result->source; // ?string — production file the failure maps to, when known
}
CheckResult is built through named constructors — CheckResult::ok(), ::warn(), ::error(), ::skipped() — so the optional remediation fields only ever appear on results that have a remediation.
An orchestrator reads agent_action.type and dispatches to the matching tool — run the command, open the file, install the dependency — then re-runs doctor to confirm the fix took.
returnCheckResult::ok($this->name(), '.env is present.');
}
returnCheckResult::error(
$this->name(),
'No .env file found at the project root.',
'Copy .env.example to .env and fill in the required values.',
AgentAction::editFile('.env', 'Create from .env.example and set DB_* and APP_KEY.'),
);
}
}
Register it on the CheckRegistry. Order matters — checks run top-to-bottom, and dependsOn() references resolve against checks that already ran — so hosts typically add() their checks via a Container prepare hook after DoctorConfiguration has populated the default set:
Three checks come inert until you hand them a host-specific hook through DoctorConfiguration. Without the hook they report skipped, never a false pass:
Check
Hook
What it verifies
container_boots
appBooter: Closure(): mixed
The application Container constructs from scratch without throwing — the most common “agent got stuck” failure mode.
container_resolves
criticalBindings: list<class-string>
Each declared PSR-11 id actually resolves (boot succeeding does not guarantee every contract is wired).
database_reachable
databaseProbe: Closure(): bool
The DB is reachable — a typical probe is static fn() => $em->getConnection()->isConnected().
The PHP floor and the required ext-* list are not hand-configured — they are read from your project’s composer.jsonrequire block, so php_version and extensions_loaded always reflect what your project itself declares:
The php constraint (e.g. ">=8.3") is parsed down to its version floor (8.3) and compared against the running runtime by PhpVersionCheck.
Every ext-* requirement (e.g. ext-redis, ext-pdo) becomes an entry in the extensions_loaded probe, sorted for determinism.
When composer.json is absent or unreadable, the floor falls back to the running PHP’s major.minor and the extension list is empty — the checks degrade gracefully rather than erroring.
The key testing tool is the in-memory FakeProcessRunner (tests/Doctor/Support/FakeProcessRunner.php): you script a result per command and assert on the calls made, so a process-backed check is exercised end-to-end without ever spawning a subprocess.
use Altair\Tests\Doctor\Support\FakeProcessRunner;
use Altair\Doctor\Check\CsCleanCheck;
use Altair\Doctor\Process\ProcessResult;
use Altair\Doctor\Result\CheckStatus;
$runner=newFakeProcessRunner();
$runner->on(['composer', 'cs'], newProcessResult(1)); // simulate a style violation
When you add a new check, mirror this: inject the dependency that touches the outside world (a Closure probe or the ProcessRunnerInterface), script it in the test, and assert on the resulting CheckStatus. No new check should require a real PHP, a real composer, or a real database to test.
The two natural extension points are the check set and the renderer set.
A new check implements CheckInterface (or FixableCheckInterface for self-remediation) and is add()-ed to the CheckRegistry, as shown in Usage above. If it touches the filesystem or a sub-process, inject the dependency rather than calling it directly so the check stays testable. Set dependsOn() to the names of any checks that must pass first — the runner will skip yours if a prerequisite breaks.
A new renderer implements ReportRendererInterface and is registered in a RendererRegistry under its --format key:
use Altair\Doctor\Contracts\ReportRendererInterface;
return"| Check | Status | Detail |\n|---|---|---|\n".implode("\n",$rows) ."\n";
}
}
The contract requires determinism — same Report, byte-identical output (the duration_ms field aside) — so avoid microtime() and unordered iteration in your renderer.
univeros/cli — the attribute-driven CLI substrate. DoctorCommand is a plain invokable registered through #[Command(name: 'doctor')]; --format/--only/--skip/--fix are #[Option]s.
univeros/introspection — the broader “what is this project?” tooling. Doctor answers “is this project healthy?”; introspection answers “what does it contain?”.
univeros/scaffold — the spec scaffolder. spec_drift and openapi_valid drive its spec:lint / spec:emit-openapi commands; manifests_current and determinism_check guard the same generated content.
univeros/mcp — the MCP server. Its framework__doctor tool wraps Doctor::run() and returns Report::toArray(), so an MCP-connected agent gets the same structured report the CLI emits as JSON.
univeros/container — resolves Doctor, CheckRegistry, and the renderers; container_resolves probes bindings through its PSR-11 get().
The host-aware checks need a host.container_boots, container_resolves, database_reachable, and determinism_check are inert (skipped) until you supply their hooks via DoctorConfiguration. bin/altair doctor from a bare framework checkout, with no host entry point applying DoctorConfiguration, will not run the default check set — wire the Configuration in your application’s entry point (see the Quick start callout).
Process-backed checks shell out.composer_deps, cs_clean, phpstan_clean, tests_passing, and the spec/manifest checks invoke composer, vendor/bin/phpunit, git, and bin/altair as sub-processes. If those binaries are not on PATH (or PHP is not installed), the underlying proc_open() fails and the check reports a non-ok status — Doctor diagnoses tool availability rather than guaranteeing it.
--fix is intentionally conservative. Fixes are limited to safe, mechanical operations (composer install, cs:fix, db:migrate, manifest:generate). Type errors, failing tests, and unreachable databases are reported, never auto-resolved — they need a root-cause edit a human or agent must make.
No parallelism. Checks run sequentially, top-to-bottom, so the registry order is also the report order. The slow members (tests_passing, phpstan_clean) dominate wall-clock time; use --skip for a fast inner-loop run.