I've recently added a new feature to Vigilant, health checks. Vigilant is an open source monitoring application that is designed to monitor all aspects of a website. With health checks we can verify if critical processes are running such as a Laravel scheduler or Redis connection. Additionaly alongside these boolean type of checks I've added metrics which allow us to expose basic system statistics such as cpu, memory and disk space. We can even add the size of the log files in the app to get notified when these values peak!
I created a healthcheck-base package to define a common interface. Framework-specific packages build on top of this and integrate seamlessly, while remaining usable without Vigilant.
Then separate packages which add Laravel or Statamic specific checks at:
These are designed to work without Vigilant but integrate seamlessly.
Package development
The usual method for package development would be to add the directory as a composer repository in an existing project and let composer make a symlink. This is documented here and a good way for development.
I recently saw that one of Statamic's core developer Duncan McClean created an opinionated script called tether do manually do these symlinks. I've personally done the same with a simpler script that I wrote to avoid having to manually edit the composer json every time I need to work on a package, here is the script:
#!/bin/bash
# Link a local package in a Laravel project
# Usage: lpackage.sh <package-name>
projects_path=$HOME/code
package=$1
composer=$(which composer)
vendorTarget=$(find vendor -maxdepth 2 -type d -name $package)
if [ -z "$vendorTarget" ]; then
echo "Package not found in vendor directory"
exit 1
fi
packagePath=$(find $projects_path -maxdepth 1 -type d -name $package)
if [ -z "$packagePath" ]; then
echo "Package not found in projects directory"
exit 1
fi
echo "Linking $packagePath to $vendorTarget"
rm -rf $vendorTarget
ln -s $packagePath $vendorTarget
echo "Reinstalling package dependencies"
rm -rf $packagePath/composer.lock $packagePath/vendor
$composer --working-dir=$packagePath install &> /dev/null
echo "Done!"In combination with an alias alias lpackage="sh ~/dotfiles/scripts/lpackage.sh" this can be ran from a project directory to link any package with the prerequisite that the target package is already installed in the project. This script is intentionally minimal and tailored to my local setup
The downside of this is that you have to have an pre existing project which isn't a clean project so you always run the risk of something in the project affecting your package. A small example is calling Model::unguard() in the project's service provider which is easy to miss in a package but will causes issues when the package is included in another project.
Don't get me wrong, these methods are perfectly fine but sometimes it's nice to have a clean install for a package so you are absolutely sure it works.
Let's build a Docker environment
This environment needs to setup a clean application and install our package. Preferably symlink to our package so that our changes are effective immediately. I'm going to use Docker compose to setup additional services like MySQL and Redis. I also want to setup things like an admin account in the case of Statamic so we don't have to create one each time the stack starts. Let's start with the Dockerfile, to reduce overhead we'll be using artisan serve to handle requests and schedule:work instead of cron. To run these we'll use supervisor.
So what do we have to do?
Pick a base image for our container
Add the required dependencies
Install composer and create a new Laravel / Statamic project
Install our package
Run the required services
Let's start with the base image, I've chosen to use php:8.5-cli which is a Debian based image that includes PHP 8.5. So the start of our Dockerfile looks like this:
FROM php:8.5-cli
WORKDIR /srvThen we need to install our dependencies such as supervisor and PHP extensions:
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip libzip-dev supervisor \
&& docker-php-ext-install zip pcntl \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& rm -rf /var/lib/apt/lists/*We can get composer and create the project:
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN composer create-project --no-interaction --no-progress laravel/laravel app
// Or for Statamic:
RUN composer create-project --no-interaction --no-progress statamic/statamic appNow that we've got a project setup we can copy our package and install it along with some other setup stuff:
COPY . /srv/package
WORKDIR /srv/app
RUN composer config minimum-stability dev \
&& composer config prefer-stable true \
&& composer config repositories.statamic-healthchecks path /srv/package \
&& composer require --no-interaction --no-progress govigilant/statamic-healthchecks:dev-main laravel/horizon \
&& php artisan key:generate \
&& php artisan horizon:installAnd finally we can copy the supervisor config and start it:
RUN rm -rf /var/www/html \
&& mv /srv/app /var/www/html \
&& mkdir -p /var/log/supervisor
WORKDIR /var/www/html
COPY devenv/supervisord.conf /etc/supervisor/conf.d/healthchecks.conf
EXPOSE 8000
CMD ["supervisord", "-n"]Here is the final Dockerfile:
FROM php:8.5-cli
WORKDIR /srv
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip libzip-dev supervisor \
&& docker-php-ext-install zip pcntl \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN composer create-project --no-interaction --no-progress laravel/laravel app
COPY . /srv/package
WORKDIR /srv/app
RUN composer config minimum-stability dev \
&& composer config prefer-stable true \
&& composer config repositories.statamic-healthchecks path /srv/package \
&& composer require --no-interaction --no-progress govigilant/statamic-healthchecks:dev-main laravel/horizon \
&& php artisan key:generate \
&& php artisan horizon:install
RUN rm -rf /var/www/html \
&& mv /srv/app /var/www/html \
&& mkdir -p /var/log/supervisor
WORKDIR /var/www/html
COPY devenv/supervisord.conf /etc/supervisor/conf.d/healthchecks.conf
EXPOSE 8000
CMD ["supervisord", "-n"]
Statamic user
For statamic we need a user to login to the control panel, for this I've created a users directory in the package repository. Then in the Dockerfile I copy that into the Statamic folder:
COPY devenv/users/ /srv/app/users/
The supervisor config looks like this, to avoid stdout being spammed I've put the logs in separate files:
[supervisord]
nodaemon=true
[program:php]
command=php artisan serve --host=0.0.0.0 --port=8000
directory=/var/www/html
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/php.log
stderr_logfile=/var/log/supervisor/php.err.log
[program:horizon]
command=php artisan horizon
directory=/var/www/html
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/horizon.log
stderr_logfile=/var/log/supervisor/horizon.err.log
[program:scheduler]
command=php artisan schedule:work
directory=/var/www/html
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/scheduler.log
stderr_logfile=/var/log/supervisor/scheduler.err.logLet's break this down quickly, it starts the following services:
php artisan serve --host=0.0.0.0 --port=8000 - For handling HTTP requests
php artisan horizon - For verifying if the Horizon checks in the package work
php artisan schedule:work - For verifying if the scheduler check in the package works
If you don't know, supervisor is a program which lets you run other programs. When something crashes, supervisor will start it again. It's a useful tool to keep services running with relatively simple configuration.
Now that we have a Dockerfile we can create a compose file. This is what we use to compose the stack together, it defines the services we need including our clean application. With this file we can start the entire stack with a single command and because it's all containerized we can be sure it's the same on each host system.
Notice that instead of an image for the app container we pass our Dockerfile. We map the package and environment file to the app container. This env file contains some basic configuration for the database, redis and package configuration.
services:
app:
build:
context: ..
dockerfile: devenv/Dockerfile
ports:
- "8000:8000"
volumes:
- ../:/srv/package
- ./app.env:/var/www/html/.env
depends_on:
- mysql
- redis
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: laravel
MYSQL_USER: laravel
MYSQL_PASSWORD: secret
volumes:
- mysql-data:/var/lib/mysql
redis:
image: redis:7-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
volumes:
- ./redis.conf:/usr/local/etc/redis/redis.conf
volumes:
mysql-data:
APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:f4rD3H8KQ1nNHNbR0J7s+F1oDttxyPjdvVSdGInxje8=
APP_DEBUG=true
APP_URL=http://localhost:8000
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=secret
BROADCAST_DRIVER=log
CACHE_STORE=redis
CACHE_DRIVER=redis
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
SESSION_DRIVER=file
SESSION_LIFETIME=120
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
VIGILANT_HEALTHCHECK_TOKEN=testing
Finally we can start the environment with `docker compose up` and start making requests to the health package.
curl --request POST --url http://127.0.0.1:8000/api/vigilant/health --header 'authorization: Bearer testing'
And receive a beautiful response:
{
"checks": [
{
"type": "database_connection",
"key": null,
"status": "healthy",
"message": "Database connection is healthy."
},
{
"type": "queue",
"status": "healthy",
"message": "Queue is operational."
},
{
"type": "cache_store",
"key": null,
"status": "healthy",
"message": "Cache store is healthy."
},
{
"type": "redis_connection",
"key": null,
"status": "healthy",
"message": "Redis connection is healthy."
},
{
"type": "redis_memory",
"status": "healthy",
"message": "Redis memory usage is healthy: 1.73MB / 10MB (17.27%)"
},
{
"type": "storage",
"status": "healthy",
"message": "Storage disk is healthy."
},
{
"type": "debug_mode",
"status": "healthy",
"message": "Debug mode is enabled (environment: local)."
},
{
"type": "horizon",
"status": "healthy",
"message": "Horizon is running."
},
{
"type": "scheduler",
"key": null,
"status": "healthy",
"message": "Scheduler is running."
},
{
"type": "disk_space",
"status": "healthy",
"message": "Disk space is healthy: 401520.39MB free of 453873.7MB (11.53% used)"
}
],
"metrics": [
{
"type": "memory_usage",
"value": 16.45,
"unit": "%"
},
{
"type": "cpu_load",
"value": 6.76,
"unit": "%"
},
{
"type": "disk_usage",
"value": 11.53,
"unit": "%"
},
{
"type": "database_size",
"value": 0,
"unit": "MB"
},
{
"type": "log_file_size",
"value": 0,
"unit": "MB"
}
]
}
And that's it, we now have a dedicated clean environment to develop and test our package.
Further possibilities
Right now I'm using this for development and I run these on a test server (not accessible from the internet) for testing the server side of healthchecks. I have a small script which updates each one by pulling the git changes and rebuilding the containers.
It might also be a nice addition to spin up this Docker image in a pipeline to run an integration test for example.
Upgrading to Statamic 6
When Statamic 6 was released, I needed to upgrade the statamic-healthchecks addon to be compatible with it. This meant updating the statamic/cms constraint in composer.json from ^5.0 to ^6.0, updating the CI matrix, and migrating the Vue 2 frontend assets to Vue 3. Once the code changes were done, I needed to verify it all works end-to-end and this is exactly where the Docker environment proved its value.
Without a dedicated environment I would have had to manually update an existing Statamic project, install the package there, and test it. With the Docker setup, I just rebuilt the image. The build process runs composer create-project statamic/statamic from scratch every time, so a rebuild gave me a clean Statamic 6 install with the updated package installed.
The container started cleanly and the health check endpoint returned the expected response including the Statamic-specific statamic_stache check. The upgrade was verified against a clean Statamic 6 install without touching any existing project.
Should all packages ship an environment like this?
Absolutely not. It depends on the package and the goals. The goal here is to have a clean install to test the package and to spin up a testing environment. The 'traditional' way of symlinking a package in a project is in most cases still the preferable way in most cases. So for most packages symlinking is enough. But when, a clean, reproducible setup is needed, Docker is hard to beat.