Search by

witify / devops

tibou-tech

Health endpoint that reports the state of a Laravel application to the Witify portal.

Package info

github.com/witify/devops

pkg:composer/witify/devops

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 0

v1.1.1 2026-09-22 15:23 UTC

This package is auto-updated.

Last update: 2026-09-22 15:24:43 UTC


README

Health endpoint that reports the state of a Laravel application to the Witify portal.

The package exposes GET /api/devops/health, protected by a bearer token. The response carries the latest results of the spatie/laravel-health checks registered by the application, plus the metadata the portal needs to link the application to its GitHub repository, Sentry project and backup bucket.

It also ships two generic checks, CpuCheck and FailedJobsCheck, and the ClientFacingCheck base class for checks whose label and message are shown to the client in French and English.

Requirements

PHP 8.0 to 8.4
Laravel 8.75 to 12
Database any, only for the failed_jobs table read by FailedJobsCheck and the health results table when you keep the default result store

spatie/laravel-health and spatie/cpu-load-health-check are installed with the package. spatie/laravel-backup is optional: when it is present, the backup name is reported to the portal.

Installation

composer require witify/devops

The service provider is discovered automatically.

Add the two variables to .env (and empty placeholders to .env.example):

PORTAL_HEALTH_TOKEN=
APP_VERSION=1.0.0

PORTAL_HEALTH_TOKEN is the secret the portal sends. Generate a random one per application. The endpoint answers 404 while the token is empty, so nothing is exposed before you configure it.

APP_VERSION is reported to the portal. Leave it out if the application has no version.

1. Configure laravel-health

Skip this step if the application already uses laravel-health.

php artisan vendor:publish --tag="health-config"
php artisan vendor:publish --tag="health-migrations"
php artisan migrate

The default result store keeps the results in the database. Both the migration and the config are documented in the laravel-health docs.

2. Register the checks

Checks are project configuration, so the application registers them, not the package. Create app/Providers/HealthServiceProvider.php:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Spatie\Health\Checks\Checks\BackupsCheck;
use Spatie\Health\Checks\Checks\DatabaseCheck;
use Spatie\Health\Checks\Checks\DebugModeCheck;
use Spatie\Health\Checks\Checks\EnvironmentCheck;
use Spatie\Health\Checks\Checks\ScheduleCheck;
use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck;
use Spatie\Health\Facades\Health;
use Witify\Devops\Checks\CpuCheck;
use Witify\Devops\Checks\FailedJobsCheck;

class HealthServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Health::checks([
            CpuCheck::new(),
            FailedJobsCheck::new(),
            UsedDiskSpaceCheck::new(),
            DatabaseCheck::new(),
            DebugModeCheck::new(),
            EnvironmentCheck::new(),
            ScheduleCheck::new()->heartbeatMaxAgeInMinutes(4),
            BackupsCheck::new()
                ->name('Database Backup')
                ->onDisk('backup')
                ->locatedAt(config('backup.backup.name'))
                ->youngestBackShouldHaveBeenMadeBefore(now()->subDays(1)->subHours(2))
                ->atLeastSizeInMb(1),
        ]);
    }
}

Keep only the checks that apply. An application without a queue worker has no QueueCheck, an application without Redis has no RedisCheck. The portal displays whatever the application reports.

BackupsCheck::onDisk() builds the disk at boot, so an S3 disk without credentials crashes every request. Register the backup checks only when the disk is configured, for example if (filled(config('filesystems.disks.backup.region'))).

Register the provider:

  • Laravel 8 to 10: add App\Providers\HealthServiceProvider::class to the providers array of config/app.php.
  • Laravel 11 and 12: add it to bootstrap/providers.php.

3. Schedule the checks

The checks run from the scheduler. The portal reads the stored results, so results must exist before the portal calls the endpoint.

Laravel 8 to 10, in app/Console/Kernel.php:

protected function schedule(Schedule $schedule): void
{
    $schedule->command('health:check')->everyMinute();
    $schedule->command('health:schedule-check-heartbeat')->everyMinute();
    $schedule->command('model:prune', ['--model' => \Spatie\Health\Models\HealthCheckResultHistoryItem::class])->daily();
}

Laravel 11 and 12, in routes/console.php:

use Illuminate\Support\Facades\Schedule;
use Spatie\Health\Models\HealthCheckResultHistoryItem;

Schedule::command('health:check')->everyMinute();
Schedule::command('health:schedule-check-heartbeat')->everyMinute();
Schedule::command('model:prune', ['--model' => HealthCheckResultHistoryItem::class])->daily();

The heartbeat line is only needed when ScheduleCheck is registered.

4. Verify

php artisan health:check
curl -H "Authorization: Bearer $PORTAL_HEALTH_TOKEN" https://app.example.com/api/devops/health

Then add the application in the portal with the same token.

The endpoint

GET /api/devops/health with the header Authorization: Bearer <PORTAL_HEALTH_TOKEN>.

Response Meaning
404 no token configured on the application
403 the token does not match
200 the payload below
{
    "app": "Client Example",
    "checked_at": "2026-08-06T14:00:00Z",
    "checks": [
        {
            "name": "FailedJobs",
            "label_client": { "fr": "Aucune tâche de fond en échec", "en": "No failed background jobs" },
            "status": "failed",
            "message": { "fr": "2 tâches de fond en échec", "en": "2 failed background jobs" },
            "meta": { "failed_jobs_count": 2 }
        },
        {
            "name": "Database",
            "label_client": null,
            "status": "ok",
            "message": null,
            "meta": []
        }
    ],
    "meta": {
        "environment": "production",
        "sprintify_version": "1.0.0",
        "sentry_project_id": 123456,
        "github_repository": "witify/client-example",
        "backup": { "bucket": "client-backups", "prefix": "https---client-example-com" }
    }
}
  • checked_at is null and checks is empty until health:check has stored results.
  • label_client and a bilingual message are present for ClientFacingCheck subclasses. Other checks report the message laravel-health stored, in both languages.
  • sprintify_version carries APP_VERSION. The key name is the one the portal reads, whatever the application is.
  • sentry_project_id comes from SENTRY_LARAVEL_DSN or SENTRY_DSN.
  • github_repository is read from .git/config of the deployed checkout and cached for seven days.
  • backup reports the bucket of the backup disk and the backup name of spatie/laravel-backup. Both are null when absent.

The response is sent with Cache-Control: no-store, private.

Client-facing checks

A ClientFacingCheck describes a business process the client understands. Its label and message exist in French and English, resolved on the application so the portal never translates.

<?php

namespace App\Health;

use App\Models\Order;
use Spatie\Health\Checks\Result;
use Spatie\Health\ResultStores\StoredCheckResults\StoredCheckResult;
use Witify\Devops\Checks\ClientFacingCheck;
use Witify\Devops\ValueObjects\LocalizedTextData;

class ShopifyImportCheck extends ClientFacingCheck
{
    public function clientLabel(): LocalizedTextData
    {
        return new LocalizedTextData(
            'Aucune commande Shopify non importée depuis plus de 15 minutes',
            'No Shopify order left unimported for more than 15 minutes',
        );
    }

    public function clientMessage(StoredCheckResult $result): LocalizedTextData
    {
        return $this->message((int) ($result->meta['pending_orders'] ?? 0));
    }

    public function run(): Result
    {
        $count = Order::query()->pendingImportFor(15)->count();

        if ($count === 0) {
            return Result::make()->ok();
        }

        return Result::make()
            ->failed($this->message($count)->fr)
            ->meta(['pending_orders' => $count]);
    }

    private function message(int $count): LocalizedTextData
    {
        return new LocalizedTextData(
            "{$count} commandes en attente d'importation",
            "{$count} orders waiting to be imported",
        );
    }
}

clientMessage() rebuilds the message from the stored meta because the portal reads stored results, not the Result returned by run(). Use LocalizedTextData::fromTranslation() and fromTranslationChoice() when the texts live in language files.

Developer console

The package serves a developer console at /devops and the results page of laravel-health at /status. The console lists the monitoring tools installed in the application, with the URL each package serves according to its own configuration:

Tool Detected when URL
Laravel Health the /status page is enabled devops.console.health_path
Laravel Horizon laravel/horizon is installed config('horizon.path')
Laravel Pulse laravel/pulse is installed config('pulse.path')
Laravel Telescope laravel/telescope is installed config('telescope.path')
Logs opcodesio/log-viewer or arcanedev/log-viewer is installed the route path of the installed package

Below the tools, the page shows a summary of the latest health run, the application information (environment, APP_VERSION, PHP and Laravel versions, debug and maintenance modes, cached configuration and routes, GitHub repository, Sentry project) and, when sentry/sentry-laravel is installed, a button that sends a test exception.

Both pages are served behind the devops.console.middleware list, which defaults to ['web', 'auth']. Add the middleware that restricts them to your administrators before deploying, for example:

'console' => [
    'path' => 'devops',
    'health_path' => 'status',
    'middleware' => ['web', 'auth', 'role:admin'],
    'links' => [],
],

Set path or health_path to null to disable a page, for example when the application already serves its own /status route. Use links to override a detected URL or to add a tool the detection cannot see:

'links' => [
    'logs' => '/my-log-viewer',
    'forge' => ['label' => 'Forge', 'description' => 'Server panel', 'url' => 'https://forge.laravel.com/servers/1234'],
],

The page is a standalone Blade view with its own styles, so it works with any frontend stack. Publish it with --tag="devops-views" to adapt it.

Configuration

Publish the config only when a default needs to change:

php artisan vendor:publish --tag="devops-config"
Key Default Purpose
token env('PORTAL_HEALTH_TOKEN') bearer token expected by the endpoint
version env('APP_VERSION') version reported to the portal
route.prefix api/devops URL prefix of the endpoint
route.middleware ['api'] middleware groups applied before the token check
sentry_dsn env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')) DSN parsed for the Sentry project id
backup_disk backup filesystem disk whose bucket is reported
console.path devops path of the developer console, null to disable
console.health_path status path of the laravel-health results page, null to disable
console.middleware ['web', 'auth'] middleware of both pages
console.links [] URL overrides and extra tools of the console

Translations of the bundled checks and of the console can be published with --tag="devops-translations".

Changelog and upgrades

See the CHANGELOG of the monorepo. Every package shares the same version number.

Contributing

This repository is a read-only split of witify/packages. Open pull requests there, in packages/devops.