Skip to content

Health checks

A health check answers one question: is this dependency usable right now? The framework collects the answers, decides an overall status from the worst of them, and exposes that over HTTP and on the command line.

Classes: Pramnos\Health\HealthRegistry, Pramnos\Health\HealthCheck, Pramnos\Health\HealthCheckResult, Pramnos\Health\HealthStatus


What you get without writing anything

Every application registers these during init():

Check Reports
database Reachable, with latency, driver and server version. Skipped entirely when the connection failed at boot, so you get one error rather than two.
disk_space Free space and percentage used on the application root.
memory_limit Peak usage against memory_limit.
cache Whether the cache is on the store it was configured for. degraded when it fell back — the application works, on the wrong store.
hypertables Whether a declared hypertable is one, and whether a continuous aggregate is one. degraded when the catalogue disagrees with the declaration.
site_url The site's public root, and where it came from. degraded while it is only being inferred from the request.
llm_tables With the llm feature: whether the conversation tables take what the framework writes — space, and a nullable organization_id. degraded, naming table and column, while an application's own older tables refuse it; migrate fixes it.
framework_scripts Whether the web root has every assets/js/pf-*.js the framework ships, as the framework ships it. degraded, listing the missing and the outdated ones, until project:resync --js --all publishes them — composer update updates the scripts in vendor/, not the copies the pages load.
session_storage Which store PHP keeps sessions in. ok on one server whatever the store; degraded on local files when the application declares 'servers' => 2 or more.

With the authserver feature enabled, one more is registered by AuthServerServiceProvider:

Check Reports
signing_keys The RSA pair can be read, parsed, and actually signs and verifies. See Signing keys below.

Redis has a check too — Pramnos\Health\Checks\RedisConnectivityCheck — which is not registered by default because not every application uses Redis. Register it yourself if yours does.

Why cache is degraded rather than down

Cache walks down to the next adapter when the configured one cannot be reached, which is correct: a cache that cannot connect must not take the site down. The risk is that nobody is told. An application whose PHP image lacks the redis extension runs on local disk with redis in its settings, redis in its compose file and Redis on its bill — and passes every test. Invalidation is then per-server, so a two-node deployment serves whatever the node that was not asked still holds.

The check names both stores (Running on file, configured for redis) and hints at the missing extension, because the container is almost always up and the extension is almost always the answer. It reports degraded: the site is working, and a check that pages somebody for a working site is a check that gets muted.

Why session_storage is ok on files

Because on one server it is correct. files is PHP's default, it is free, it has no dependency and nothing to configure, and the existence of a shared-store option does not make not using it a fault. Either degraded or notice here would put an alarm colour beside a working site.

What the check is actually for survives in ok: it names the store. The common way to get sessions wrong is not leaving them on files — it is pointing them at a Redis the other nodes do not use, and that is visible in details.handler and details.path whatever the status.

It becomes degraded when the application says it has more than one node:

// app/config/app.php — only a deployment with more than one server needs this
'servers' => 2,

Declared rather than detected, because it cannot be detected: a node has no way to count its siblings behind a balancer, and guessing from the environment would make the answer depend on which machine answered the probe. One is assumed until you say otherwise.

On two nodes without a shared store, a visitor whose next request lands on the other one has no session — signed out at random on a site that is otherwise working, and it reads as an expiry, a cookie problem, a SameSite mistake. Everything except a load balancer. Nothing else reports it, which is the argument for the check.

APP_SESSION_HANDLER=redis in .env (or 'session' => ['handler' => 'redis'] in app/config/app.php) turns it green — pointed at a Redis this application controls. With no path it reuses the cache's host, and on a shared server, where one Redis serves every vhost, that puts session ids somewhere every other site on the machine can read. A session id is an account. Use a dedicated instance, or at least a separate database with its own credentials.

Sticky sessions at the balancer are the other answer, and this check cannot see them.

A handler this check does not recognise — user, or an extension it has not heard of — is a notice: it may reach a second server and it may not, and either answer would be a guess.

Why site_url is degraded when nothing is configured

A web request almost always infers a usable root, so the site works and nothing reports a problem. The process that cannot infer one is the scheduler: cron has no Host header, so a task putting a public URL into an email, a webhook payload or a feed builds http:///uploads/x.jpg — a string that reads as a bug in the caller and is a missing setting.

Set APP_URL in .env (or 'site_url' in app/config/app.php) and the check goes green. It reports the resolved address either way, because the common mistake is not leaving it unset but setting it to the wrong thing — a staging host copied into production, or http on a site behind a TLS-terminating proxy. One line of output showing the address the application believes in is what makes that visible.

See SiteUrl for why sURL cannot answer this.


The endpoints

GET /health          HTML dashboard          (sign-in required)
GET /health/check    Full JSON report        (public)
GET /health/status   Flattened verdict       (public — the safe monitor URL)
GET /health/phpinfo  phpinfo()               (usertype >= 90)
GET /Status          Status page for people  (public — verdicts by name only)

Both JSON endpoints are deliberately public, because a monitor calls them with no credentials, and both answer with the status in the body and in the HTTP code.

/health/check is the full report, to a signed-in administrator:

{
  "status": "ok",
  "checks": {
    "database": { "status": "ok", "name": "database", "message": "Reachable", "details": { "latency_ms": 4.7 } }
  }
}

To everybody else it gives the verdict, its HTTP code, and each check by name as ok or error. ok covers both ok and notice.

{ "status": "degraded", "checks": { "database": { "status": "ok" }, "redis": { "status": "error" } } }

What a check found, its message and details, names versions, drivers, hosts and latencies: it is a map of the installation. It is shown to anybody only where app.php says so, with the same switch as the authorization server's discovery health:

'health' => ['public_details' => true],
Overall status HTTP
ok 200
notice 200
degraded 503
down 503

degraded answering 503 is a decision worth knowing about: a monitor that only looks at the status code treats reduced capacity as an outage. That is the safer default — a cache that has stopped working is not something to discover a week later — but if you want the two distinguished, read status from the body.

notice exists for the one category where that default would be wrong. "Correct here, and would not be everywhere" is a real answer, and reporting it as degraded would make a correct installation answer 503 to the uptime monitor this endpoint exists for. notice appears in the JSON, in the table and on the badge, and does not change the status code.

/health/status — when the full report says too much

{ "status": "healthy", "timestamp": "2026-08-25T14:46:08+00:00", "service": "My App" }

Same verdict, three keys. When something is wrong it adds the names of the failing checks and nothing else:

{ "status": "unhealthy", "timestamp": "…", "service": "My App", "errors": ["redis", "disk_space"] }

Reach for it in two situations.

The probe cannot read a nested document. A load balancer health probe, a status page widget or a shell script wants one field. Asking it to walk checks.*.status is how a probe ends up parsing JSON with grep.

The endpoint is reachable from the internet. With public_details on, /health/check publishes versions, drivers, paths and latencies in details. That is a fair trade on a private network and not one to make publicly — a database version and a driver name are a starting point for somebody who is looking for one. /health/status gives away whether the application is well and where to look, which is what an operator needs and all an attacker gets.

It does not re-probe anything: both endpoints read the same HealthRegistry::runAll(). Two endpoints answering the same question from two sets of probes is how they come to disagree.

Whichever you expose, keep detail out of what you add. If a check's details would carry a hostname or a fragment of a credential, that is worth a second thought even on /health/check.

/Status — the page for people

Pramnos\Application\Controllers\Status extends the Health controller with a public page: the overall verdict in words ("Everything is working", "Partly available", …) and each check's verdict by name, run when the page loads. It never shows a check's message or details, to anybody — an administrator reads those on /health. Its own check and status actions are the inherited JSON endpoints.

init scaffolds src/Controllers/Status.php, a wrapper the application can extend; the views are status/status.html.php in each scaffolded theme. It is what a footer's "Status" link points at.


From the command line

php pramnos health:check                        # a table
php pramnos health:check --json                 # the same report as JSON
php pramnos health:check --only=database,cache  # just these

Exit codes make it usable in CI or a deploy gate: 0 all ok, 1 something degraded, 2 something down. A notice is a success.

The command runs the same set as /health/check: the built-in checks listed above, those registered by enabled features' service providers, and your own Application::init() registrations.


Writing a check

Implement HealthCheck. Two methods, and one rule.

use Pramnos\Health\HealthCheck;
use Pramnos\Health\HealthCheckResult;

class PaymentGatewayCheck implements HealthCheck
{
    public function getName(): string
    {
        return 'payment_gateway';
    }

    public function run(): HealthCheckResult
    {
        try {
            $latency = $this->ping();
        } catch (\Throwable $e) {
            return HealthCheckResult::down($this->getName(), 'Unreachable: ' . $e->getMessage());
        }

        if ($latency > 2.0) {
            return HealthCheckResult::degraded(
                $this->getName(),
                'Responding slowly',
                ['latency_s' => $latency]
            );
        }

        return HealthCheckResult::ok($this->getName(), 'Reachable', ['latency_s' => $latency]);
    }
}

The rule: run() must not throw. A check that raises takes the whole report with it, so the one dependency you were worried about brings down the endpoint you were going to use to find out about it. Catch everything and return down().

The name is a contract. The JSON report is keyed by it, so a monitor or dashboard reading checks.payment_gateway breaks if you rename it. Use a stable snake_case identifier.

Registering it

// In Application::init(), after parent::init()
\Pramnos\Health\HealthRegistry::register(new PaymentGatewayCheck());

Or, if the check belongs to a feature, in that feature's service provider boot() — which is how signing_keys arrives. register() is keyed by name and idempotent, so registering the same name twice replaces rather than duplicates; that is also how an application overrides a built-in check with its own.

Choosing a status

  • down — the application cannot do its job. Somebody should be woken up.
  • degraded — it works, at reduced capacity or with a problem that will become an outage if ignored. A cache that is unreachable, a disk at 95%, a key that is smaller than it should be.
  • notice — worth reading once, and not a fault. A session handler this framework does not recognise, where neither "shared" nor "local" can be established. It does not change the HTTP code and does not fail health:check.
  • ok — with details carrying whatever an operator would want from a green result. A latency figure or a key size in a passing check is what tells someone the trend before it crosses a threshold.

Before reaching for a status above ok, ask what the reader is supposed to do. If the answer is "nothing, this installation is correct", it is ok — and anything the operator might want goes in details, which is where a check's value usually lives. Sessions on local files are the worked example: the shared store exists, and choosing not to use it on one machine is not a fault.

notice is for "this is genuinely unclear and you should know". degraded is for something wrong: an unset APP_URL makes every URL built outside a request wrong on one server exactly as much as on five, so site_url stays degraded.

Getting this wrong in the direction of severity is not a harmless over-report. A check that pages for a correct installation is a check somebody mutes, and a muted check is the one that was going to tell you about the real thing.


Signing keys

This one deserves its own section, because it is the check that exists for a failure the others cannot see.

An authorization server whose private key has gone missing answers every page normally. The database is fine, the disk is fine, memory is fine, and every /oauth/token request returns a 500. Without this check, /health/check reports ok on exactly that server.

So signing_keys does not ask whether the files exist. It walks the states in which they exist and the server still cannot issue a usable token:

State Result
openssl not loaded, or the OAuth2 library missing down
A key file missing, unreadable, or a directory down — and it says which half
A key file present but unparseable (truncated write, mangled PEM) down
Both keys valid, but from different pairs down
A matching pair below 2048 bits degraded
A matching pair, 2048 bits or more ok, with bits in the details

The mismatched-pair case is why the check signs and verifies a constant rather than stopping at file tests. Both files parse, both are real keys, every file test passes — and no token this server issues can be verified by anybody, so the failure appears in somebody else's application. One small signature rules it out.

The pair is made by php bin/pramnos oauth:keys: RSA 2048 in app/keys/, the private key 0600, with encryption.key beside it (made once, never replaced). It refuses to replace an existing pair without --force, because a new key invalidates every token signed with the old one. Run as root, it gives the files to the web server's user with --owner=www-data, and warns without it: a 0600 key owned by root is one the web server cannot open.

The server never makes a key itself. A pair made mid-request belongs to whatever user the web server runs as, is never backed up, and a fresh container makes a fresh one, which signs everybody out of every client. Until oauth:keys runs, this check is down with the command to run, and the token endpoint refuses with the same message in the oauth log.

The paths come from OAuth2ServerFactory::defaultPrivateKeyPath() and defaultPublicKeyPath(), so the check always reports on the keys the server actually signs with. Pass explicit paths to the constructor if yours live elsewhere:

HealthRegistry::register(new \Pramnos\Auth\Health\SigningKeysCheck(
    '/etc/keys/oauth-private.pem',
    '/etc/keys/oauth-public.pem'
));

  • Console — the health:check command among the rest
  • /.well-known/health — an authserver-only endpoint that reads the same registry and answers healthy/unhealthy with 200/503, naming the components only with 'health' => ['public_details' => true]; see Third-Party Integration
  • Third-Party Integration — what the signing keys are used for
  • Redis — registering the Redis connectivity check