In una delle mie falcate da Fractional CTO per una delle realtà con cui collaboro, mi sono trovato a dover realizzare un PoC per la creazione di un piccolo pannello di amministrazione. Dopo un po’ di scouting per capire quale potesse essere la soluzione migliore per realizzarne uno in poco tempo, sono atterrato su questo progetto veramente interessante e che non conoscevo: Filament.

Filament è un framework di componenti UI full-stack “pre cucinate” basato sullo stack TALL ( Tailwind, Alpine.js, Laravel, Liveware ) e che ti permette ti costruire un pannello di amministrazione tramite il suo Panel Builder in maniera molto rapida.

Le motivazioni che mi hanno spinto a sceglierlo sono principalmente:

  • E’ un oggetto fatto su PHP/Laravel ( un mio vecchio amore e quindi conoscenza ) supportato e utilizzato da molti sviluppatori e quindi con una community abbastanza estesa.
  • Ha un bel sistema di plugins che permette di usare anche cose fatte da altri con veramente poco sforzo integrativo.
  • Ha tutto quello che serve per un pannello di amministrazione out of the box.
  • E’ open source e gratuito (https://github.com/filamentphp/filament).

Il Problema

Quello di cui voglio parlare in questo post però non è di Filament di per se ( ma vi consiglio vivamente di dargli uno sguardo ), ma del fatto che nel suo utilizzo mi sono imbattuto in un po’ di lavoro per fare la configurazione del giusto ambiente per far funzionare il tutto. Questa è una cosa abbastanza tipica del mondo PHP, almeno per come me lo ricordavo quando l’ho lasciato tempo fa, e siccome dopo averlo usato per un PoC mi è capitato di doverlo ( forse più volerlo ) usare anche per un altro PoC, allora ho deciso di scrivermi un piccolo tool che permettesse il bootstrap di un progetto basato su Filament, con giusto una manciata di comandi, ed essere operativo per lo sviluppo molto rapidamente. Per darvi un’ idea di cosa ho voluto risolvere, ecco quello di cui Filament necessita per funzionare:

Dipendenze

Per poter funzionare Filament ha bisogno di:

  • PHP 8.1+
  • Laravel v10.0+
  • Livewire v3.0+

Sistema

Ovviamente il mio scopo è quello di poterci sviluppare sopra in locale e molto rapidamente. Per fare ciò ho bisogno quindi anche di avere:

  • Un web server
  • Un database
  • Qualcosa per far girare e servire l’applicazione PHP

La Soluzione

Alla base di tutta la mia soluzione c’è Docker ed in generale un approccio ai containers che mi permette di essere operativo con tutto quello che mi serve in maniera rapida e soprattutto “sandboxed”, cioè senza inquinare troppo la macchina di sviluppo.

Dockerfile

Il primo step è stato quello di definire un Dockerfile che rispondesse a tutte le esigenze del progetto, quindi in modo tale che l’immagine risultante dalla build avesse:

  • PHP 8.1+
  • Laravel v10.0+
  • Livewire v3.0+
  • PHP-FPM per eseguire il PHP
  • Composer per gestire le dipendenze

Quello che ne è venuto fuori è il seguente Dockerfile:

FROM php:8.3-fpm-bullseye AS php-base

RUN apt-get update \
 && apt-get install -y --no-install-recommends \
    git \
    zip \
    unzip \
    libicu-dev \
 && mkdir -p /app \
 && mkdir -p /.composer && chmod -R 777 /.composer/
WORKDIR /app
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
 && php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \
 && php composer-setup.php \
 && php -r "unlink('composer-setup.php');" \
 && mv composer.phar /usr/local/bin/composer \
 && docker-php-ext-configure intl && docker-php-ext-install intl \
 && cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini \
 && curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s \
    pgsql pdo_pgsql \
 && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
 && apt-get install -y nodejs

EXPOSE 9000

FROM php-base AS php-prod
COPY --chown=www-data:www-data ./src /app
WORKDIR /app
RUN set -e; \
    cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini; \
    composer install --optimize-autoloader --no-dev; \
    php artisan config:cache; \
    php artisan route:cache; \
    php artisan view:cache; \
    npm install; \
    npm run build; \
    chown -R www-data:www-data /app;
USER www-data

Come si può vedere sono partito dalla base php-fpm ufficiale per poi installarci sopra Composer e anche NodeJS per poter gestire tutto quello che riguarda lo sviluppo. Il Dockerfile è un così detto multi-stage file che permette di avere diverse versioni della stessa immagine in fase di build a seconda delle esigenze. Chiaramente qui l’esigenza era quella di avere un’immagine adatta all sviluppo locale ed una adatta al deployment in un ambiente di pre-produzione.

Il target php-base suppone che in esecuzione venga montanto un volume su /app con il sorgente php, cosa che invece nella versione php-prod viene copiato nell’immagine ed usato per generare la versione di produzione della app, con tutte le ottimizazioni del caso.

Il Tool

Dato che lo scopo era quello di avere un tool per la gestione del progetto, il prossimo passo è stato quello di crearmi una serie di scripts bash necessari allo scopo. Il risultato è stato quello di avere uno script main che funge da cliper questa serie di scripts.

La struttura quindi, alla fine delle varie iterazioni, è diventata la seguente:

├── commands
│   ├── VERSION
│   ├── art
│   ├── build-docker-image
│   ├── common
│   ├── create-admin-user
│   ├── exec
│   ├── generate
│   ├── install-docker
│   ├── install-service
│   ├── migrate-db
│   └── run
└── manage 

manage è appunto uno script bash che da accesso ai vari comandi che a loro volta sono scripts bash che eseguono i vari tasks ( che a loro volta … ah no, basta ).

L’help command alla versione del tool nel momento in cui scrivo è:

$ ./manage help

Manage the laravel/filament project
Version: 0.9.0
Usage: manage [command]
Commands:
  install-docker                    Install the docker engine
  build-docker-image [dev|prod]     Build/rebuild the docker image for the environment passed as argument
  create-admin-user                 Create a new admin user that can access the dashboard
  migrate-db                        Run the migration tasks for the DB
  run                               Run the docker containers defined in the compose.yml file
  generate                          Generate the laravel/filament project by installing all required components (it is destructive)
  exec <command>                    Exec the command using the php-composer container
  art <command>                     Execute 'php artisan' command using the php-composer container
  install-service [name]            Install the systemd service with the specified name if passed as argument
  *                                 Help

La lista dei comandi che vedete sono quelli che, al momento, sono serviti allo scopo dei PoC da realizzare. Inizialmente c’erano solo comandi utili allo sviluppo in locale ( come generate, run, build-docker-image, create-admin-user, migrate-db, art, etc... ). Poi avendo avuto anche l’esigenza di deployare la soluzione in modo che il PoC fosse usabile dai vari stakeholders in un ambiente che fosse una “pre-produzione”, ho integrato anche altri comandi utili a tale scopo ( come install-docker, install-service ).

Docker compose e infrastruttura

Ovviamente i comandi da soli non hanno capacità di fare nulla di speciale senza una adeguata infrastruttura sottostante.

E’ stato necessario definire un’architettura di runtime per eseguire i vari servizi richiesti come il database ed il web server.

Per fare questo mi sono servito di Docker Compose e delle sue features per orchestare i vari containers. Una delle prime features che ho usato è stata l’abilità di docker compose di poter mergiare e sovrascrivere una serie di compose files insieme. La struttura finale di tutto quello che riguarda docker compose è:

├── Dockerfile
├── compose.dev.yml
├── compose.prod.yml
├── compose.yml

Il file compose.yml è il file di definizione base dei servizi ed ha il seguente contenuto:

version: "3"
services:
  web:
    image: nginx:1.25
    ports:
      - "${NGINX_HOST}:${NGINX_PORT}:${NGINX_INT_PORT}"
    volumes:
      - ./conf/${NGINX_CONFIG}:/etc/nginx/conf.d/${NGINX_INT_CONFIG}
      - ./src/public:/app/public
      - ./src/storage/app/public:/app/storage/app/public
  php:
    build:
      context: .
      target: php-base
    image: php-composer:8.3
    ports:
      - "${FPM_HOST}:${FPM_PORT}:${FPM_INT_PORT}"
    environment:
      - DB_CONNECTION=${DB_CONNECTION}
      - DB_HOST=postgres
      - DB_PORT=${DB_PORT}
      - DB_DATABASE=${DB_DATABASE}
      - DB_USERNAME=${DB_USERNAME}
      - DB_PASSWORD=${DB_PASSWORD}
  postgres:
    image: postgres:16
    ports:
      - "127.0.0.1:${DB_PORT}:${DB_INT_PORT}"
    environment:
      - POSTGRES_DB=${DB_DATABASE}
      - POSTGRES_USER=${DB_USERNAME}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - PGDATA=${DB_INT_DATA_PATH}/pgdata
    volumes:
      - ${DB_EXT_DATA_PATH}:${DB_INT_DATA_PATH}

Come si può osservare i servizi sono:

  • Un nginx che funge da web server e reverse proxy verso il php-fpm. Da notare che nginx si prende in carico anche delle cartelle public del progetto per poterle servire direttamente, senza dover passare per il php-fpm.
  • Un servizio php che crea/usa l’immagine php-base definita nel docker file
  • Un servizio postgres che fornisce appunto il DBMS PostgreSQL

Nel file si fa abbondantemente uso di variabili di ambiente che permettono di definire il comportamento dei servizi senza doverne modificare la definizione. Le variabili sono definite nel file .env che ha questo contenuto:

SERVICE_NAME=filament-app
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=db-name
DB_USERNAME=pg-user
DB_PASSWORD=needtobechanged

DB_INT_PORT=5432
DB_EXT_DATA_PATH=.local/data
DB_INT_DATA_PATH=/var/lib/postgresql/data

NGINX_HOST=0.0.0.0
NGINX_PORT=80
NGINX_INT_PORT=80
NGINX_CONFIG=nginx.dev.conf
NGINX_INT_CONFIG=default.conf

FPM_HOST=127.0.0.1
FPM_PORT=9000
FPM_INT_PORT=9000

LOGGING_DRIVER=local
LOGGING_MODE=blocking

Qui la reference di come docker compose gestisce il passaggio di variabili di ambiente ai containers https://docs.docker.com/compose/environment-variables/set-environment-variables/.

Lo scopo qui è abilitare la configurazione necessaria al progetto senza doversi preoccupare di modificare il file di definizione dei servizi.

Dando un rapido sguardo al file compose.dev.yml :

version: "3"
services:
  php:
    build:
      context: .
      target: php-base
    image: php-composer:8.3
    volumes:
      - ./src:/app

possiamo vedere che aggiunge al servizio php il mount del volume dei sorgenti.

Ora se diamo uno sguardo al comando commands/run :

#!/usr/bin/env bash
. "$BAGCLI_WORKDIR/commands/common"

docker compose -f compose.yml -f compose.dev.yml down
cp $LF_SRC_PATH/.env $LF_SRC_PATH/.env.local
echo "" >> $LF_SRC_PATH/.env
echo "# ADDED BY DOCKER RUNTIME" >> $LF_SRC_PATH/.env
cat .env >> $LF_SRC_PATH/.env
docker compose -f compose.yml -f compose.dev.yml up
mv $LF_SRC_PATH/.env.local $LF_SRC_PATH/.env
docker compose -f compose.yml -f compose.dev.yml down

a parte una serie di operazioni di configurazione, possiamo notare il comando

$ docker compose -f compose.yml -f compose.dev.yml up

che è il modo in cui docker compose permette di usare più file di definizione dei servizi mergiandoli tra loro.

Se volete avere una preview di come effettivamente sarà il compose risultante potete eseguire il comando

$ docker compose -f compose.yml -f compose.dev.yml config

che se tutto è ben configurato vi mostrerà un output simile a questo:

name: filament-bootstrapper
services:
  php:
    build:
      context: /filament-bootstrapper
      dockerfile: Dockerfile
      target: php-base
    environment:
      DB_CONNECTION: pgsql
      DB_DATABASE: db-name
      DB_HOST: postgres
      DB_PASSWORD: needtobechanged
      DB_PORT: "5432"
      DB_USERNAME: pg-user
    image: php-composer:8.3
    networks:
      default: null
    ports:
      - mode: ingress
        host_ip: 127.0.0.1
        target: 9000
        published: "9000"
        protocol: tcp
    volumes:
      - type: bind
        source: /filament-bootstrapper/src
        target: /app
        bind:
          create_host_path: true
  postgres:
    environment:
      PGDATA: /var/lib/postgresql/data/pgdata
      POSTGRES_DB: db-name
      POSTGRES_PASSWORD: needtobechanged
      POSTGRES_USER: pg-user
    image: postgres:16
    networks:
      default: null
    ports:
      - mode: ingress
        host_ip: 127.0.0.1
        target: 5432
        published: "5432"
        protocol: tcp
    volumes:
      - type: bind
        source: /filament-bootstrapper/.local/data
        target: /var/lib/postgresql/data
        bind:
          create_host_path: true
  web:
    image: nginx:1.25
    networks:
      default: null
    ports:
      - mode: ingress
        host_ip: 0.0.0.0
        target: 80
        published: "80"
        protocol: tcp
    volumes:
      - type: bind
        source: /filament-bootstrapper/conf/nginx.dev.conf
        target: /etc/nginx/conf.d/default.conf
        bind:
          create_host_path: true
      - type: bind
        source: /filament-bootstrapper/src/public
        target: /app/public
        bind:
          create_host_path: true
      - type: bind
        source: /filament-bootstrapper/src/storage/app/public
        target: /app/storage/app/public
        bind:
          create_host_path: true
networks:
  default:
    name: filament-bootstrapper_default

Per completezza il file compose.prod.yml ha il seguente contenuto:

version: "3"
services:
  web:
    logging:
      driver: ${LOGGING_DRIVER}
      options:
        mode: ${LOGGING_MODE}
        tag: ${SERVICE_NAME}
        labels: com.docker.compose.service
  php:
    build:
      context: .
      target: php-prod
    image: ${SERVICE_NAME}:latest
    user: ${DOCKER_USER}
    volumes:
      - ./src/.env:/app/.env:ro
      - ./conf/custom-php.ini:/usr/local/etc/php/conf.d/custom-php.ini:ro
    logging:
      driver: ${LOGGING_DRIVER}
      options:
        mode: ${LOGGING_MODE}
        tag: ${SERVICE_NAME}
        labels: com.docker.compose.service
  postgres:
    logging:
      driver: ${LOGGING_DRIVER}
      options:
        mode: ${LOGGING_MODE}
        tag: ${SERVICE_NAME}
        labels: com.docker.compose.service

che in sostanza definisce il target php-prod per l’immagine del servizio php, ne cambia il nome della immagine risultante dalla build e definisce un sistema di log, configurabile tramite variabili di ambiente, per tutti i servizi. Questa ultima cosa è molto utile per il modello di esecuzione dell’intero sistema che si basa sul systemd della macchina host su cui girerà la pre-produzione.

Configurazioni di deployment

Un’altro pezzo importante di tutta l’infrastruttura è definito all’interno della cartella conf del progetto, il cui contenuto è:

conf
├── custom-php.ini
├── filament-app.service
├── nginx.dev.conf
└── nginx.prod.conf 

Questa cartella contiene alcuni files utili alla configurazione dei vari servizi, come quelli per le due versioni di nginx, per lo sviluppo e per la produzione, e un file di configurazione per php che andrà a mergiarsi con il php.ini dell’immagine docker generata ( guardando il file compose.prod.yml potete vedere che viene montato come volume ).

Un file da considerare e relativo al modo in cui il tutto viene eseguito in produzione è filament-app.service, che è uno unit file per systemd con il seguente contenuto:

[Unit]
Description=filamnent app service with docker compose
PartOf=docker.service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/path/to/project
ExecStart=/usr/bin/docker compose -f compose.yml -f compose.prod.yml up -d --remove-orphans
ExecStart=/usr/bin/docker compose -f compose.yml -f compose.prod.yml exec php php artisan config:cache
ExecStart=/usr/bin/docker compose -f compose.yml -f compose.prod.yml exec php php artisan migrate -n
ExecStop=/usr/bin/docker compose -f compose.yml -f compose.prod.yml down

StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=filament-app

[Install]
WantedBy=multi-user.target

Come si può vedere questo definisce appunto un servizio per systemd che lancia la versione prod del docker compose ed effettua diversi tasks dopo l’esecuzione, relativi ad esempio alla rigenerazione della cache di laravel e all’applicazione delle eventuali migrazioni necessarie.

Conclusioni

Alla fine di tutto ho costruito un tool che mi permette di:

Generare un progetto filament da zero con tutte le dipendenze

$ ./manage generate

Avviare una versione di sviluppo del progetto con tutto il necessario

$ ./manage run

Avere uno strumento integrato per la gestione di vari task sul progetto

$ ./manage build-docker-image dev # (ri)genera l'immagine docker per lo sviluppo
$ ./manage create-admin-user # crea un nuovo utente admin per il pannello di filament
$ ./manage migrate-db # applica le migrazioni del database 
$ ./manage art <command> # shortcut per eseguire php artisan all'interno del container php
$ ./manage exec <command> # shortcut per eseguire un comando all'interno del container php

Avere uno strumento che mi facilita il deployment e l’aggiornamento della soluzione

$ ./manage install-docker # Installa il docker engine sull'host
$ ./manage build-docker-image prod # (ri)genera l'immagine docker per la produzione
$ ./manage install-service <service-name> # Installa il servizio systemd sull'host usando il nome passato come parametro
$ ./manage update-deployment # Rigenera l'immagine docker di produzione e restarta i servizi

L’intero codice sorgente è sul mio GitHub qui https://github.com/Kyserbyte/filament-bootstrapper. Per poterlo usare basta fare il clone o il download del progetto e poi seguire le istruzioni contenute nel file README.md.

Se poi il progetto vi piace, premete sulla stella per aggiugerlo ai vostri preferiti e restare aggiornati sui vari sviluppi.

Attenzione

La versione attuale, soprattutto la configurazione per il deployment, non è pensata ne lavorata per essere usata in un vero contesto di produzione, soprattutto perchè tralascia molti temi relativi alla sicurezza e alle performance.