Health Checks
Tri-state liveness/readiness/startup probes, a budget-bound aggregator, legacy check compatibility, and pre-flight diagnostics.
Health Checks
vortos-health is the canonical probe system — it answers "is the process alive," "can it safely receive traffic," and "has it finished starting up" as three genuinely separate questions, each with its own endpoint, so your container platform can make the right decision for each one instead of one fuzzy health check trying to answer all three.
Three states, not two
Every probe reports pass, warn, or fail — not just healthy/unhealthy. warn lets a probe flag "this is getting concerning" (disk at 88% used, a certificate expiring in nine days) without flipping /health/ready to a 503. Only fail takes an instance out of rotation.
Endpoints
| Endpoint | Probe kind | Purpose |
|---|---|---|
GET /health/live | ProbeKind::Liveness | Process liveness — container platform restarts on failure |
GET /health/ready | ProbeKind::Readiness | Traffic readiness — load balancer / k8s readiness gate |
GET /health/startup | ProbeKind::Startup | Has the process finished its startup sequence |
GET /health/monitor | ProbeKind::Monitoring | Informational observability (cert expiry, …) — always HTTP 200, never a gate |
GET /health/detail | ProbeKind::Readiness | Authenticated full per-check breakdown (see below) |
GET /health | — | Operational summary — runs every probe, for dashboards and humans |
curl -i http://localhost:8000/health/readyBy default the anonymous readiness body is minimal — status, mode, timestamp — and, when it is not passing, the names of the non-passing checks (never their messages, values, or errors), so an operator can see which check degraded over HTTP without shelling into the box:
{ "status": "warn", "mode": "ready", "timestamp": "…", "degraded": ["disk-capacity"] }Set VORTOS_HEALTH_PUBLIC_DEGRADED_NAMES=false to suppress even the names.
Authenticated detail — /health/detail
For the full per-check breakdown over HTTP, GET /health/detail requires a valid X-Health-Token
regardless of the anonymous HEALTH_DETAILS policy — so you can always diagnose a warn/fail
during an incident, even with HEALTH_DETAILS=never. A missing or wrong token returns 401 (an
authorization failure, deliberately distinct from a health status, so a scraper never mistakes it for
unreadiness):
curl -s -H "X-Health-Token: $HEALTH_TOKEN" http://localhost:8000/health/detail{
"status": "warn",
"checks": {
"database": { "status": "pass" },
"redis": { "status": "pass" },
"disk-capacity": { "status": "warn" }
}
}| Env | Effect |
|---|---|
HEALTH_TOKEN | The secret compared (constant-time) against X-Health-Token; also unlocks /health/detail regardless of policy |
HEALTH_DETAILS | Anonymous detail policy: never (default) / token / debug / always |
HEALTH_EXPOSE_ERRORS | Include raw probe error strings in detailed output (default off; on in a debug env) |
VORTOS_HEALTH_PUBLIC_DEGRADED_NAMES | Anonymous readiness lists non-passing check names when degraded (default on) |
The probe contract
interface HealthProbeInterface extends DriverInterface
{
public function name(): string;
public function kind(): ProbeKind;
public function check(): ProbeResult;
}ProbeKind is Liveness, Readiness, Startup, or Monitoring — a probe declares which question it answers, and the aggregator only runs the probes relevant to the endpoint that was hit. A probe that talks to the database has no business running on /health/live, which exists specifically so a database outage doesn't make your container platform think the process is dead and start restarting it. Monitoring probes are the inverse case: they answer an observability question (is a certificate about to expire?), so they run only on /health/monitor — never on /health/ready — and that endpoint always returns HTTP 200 so a monitoring breach can never be mistaken for unreadiness and roll back a healthy instance.
enum ProbeStatus: string
{
case Pass = 'pass';
case Warn = 'warn';
case Fail = 'fail';
public function isHealthy(): bool
{
return $this !== self::Fail;
}
}The aggregator and its budget
HealthAggregator runs every probe registered for the requested kind and combines the results, bounded by a HealthBudget:
final readonly class HealthBudget
{
public function __construct(
public int $perProbeDeadlineMs = 3000, // a single slow probe can't hang the whole check
public int $overallBudgetMs = 10000, // the entire aggregation has a hard ceiling
public int $readyCacheTtlMs = 1000, // /health/ready briefly caches its result
) {}
}perProbeDeadlineMs must be less than or equal to overallBudgetMs — enforced at construction, not discovered at 3am when a hung probe takes your readiness endpoint down with it. The short readyCacheTtlMs window means a load balancer hammering /health/ready every few hundred milliseconds doesn't re-run every probe on every single request.
Custom probes
use Vortos\Health\Probe\HealthProbeInterface;
use Vortos\Health\Probe\ProbeKind;
use Vortos\Health\Probe\ProbeResult;
use Vortos\Health\Probe\ProbeStatus;
use Vortos\OpsKit\Attribute\AsDriver;
use Vortos\OpsKit\Driver\Capability\CapabilityDescriptor;
#[AsDriver('payment-gateway')]
final class PaymentGatewayProbe implements HealthProbeInterface
{
public function __construct(private PaymentGatewayClient $client) {}
public function name(): string
{
return 'payment-gateway';
}
public function kind(): ProbeKind
{
return ProbeKind::Readiness;
}
public function capabilities(): CapabilityDescriptor
{
return CapabilityDescriptor::create([]);
}
public function check(): ProbeResult
{
try {
$this->client->ping();
return ProbeResult::pass($this->name());
} catch (\Throwable $e) {
return ProbeResult::fail($this->name(), $e->getMessage());
}
}
}Probes follow the Ops Kit driver pattern — #[AsDriver] plus registerForAutoconfiguration() is all the wiring needed; CollectHealthProbesPass collects every registered probe automatically.
Legacy compatibility
If your application still has health checks written against the older Vortos\Foundation\Health\Contract\HealthCheckInterface / #[AsHealthCheck] API, you don't need to migrate them. BridgeLegacyHealthChecksPass finds every class implementing the legacy interface and wraps each one in a LegacyHealthCheckProbe automatically — they run through the same aggregator, budget, and endpoints as native HealthProbeInterface probes, with zero code changes on your side.
Pre-flight diagnostics
vortos:doctor checks configuration correctness before you boot. This is separate from vortos:health, which probes live infrastructure:
php bin/console vortos:doctorTwo doctor checks specifically guard against a dangerous blind spot — having no way to know your monitoring itself has failed:
| Check | What it refuses |
|---|---|
LivenessIndependenceDoctorCheck | A liveness probe that depends on the same infrastructure it's meant to detect failures in |
DetectorIndependenceDoctorCheck | Fewer than 3 independent failure detectors configured in production: in-app probes, the dead-man heartbeat, and an external synthetic uptime monitor |
[FAIL] health.detector_independence 1 of 3 independent failure detectors configured: in-app probes.
Fix: Wire the dead-man heartbeat (vortos-observability) and a real external synthetic
monitor driver (e.g. "betterstack") declaring SyntheticJourney — a dead host and its
own monitoring being dead simultaneously is the failure mode this check exists to catch.The reasoning behind requiring three independent detectors: if your only failure detection lives on the host that might fail, a host that goes fully dark (not just unhealthy — gone) never alerts anyone, because the thing that would have alerted is dead too. An external synthetic prober runs on someone else's infrastructure entirely, so it's the one detector that keeps working when yours doesn't.
This check is fail-closed in production, not elsewhere
Outside prod/production, a gap in detector independence surfaces as a passing check with an explanatory note — not a blocker. There's no advisory "warning" preflight state in the framework; every check is pass, fail, or skip, so non-prod environments intentionally aren't held to the same bar.