net-os / laravel-vitals
Health checks for Laravel, centrally and per tenant, with a Horizon-style dashboard.
Requires
- php: ^8.3
- illuminate/contracts: ^11.0||^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- stancl/tenancy: ^3.9
Suggests
- laravel/horizon: Required for HorizonCheck
- stancl/tenancy: Run tenant checks for every stancl/tenancy tenant (^3.9)
Provides
None
Conflicts
None
Replaces
None
README
Health checks for Laravel, both for the whole application and per tenant, with a dashboard in the style of Horizon.
- You write checks as small PHP classes and register them in code, either centrally or per tenant.
- A dispatcher runs every minute and puts the checks that are due on the queue. A tenant's checks run together in one job, inside a single tenancy initialization.
- Results go to the central database:
vitals_resultsholds the history (14 days by default) andvitals_statesholds the latest state. - A server-rendered Blade dashboard lives at
/vitals, behind a configurable gate, in light or dark mode. You can mute known problems from it. /vitals/status.jsonreturns 200 or 503 for an external monitor.- There are no notification channels. Listen to the
CheckStatusChangedandCheckFinishedevents to add your own.
Tenancy is optional. Vitals detects stancl/tenancy on its own, and you can plug in any other tenancy package with a TenancyAdapter.
Installation
composer require net-os/laravel-vitals php artisan vitals:install
The install command publishes config/vitals.php and the migration, and offers to run the migration.
Registering checks
Register checks in a service provider:
use NetOs\Vitals\Checks; use NetOs\Vitals\Facades\Vitals; use NetOs\Vitals\Tenancy\TenantReference; Vitals::central([ Checks\DatabaseCheck::new(), Checks\CacheCheck::new(), Checks\RedisCheck::new()->failWhenMemoryAboveMb(500), Checks\DiskSpaceCheck::new()->warnWhenUsedAbove(80)->failWhenUsedAbove(90), Checks\FailedJobsCheck::new(), Checks\HorizonCheck::new(), Checks\SchedulerHeartbeatCheck::new()->everyMinute(), Checks\DebugModeCheck::new(), Checks\EnvironmentCheck::new(), Checks\OptimizedAppCheck::new(), ]); Vitals::tenant([ Checks\TenantDatabaseCheck::new(), Checks\PendingMigrationsCheck::new()->hourly(), DeviceSyncCheck::new()->onlyFor(fn (TenantReference $tenant) => $tenant->model->hasDevices()), ]);
Central checks run every 5 minutes and tenant checks every 15 minutes, unless a check sets its own frequency. The available frequencies are everyMinute(), everyFiveMinutes(), everyTenMinutes(), everyFifteenMinutes(), everyThirtyMinutes(), hourly(), hourlyAt(15), daily(), dailyAt('06:00') and cron('…'). Vitals spreads tenants over the interval using a stable offset based on the tenant key, so they do not all run in the same minute.
A check registered with onlyFor() runs only for the tenants that pass the callback. The dashboard shows it as "Not applicable" for every other tenant.
If two checks in the same scope have the same key, Vitals throws an exception. Use ->withKey('…') to tell them apart, and ->withLabel('…') to change the name the dashboard shows.
Writing a check
use NetOs\Vitals\Check; use NetOs\Vitals\Result; class Auth0SyncCheck extends Check { public function run(): Result { $lastSync = SyncLog::latest()->first(); if ($lastSync === null) { return Result::warning('No sync has run yet'); } return $lastSync->succeeded ? Result::ok("Synced {$lastSync->users} users") : Result::failed($lastSync->error)->meta(['last_sync' => $lastSync->created_at->toDateTimeString()]); } }
A check returns ok, warning, failed or skipped. Vitals marks a check as crashed when it throws an exception or runs past its timeout (30 seconds by default, change it with ->timeout(10)). The exception is passed to report(). A tenant check runs inside the tenant context, so the default database connection is the tenant's database.
Running
Vitals adds vitals:dispatch (every minute, on one server) and vitals:prune (daily) to the scheduler. You only need schedule:run and a queue worker. To schedule them yourself, set vitals.schedule to false.
| Command | Purpose |
|---|---|
vitals:dispatch {--all} |
Put the checks that are due on the queue |
vitals:run {check?} {--tenant=} |
Run checks synchronously and print a table |
vitals:list |
List the registered checks with their schedule |
vitals:prune {--days=} |
Delete results older than vitals.retention_days |
Give Vitals its own queue (VITALS_QUEUE=vitals), so a busy default queue does not delay the checks.
Dashboard access
Access is decided in this order:
- A callback, if you set one:
Vitals::auth(fn (Request $request) => …). - Otherwise the
viewVitalsgate, if it is defined. - Otherwise only the
localenvironment.
status.json also accepts the X-Vitals-Token header when VITALS_STATUS_TOKEN is set. It returns 503 when a check has failed, crashed or gone stale, or when the dispatcher has not run for 3 minutes. A check counts as stale when it has not reported for twice its interval plus 5 minutes.
Muting
From the dashboard you can mute a known problem, so it no longer counts in the totals or in status.json, while its real status stays visible. You can mute four things:
- One check for one tenant.
- A central check.
- One check for all tenants.
- A whole tenant.
A mute lasts for a fixed period (1 hour up to 1 week), until the muted checks recover, or until you unmute it. You can add a reason, and the overview lists every active mute. status.json reports muted problems under muted instead of problems. The events carry a muted flag, so your own notifications can skip them.
Muting during a deploy
Mute everything at the start of a deploy and lift it at the end, so short hiccups don't page anyone:
php artisan vitals:mute --all --for=15m --reason=Deploy
# ... deploy ...
php artisan vitals:unmute --reason=Deploy
vitals:mute {check?} {--tenant=} {--central} {--all} {--for=15m|2h|1d} {--until-recovered} {--reason=} and vitals:unmute {id?*} {--reason=} {--all}.
Dashboard
- Recent changes: the overview lists the latest status changes over all checks and tenants.
- Tenant checks: the overview has a summary per tenant check, and each check has a page that shows it for every tenant (
/vitals/checks/{check}/tenants), with a button to mute it for all tenants. - Flapping: a check gets a "Flapping" badge when it switched between healthy and unhealthy at least 4 times in its last 12 runs. That usually means a threshold is too tight.
- Queue backlog: the overview and
status.jsonshow how many check jobs wait on the Vitals queue. Both warn, andstatus.jsonreturns 503, when the oldest job waits longer thanvitals.queue_backlog_minutes. This is measured live, so it still works when the queue is stuck. - Runbooks:
->runbook('https://…')on a check adds a link to the check page and arunbookfield tostatus.json. Every problem instatus.jsonalso has aurlto its check page. - Sentry: when a check crashes and the Sentry SDK is installed, the event id is stored in the result's meta. Set
VITALS_SENTRY_EVENT_URL(for examplehttps://my-org.sentry.io/issues/?query=id%3A{event_id}) to turn it into a link.
Tenancy
With stancl/tenancy installed, Vitals uses StanclTenancyAdapter. For each tenant it initializes tenancy once, runs all due checks, and ends tenancy in a finally block. If the tenant cannot be initialized, or its database cannot be reached, every check of that tenant is recorded as crashed with "Tenant could not be initialized". Results are always written to the central connection.
For any other tenancy package, implement NetOs\Vitals\Tenancy\TenancyAdapter (tenants(), find() and run()) and set vitals.tenancy.adapter to your class.
Events
CheckFinished($check, $tenant, $run)is dispatched after every run.CheckStatusChanged($check, $tenant, $from, $to, $run)is dispatched when the status changes, including on the first run, where$fromisnull.
Development
composer test # Pest composer analyse # Larastan (level 8) composer format # Pint composer serve # build the workbench and open http://127.0.0.1:8000/vitals
The workbench has three stancl tenants. Acme has a failing sync, Globex has a pending migration and Northwind has no database. It also contains seven days of history.
License
The MIT License (MIT). Please see License File for more information.