Skip to content

Health Checks ​

Evolve monitors every install from the inside with spatie/laravel-health. The split with 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

Three places show or serve the results:

WhereWhatWho
/evolve/healthThe panel dashboard: status, duration, last run and five days of history per check, failures first, plus Run Checks and Purge buttonsadmins and developers
/oh-dear-health-check-resultsThe results of the last scheduled run as JSON for Oh Dear's application healthOh Dear, with the oh-dear-health-check-secret header
GET /api/health-checkSystem information (versions, drivers, extensions, tracking posture, editorial activity), not check resultsFleet 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.

CheckWhat it verifiesCadenceToggle
Used disk spaceWarns above 70 percent, fails above 90 percentevery minuteHEALTH_CHECK_DISK_SPACE
DatabaseConnectivityevery minuteHEALTH_CHECK_DATABASE
CacheWrite, read and forget roundtripevery minuteHEALTH_CHECK_CACHE
Optimized appConfig and route caches presentevery minuteHEALTH_CHECK_OPTIMIZED_APP
Debug modeAPP_DEBUG is offevery minuteHEALTH_CHECK_DEBUG_MODE
EnvironmentAPP_ENV and EVOLVE_ENV_TYPE agreeevery minuteHEALTH_CHECK_ENVIRONMENT
Canonical hostAPP_URL names a real hostevery minuteHEALTH_CHECK_CANONICAL_HOST
TrackingA live site resolves its tag manager and GA4 ids and emits trackingevery minuteHEALTH_CHECK_TRACKING
Shadowed configNo project config empties a value the package providesevery minuteHEALTH_CHECK_SHADOWED_CONFIG
API keysEvery enabled module has the keys it requires, for the locator GOOGLE_GEOCODING_API_KEY and GOOGLE_MAPS_API_KEYevery minuteHEALTH_CHECK_API_KEYS
Timezoneapp.timezone is not UTC, because editors type Swiss local time (2.19)every minuteHEALTH_CHECK_TIMEZONE
Module switchesEvery 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 minuteHEALTH_CHECK_MODULE_SWITCHES
SchedulerThe scheduler heartbeat is younger than two minutesevery minuteHEALTH_CHECK_SCHEDULE
QueueThe heartbeat job was processed within five minutesevery minuteHEALTH_CHECK_QUEUE, auto when unset
MailerThe mail transport is usable, plus one real test mail a dayhourlyHEALTH_CHECK_EMAIL
Shopify APIThe Admin API answers with the stored credentials. Registered by the Shopify module, absent while the module is off (2.21).hourlyHEALTH_CHECK_SHOPIFY, default off
Scout24 APIOAuth and the listings endpoint answer. Registered by the Scout24 module, absent while the module is off (2.21).hourlyHEALTH_CHECK_SCOUT24, default off
Email directoryThe 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.hourlynone

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 2.16.1 ​

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 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.

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 resolves is listed under resolved_by_settings in the check's meta and does not warn 2.12.

API keys 2.19 ​

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 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 2.19 ​

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 2.21 ​

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.

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 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.

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 2.12. Since 2.12 image conversions run on the queue, so a stopped worker or an image backlog shows up here, see Image Rendering.

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. 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.

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.

VariableDefaultPurpose
HEALTH_CHECK_<NAME>mostly truePer-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_RECIPIENTunsetRecipient of the daily test mail. No fallback to MAIL_FROM_ADDRESS.
HEALTH_WEBMASTER_ADDRESSunsetFallback for the alert address (Settings > Mail): notification recipient and fallback recipient of the daily test mail.
HEALTH_NOTIFICATIONS_ENABLEDfalseDirect failure mails.
HEALTH_KEEP_HISTORY_DAYS5Retention of results.
HEALTH_DB_CONNECTIONunsetA separate database connection for the result store.
OH_DEAR_HEALTH_CHECK_SECRETunsetEnables and protects the Oh Dear endpoint.
HEALTH_OH_DEAR_FRESH_RESULTSfalseRun checks inside the Oh Dear poll. Avoid.
SCHEDULE_HEARTBEAT_URLunsetA URL pinged whenever the scheduler check passes, for an external dead man's switch.
EVOLVE_HEALTH_CHECK_TOKENunsetBearer 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:

bash
curl -H "Authorization: Bearer $TOKEN" https://<site>/api/health-check
bash
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 2.12 says per value where it comes from: settings (the 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 2.9.1: 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.