---
url: /site/health-checks.md
---
# Health Checks

Evolve monitors every install from the inside with [spatie/laravel-health](https://spatie.be/docs/laravel-health). The split with [Oh Dear](/integrations/oh-dear) is deliberate: Oh Dear watches what is visible from the outside (uptime, certificates, DNS, broken links, mixed content, Lighthouse), Evolve's own checks cover what Oh Dear cannot see (database, cache, disk, queue, scheduler, mail transport and configuration posture). There is no overlap and no in-app SSL or performance check.

![The health dashboard in the panel with the result of every check](/screenshots/panel-health.png)

Three places show or serve the results:

| Where | What | Who |
|---|---|---|
| `/evolve/health` | The panel dashboard: status, duration, last run and five days of history per check, failures first, plus Run Checks and Purge buttons | admins and developers |
| `/oh-dear-health-check-results` | The results of the last scheduled run as JSON for Oh Dear's application health | Oh Dear, with the `oh-dear-health-check-secret` header |
| `GET /api/health-check` | System information (versions, drivers, extensions, tracking posture, editorial activity), not check results | Fleet and dashboards, with a bearer token |

## The checks

All checks are registered by the package and run through the Laravel scheduler, nothing to wire in the project.

| Check | What it verifies | Cadence | Toggle |
|---|---|---|---|
| Used disk space | Warns above 70 percent, fails above 90 percent | every minute | `HEALTH_CHECK_DISK_SPACE` |
| Database | Connectivity | every minute | `HEALTH_CHECK_DATABASE` |
| Cache | Write, read and forget roundtrip | every minute | `HEALTH_CHECK_CACHE` |
| Optimized app | Config and route caches present | every minute | `HEALTH_CHECK_OPTIMIZED_APP` |
| Debug mode | `APP_DEBUG` is off | every minute | `HEALTH_CHECK_DEBUG_MODE` |
| Environment | `APP_ENV` and `EVOLVE_ENV_TYPE` agree | every minute | `HEALTH_CHECK_ENVIRONMENT` |
| Canonical host | `APP_URL` names a real host | every minute | `HEALTH_CHECK_CANONICAL_HOST` |
| Tracking | A live site resolves its tag manager and GA4 ids and emits tracking | every minute | `HEALTH_CHECK_TRACKING` |
| Shadowed config | No project config empties a value the package provides | every minute | `HEALTH_CHECK_SHADOWED_CONFIG` |
| API keys | Every enabled module has the keys it requires, for the locator `GOOGLE_GEOCODING_API_KEY` and `GOOGLE_MAPS_API_KEY` | every minute | `HEALTH_CHECK_API_KEYS` |
| Timezone | `app.timezone` is not UTC, because editors type Swiss local time (2.19) | every minute | `HEALTH_CHECK_TIMEZONE` |
| Module switches | Every module has an explicit switch, no Brevo key is set while the email module is off, and no module is switched on without its package (`laravel/mcp`, `laravel/ai`) (2.21) | every minute | `HEALTH_CHECK_MODULE_SWITCHES` |
| Scheduler | The scheduler heartbeat is younger than two minutes | every minute | `HEALTH_CHECK_SCHEDULE` |
| Queue | The heartbeat job was processed within five minutes | every minute | `HEALTH_CHECK_QUEUE`, auto when unset |
| Mailer | The mail transport is usable, plus one real test mail a day | hourly | `HEALTH_CHECK_EMAIL` |
| Shopify API | The Admin API answers with the stored credentials. Registered by the Shopify module, absent while the module is off (2.21). | hourly | `HEALTH_CHECK_SHOPIFY`, default off |
| Scout24 API | OAuth and the listings endpoint answer. Registered by the Scout24 module, absent while the module is off (2.21). | hourly | `HEALTH_CHECK_SCOUT24`, default off |
| Email directory | The contact sync provider is reachable and set up, no sync failures are open. Registered by the email module, reports `off` without `EMAIL_DRIVER` (2.17). See [Brevo](/integrations/brevo#health-check). | hourly | none |

Every toggle defaults to on except the two API checks and the queue check, which is auto detected. Checks with a longer cadence store a `skipped` result until they are due, and skipped results never fail the batch.

### Mailer&#x20;

The check probes the transport on every run without sending anything: SMTP transports connect and authenticate, sendmail transports need an executable binary, API transports are not probed. A real test mail goes out at most once a day, and only to `HEALTH_CHECK_EMAIL_RECIPIENT`, falling back to the alert address (Settings > Mail, fallback `HEALTH_WEBMASTER_ADDRESS`). It never falls back to `MAIL_FROM_ADDRESS`, because on a client install that is the client's own mailbox. Without a recipient the check reports the transport result and sends nothing. Point the recipient at an agency address, never at a client mailbox. The check skips itself when the mail driver is `array` or `log`.

What it does not do: verify inbox placement or bounces. Those are provider concerns (Brevo statistics and webhooks). On staging the test mail is subject to the [mail redirect](/getting-started/mail) like every other message.

### Tracking

A live site that silently stops loading its tag manager still answers 200, so Oh Dear cannot see it, and the loss usually surfaces weeks later as missing numbers. The check reads the resolved config, not the `.env`, so a shadowing project config is caught too. It fails on live when neither `GTM_ID` nor `ANALYTICS_MEASUREMENT_ID` resolves, when an id does not start with `GTM-` or `G-` respectively, and when `TRACKING_EMIT=false` silences a live site (the typical shape of a staging `.env` copied to live). It warns when `TRACKING_EMIT=true` fires tags on a staging site, and when the consent policy is opt in while both the consent banner and the consent via CMP option are off, because nothing can grant consent then. A site whose consent comes from a platform loaded through the tag manager switches the CMP option on (Settings > Tracking, Consent card, since 2.18) and the warning goes away. The check's meta names the source of the GTM and GA4 IDs (`gtm_id_source`, `ga_measurement_id_source`: `settings` or `config`); a site without tracking on purpose sets `HEALTH_CHECK_TRACKING=false`. Background on [Tracking and Consent](/site/tracking-and-consent).

### Shadowed config

Lists `services` values that resolve to something in the package but end up empty because a project config replaced them, typically an old published `config/services.php` that reads a differently named variable (`GOOGLE_TAG_MANAGER_ID` instead of `GTM_ID`). Evolve records these while it merges the config, so the list survives `config:cache`. A shadowed key that a stored value from the [settings store](/panel/settings-store) resolves is listed under `resolved_by_settings` in the check's meta and does not warn .

### API keys&#x20;

The package ships no API keys of its own. A feature that lacks one degrades quietly (the store finder loses its map, a saved location keeps empty coordinates), so each enabled module declares the env keys it requires and this check lists the ones that resolve to nothing, with the module that needs them. Today that is the [locator](/modules/locator#requirements) with `GOOGLE_GEOCODING_API_KEY` and `GOOGLE_MAPS_API_KEY`. A disabled module asks for nothing, so a site without the locator reports "None required". Set the keys in the `.env` or in the panel settings (Tracking, Google).

A module declares its keys in the `settings` entry of `Evolve::registerModule()`: `'settings' => ['required' => ['MY_API_KEY' => 'services.my-service.key']]`, env variable to config path.

### Timezone&#x20;

Evolve stores and compares every date an editor types (block scheduling, datetime blueprint fields, blog and job publication dates) in `app.timezone`, without converting anything. Editors type Swiss local time, so on a site running on UTC a block scheduled for 17:00 switches at 19:00 Swiss time (18:00 in winter). The panel shows the same value back, which is why nobody notices.

The check warns when `app.timezone` is `UTC` (in any spelling, an empty value counts as UTC) and is ok with any other timezone, which it names in the summary. The meta lists `app_timezone` and `php_default` (the PHP default timezone Laravel sets from it).

To fix it, set `APP_TIMEZONE=Europe/Zurich` in the server `.env` and run `php artisan config:cache`. The project's `config/app.php` must read `'timezone' => env('APP_TIMEZONE', 'UTC')`; many older projects hardcode `'UTC'` there. After the switch the stored editor input reads correctly, because it was Swiss time all along. Only machine timestamps written before the switch (`created_at`, `updated_at`, the activity log) now show one to two hours early. Evolve deliberately has no display conversion layer: it would shift the stored editor input instead.

### Module switches&#x20;

A project that published `config/evolve.php` long ago lists only the modules that existed then. A module missing from that list runs on its own default, without anyone having switched it, and changes with the default when a release changes it. The check names every module nothing switches, as the `EVOLVE_MODULE_<KEY>` variables to set. Set each to the state the site needs, in the `.env` or in the Developers tab of the settings, then run `php artisan config:cache`. The check also warns when a Brevo key is set but the email module is off, because newsletter sync and campaigns then do not run.

The meta lists `on_default` (the module keys) and `brevo_key_without_module`. See [Modules](/modules/#switching-a-module-on-or-off).

### Canonical host

`APP_URL` is the host the site calls itself. The sitemap builds every entry from it, and the page cache refuses to write under any other host. Both failures are quiet, a sitemap of `http://localhost/` still returns 200. The check warns when `APP_URL` does not name a real host and fails when it does not while the [page cache](/site/page-cache) is enabled.

### Environment

`APP_ENV` is Laravel's switch, `EVOLVE_ENV_TYPE` is Evolve's posture (mail redirect, preview gate, noindex). The deploy writes both from the environment declaration. A mismatch fails, a remote without a declared type warns, because the type is then only inferred from `APP_ENV`. See [Environments](/getting-started/environments).

### Queue

Fails when queued jobs stop being processed: the scheduler dispatches a heartbeat job every minute, the check fails when it has not run within five minutes. Unset means auto detect, the check runs when the queue driver is not `sync`. `true` or `false` pins it; in the Health tab of the settings panel this is the select Auto, On, Off . Since 2.12 image conversions run on the queue, so a stopped worker or an image backlog shows up here, see [Image Rendering](/frontend/image-rendering#monitoring).

## How it runs

The package schedules everything itself:

* `health:check` runs every minute and runs every check that is due.
* `health:schedule-check-heartbeat` runs every minute and feeds the scheduler check.
* `health:queue-check-heartbeat` runs every minute when a real queue driver is configured and feeds the queue check.
* Results are stored in `health_check_result_history_items` and pruned daily after `HEALTH_KEEP_HISTORY_DAYS` (default 5). `php artisan evolve:health-purge [--days=N] [--all] [--force]` or the Purge button on the dashboard clean up by hand.

All of this needs the Laravel scheduler in the server's cron, see [Deployment](/getting-started/deployment). Run the checks by hand with `php artisan health:check`, or with the Run Checks button on the dashboard, which passes `--no-notification`. `php artisan health:list` prints the registered checks.

## Switching checks on and off

The Health tab of the settings panel (admins and developers) carries the toggles for disk space, database, cache, optimized app, mailer, scheduler, debug mode, environment, Shopify and Scout24, the queue select, the notification switch, the Oh Dear secret (masked), the mailer recipient and the history retention. The alert address that receives failure notifications moved to the Mail tab in 2.18 (settings store); the Health tab shows the effective one with a pointer there. The canonical host, tracking, shadowed config, API keys, timezone and module switches toggles are `.env` only. Everything the tab writes is a `HEALTH_*` variable, so a project can pin any of it in the `.env` or in the environment declaration instead.

## Oh Dear

Oh Dear polls `https://<site>/oh-dear-health-check-results` for the results of the last scheduled run. The route exists only while `OH_DEAR_HEALTH_CHECK_SECRET` is set (Settings > Health > Oh Dear, or the `.env`), and Oh Dear must send the secret as the `oh-dear-health-check-secret` request header. Oh Dear alerts when a check flips, when it recovers, and when results are older than ten minutes; that stale alert is the watchdog for a dead scheduler or a crashed application. Leave `HEALTH_OH_DEAR_FRESH_RESULTS` off: on, every poll would run every due check inside the request. The per-site setup in Oh Dear (uptime, certificates, crawls, status page, alert routing) is on [Oh Dear](/integrations/oh-dear).

Test with:

```bash
curl -H "oh-dear-health-check-secret: <secret>" https://<site>/oh-dear-health-check-results
```

## Notifications without Oh Dear

Installs that Oh Dear does not monitor can mail failures directly:

```ini
HEALTH_NOTIFICATIONS_ENABLED=true
HEALTH_WEBMASTER_ADDRESS=alerts@example.com
```

Notifications fire only for failing results, are throttled to one per hour, and use the mail channel. They go to the alert address: the value on Settings > Mail when stored, otherwise `HEALTH_WEBMASTER_ADDRESS`. The alert address is also the fallback recipient of the mailer check's daily test mail. A published health config that names `Spatie\Health\Notifications\Notifiable` follows the alert address too, because Evolve binds its own notifiable in its place (2.18). The sender of the failure mails is still `MAIL_FROM_*` from the `.env`, not the stored sender.

## Configuration reference

The package merges `core/config/health.php`; everything operational is driven by variables and the file is deliberately not publishable. A project that still carries a published `config/health.php` from an earlier version should delete it, otherwise the merged defaults (cached Oh Dear results, notification behaviour, the toggles) never reach that install.

| Variable | Default | Purpose |
|---|---|---|
| `HEALTH_CHECK_<NAME>` | mostly `true` | Per-check toggles, see the table above. `HEALTH_CHECK_SHOPIFY` and `HEALTH_CHECK_SCOUT24` default to `false`; `HEALTH_CHECK_QUEUE` unset means auto. |
| `HEALTH_CHECK_EMAIL_RECIPIENT` | unset | Recipient of the daily test mail. No fallback to `MAIL_FROM_ADDRESS`. |
| `HEALTH_WEBMASTER_ADDRESS` | unset | Fallback for the alert address (Settings > Mail): notification recipient and fallback recipient of the daily test mail. |
| `HEALTH_NOTIFICATIONS_ENABLED` | `false` | Direct failure mails. |
| `HEALTH_KEEP_HISTORY_DAYS` | `5` | Retention of results. |
| `HEALTH_DB_CONNECTION` | unset | A separate database connection for the result store. |
| `OH_DEAR_HEALTH_CHECK_SECRET` | unset | Enables and protects the Oh Dear endpoint. |
| `HEALTH_OH_DEAR_FRESH_RESULTS` | `false` | Run checks inside the Oh Dear poll. Avoid. |
| `SCHEDULE_HEARTBEAT_URL` | unset | A URL pinged whenever the scheduler check passes, for an external dead man's switch. |
| `EVOLVE_HEALTH_CHECK_TOKEN` | unset | Bearer token for the `api/health-check` endpoint below. |

Variables from earlier versions that are inert now and can be removed from the `.env`: `HEALTH_CHECK_SECURITY`, `HEALTH_CHECK_PERFORMANCE`, `HEALTH_CHECK_EMAIL_VERIFY_INBOX`, `HEALTH_CHECK_IMAP_*`, `HEALTH_SLACK_WEBHOOK_URL`, `HEALTH_SECRET_TOKEN`, `HORIZON_HEARTBEAT_URL`, `HEALTH_CHECK_CONCURRENT_*`.

## System information endpoint

`GET /api/health-check` reports configuration and environment details for dashboards and inventories. It runs no checks and sends no mail; it performs a simple database connection test and reads config. Fleet, the agency inventory, polls it for every instance.

::: info
The name is historical. For check results use the dashboard or the Oh Dear endpoint above.
:::

### Authentication

Set `EVOLVE_HEALTH_CHECK_TOKEN` to a long random string and send it as a bearer token or as a `token` query parameter:

::: code-group

```bash [Header]
curl -H "Authorization: Bearer $TOKEN" https://<site>/api/health-check
```

```bash [Query parameter]
curl "https://<site>/api/health-check?token=$TOKEN"
```

:::

A missing or wrong token answers `401` with `{"error": "Unauthorized"}`. Common causes: the variable is not set, the token differs, or the header is missing.

### Response

```json
{
  "status": "healthy",
  "timestamp": "2026-10-13T07:44:41.000Z",
  "system": {
    "php_version": "8.3.19",
    "laravel_version": "12.32.5",
    "environment": "production",
    "debug": false,
    "timezone": "UTC",
    "memory_limit": "256M",
    "max_execution_time": "60",
    "upload_max_filesize": "64M",
    "post_max_size": "64M"
  },
  "project": {
    "name": "Your Project Name",
    "url": "https://yourdomain.com",
    "locale": "en",
    "fallback_locale": "en"
  },
  "database": { "connection": "mysql", "status": "connected" },
  "mail": { "driver": "smtp", "from_address": "noreply@yourdomain.com", "from_name": "Your Project Name" },
  "cache": { "default": "redis" },
  "session": { "driver": "redis", "lifetime": "120" },
  "queue": { "default": "redis" },
  "storage": { "default": "public" },
  "evolve": { "version": "2.16.1" },
  "environment": {
    "image_driver": "gd",
    "broadcast_driver": "log",
    "log_channel": "stack",
    "app_key_set": true,
    "maintenance_mode": false,
    "opcache_enabled": true,
    "extensions": { "gd": true, "imagick": false, "curl": true, "mbstring": true, "openssl": true, "pdo": true, "tokenizer": true, "xml": true, "ctype": true, "json": true, "bcmath": true, "fileinfo": true }
  },
  "tracking": {
    "environment_type": "live",
    "emits": true,
    "emit_override": null,
    "gtm_id": "GTM-XXXXXX",
    "ga_measurement_id": "G-XXXXXXXXXX",
    "meta_pixel_id": null,
    "pinterest_tag_id": null,
    "linkedin_partner_id": null,
    "hotjar_site_id": null,
    "consent_policy": "opt-out",
    "server_side": true,
    "ga_api_secret_set": true,
    "meta_conversions_api_key_set": false,
    "meta_test_event_code_set": false,
    "legacy_variables": false,
    "sources": { "gtm_id": "settings", "ga_measurement_id": "config", "meta_pixel_id": null, "pinterest_tag_id": null, "linkedin_partner_id": null, "hotjar_site_id": null, "consent_policy": "config", "server_side": "config" }
  },
  "content": {
    "last_change_at": "2026-10-12T15:02:11+00:00",
    "last_change_by": "Jane Editor",
    "last_change_subject": "Page: About us",
    "changes_30d": 14,
    "changes_90d": 41,
    "editors_90d": 2,
    "last_login_at": "2026-10-13T06:40:00+00:00",
    "last_login_by": "Jane Editor"
  }
}
```

The blocks in detail:

* **system, project, database, mail, cache, session, queue, storage**: PHP and Laravel versions and limits, app name and locales, and the configured driver per subsystem. Only the database block carries a live status, the rest reports configuration. Since 2.18 the `mail` block also lists `reply_to_address`, `notification_address` and `alert_address` and a `sources` map for all five mail addresses, like `tracking.sources`.
* **environment**: image driver, broadcast driver, log channel, whether `APP_KEY` is set, maintenance mode, OPcache, and which PHP extensions are loaded.
* **tracking**: environment type, whether the site emits tracking and the `TRACKING_EMIT` override, every public id, the consent policy, the cookie banner and the consent via CMP option (2.18), server side conversions, and whether the GA4 API secret, the Meta Conversions API key and a Meta test event code are set (booleans, never the values). `sources`  says per value where it comes from: `settings` (the [settings store](/panel/settings-store)), `config` (the `.env` or a config file) or `null`. Values are the resolved config, not the `.env`, so Fleet compares what each environment really carries.
* **content** : the last content change (when, by whom, on what), change counts over 30 and 90 days, distinct editors over 90 days, and the last successful panel login, read from the activity log. The block is always present, every value is `null` without data.

## Troubleshooting

**Every check shows "skipped" or the dashboard is stale.** The scheduler is not running. `crontab -l` on the server must list the `schedule:run` line, `php artisan schedule:list` shows the health commands. Oh Dear reports this as stale results after ten minutes.

**Mailer fails with a transport error.** The SMTP credentials or host in the Mail tab (or `MAIL_*`) are wrong; the check reproduces exactly what a form mail would hit. With sendmail, the configured binary is not executable.

**Mailer is green but no test mail arrives.** No recipient is configured (the meta says `no recipient configured`), the daily slot was already used (`not due`), or the site is staging and the message went to the redirect address.

**Queue fails right after a deploy.** The worker was not restarted, or `QUEUE_CONNECTION` was switched to a real driver without a worker. Pin `HEALTH_CHECK_QUEUE=false` only for sites that run everything synchronously on purpose.

**Timezone still warns after setting `APP_TIMEZONE`.** The project's `config/app.php` hardcodes `'timezone' => 'UTC'` instead of reading `env('APP_TIMEZONE')`, or the config cache is stale. Fix the file, then run `php artisan config:cache`.

**Tracking fails on a site that has no tracking.** Set `HEALTH_CHECK_TRACKING=false` for that site instead of inventing ids.

**A toggle in the panel has no effect.** The project carries a published `config/health.php` from an earlier version. Delete it.
