DEV Community

Vincent Boon
Vincent Boon

Posted on

Docker environment for Laravel/Statamic package development

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!"
Enter fullscreen mode Exit fullscreen mode

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?

  1. Pick a base image for our container
  2. Add the required dependencies
  3. Install composer and create a new Laravel / Statamic project
  4. Install our package
  5. 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 /srv
Enter fullscreen mode Exit fullscreen mode

Then 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/*
Enter fullscreen mode Exit fullscreen mode

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 app
Enter fullscreen mode Exit fullscreen mode

Now 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:install
Enter fullscreen mode Exit fullscreen mode

And 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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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.log
Enter fullscreen mode Exit fullscreen mode

Let’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:
Enter fullscreen mode Exit fullscreen mode

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.

Originally published at https://govigilant.io on April 6, 2026.

Top comments (0)