Deployment
Deployment
What a production environment runs in addition to (never instead of) what a development
environment already does — checked against Commander::optimize(),
ConfigCache, RouteCache and preload.php.
niang optimize
A single command chains the route cache and the configuration cache:
./bin/niang optimize
It also prints, without running them itself (they are server settings, not one-off CLI actions), the following reminders (in French, like the rest of the CLI):
Pensez aussi, en production : composer install --no-dev --optimize-autoloader
et activez opcache.validate_timestamps=0 dans votre php.ini.
preload.php existe à la racine du projet : un réglage serveur (opcache.preload dans
php.ini), pas quelque chose qu'une commande CLI ponctuelle comme celle-ci peut activer.
That is: in production, also run composer install --no-dev --optimize-autoloader and set
opcache.validate_timestamps=0 in your php.ini; preload.php exists
at the project root, but it is a server setting (opcache.preload), not something a
one-off CLI command can turn on.
Route caching
See Routing for the details — in short, it precompiles routes/web.php so it is not re-run on every request:
./bin/niang route:cache
./bin/niang route:clear # to go back
Only routes whose action is [Controller::class, 'method'] survive caching — a closure action is silently skipped (counted and reported on screen).
Configuration caching
Freezes every config/*.php (and the env() calls they contain) into a single file, storage/framework/config.php:
./bin/niang config:cache
./bin/niang config:clear
Once config:cache has run, changing .env or a config/*.php
file has no effect at all until the cache is cleared (Config::load()
reads the cache first, without even looking at the source files) — keep it for a frozen
deployment, never active in development.
OPcache and preloading
preload.php, at the project root, precompiles every framework file (vendor/niangpro/framework/packages/)
with opcache_compile_file() — built with glob(), never a hard-coded list.
It is a server setting, enabled in php.ini, not something a one-off CLI
command can trigger (the file only has an effect when loaded by OPcache itself as PHP-FPM starts):
opcache.preload=/absolute/path/to/the/project/preload.php
opcache.preload_user=www-data
opcache.validate_timestamps=0
opcache.validate_timestamps=0 stops comparing file timestamps on every request (a
performance gain), but also means a file changed on disk is not picked up until
OPcache is reloaded — keep it for an environment where deploying reloads PHP-FPM on every code update.
Production environment variables
| Variable | Production value |
|---|---|
APP_ENV | production |
APP_DEBUG | false — otherwise the Debug Toolbar is shown to visitors and errors expose their full details. |
APP_KEY | generated once (./bin/niang key:generate) and kept stable — it signs URLs (UrlSignature), API tokens (ApiToken) and cookies. |
DB_CONNECTION, DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD | mysql or pgsql rather than sqlite in the vast majority of deployments — see multi-DBMS grammar. |
DB_READ_HOST (etc.) | optional — a read replica, see splitting reads and writes. |
MAIL_MAILER | smtp, with MAIL_HOST, MAIL_FROM_ADDRESS and the credentials — neither log nor array actually sends email (niang doctor reports it). See SMTP. |
Debug and configuration in production
With APP_ENV=production, debug mode is always off (no error trace, no debug toolbar),
even if APP_DEBUG=true lingers in .env. No request is served without a valid
APP_KEY: 503 response, details in the logs. Outside production, debug is on unless
APP_DEBUG=false.
Several web servers
Behind a load balancer, each server keeps its files to itself: a visitor sent to another server loses their session (logged out, empty cart), the cache is not shared, and a “10 login attempts per minute” limit becomes 10 × the number of servers. Sessions, cache and rate limiting move to the database with two variables:
SESSION_DRIVER=database
CACHE_DRIVER=database
./bin/niang migrate # sessions, cache_entries, rate_limits tables
./bin/niang doctor # reports a missing table
file stays the default. CACHE_DRIVER also drives
rate limiting, whose increment stays atomic in the database (a
single conditional UPDATE). No per-session lock: if two simultaneous requests from the same
visitor both change the session, the last write wins.
With a Redis server, redis replaces database for both variables (and for QUEUE_DRIVER): see Redis.
Maintenance mode
./bin/niang down # every request gets a 503 page
./bin/niang down --retry=60 # + Retry-After: 60 header
./bin/niang down --secret # prints a secret URL that lets you browse (12 h cookie)
./bin/niang up # reopens the site
/up and /health stay reachable, so monitoring doesn't raise an alert during a
planned maintenance. An API gets a JSON 503. The 503 page (resources/views/errors/503.php)
doesn't use the site layout, which could depend on a database being migrated. Only the hash of the
secret is written to disk (storage/framework/down); niang doctor reports a
site left in maintenance. With several web servers, run down and up on each.
Measured performance
php -d opcache.enable_cli=1 benchmarks/run.php
php -d opcache.enable_cli=1 benchmarks/run.php --markdown # updates benchmarks/RESULTS.md
Each layer (routing, container, database, views, full request, startup) is compared with its plain PHP equivalent: p50, p95, p99, throughput and memory, with the measurement conditions. On an Apple M1 with OPcache, a full request costs about 0.015 ms of framework time, plus 0.08 ms of startup.
Docker
docker compose up -d # PHP-FPM 8.3 + Nginx + MySQL 8.4 at http://localhost:8080
docker compose exec app php bin/niang db:seed
docker compose down # -v also deletes the database
On first start, the app container installs vendor/, creates .env and an
APP_KEY never replaced afterwards, then migrates (NIANGPRO_MIGRATE=false to skip
it). The code is mounted from your folder. Variables from compose.yaml
(DB_HOST=db...) take precedence over .env, and PHP-FPM does receive them
(clear_env = no). Nginx only serves public/, only runs index.php and
refuses hidden files. Another port: NIANGPRO_HTTP_PORT=8000 docker compose up -d.
For production, docker/php/Dockerfile builds a standalone image (code copied,
composer install --no-dev): pass APP_KEY, APP_ENV=production,
APP_DEBUG=false and the DB_* as environment variables, and set
opcache.validate_timestamps=0 in docker/php/php.ini. Docker stays optional:
regular PHP hosting is enough.
Backups
The framework ships no backup command: each engine's own tools do it better. Three things need backing up, and a backup is only reliable once restoring it has been tried.
Database
# SQLite: consistent copy, even during writes (do not copy the file with cp)
sqlite3 storage/database.sqlite ".backup 'backups/db-$(date +%F).sqlite'"
# MySQL / MariaDB
mysqldump --single-transaction --routines --triggers --no-tablespaces \
--set-gtid-purged=OFF -u niangpro -p niangpro | gzip > backups/db-$(date +%F).sql.gz
# restore
gunzip -c backups/db-2026-09-27.sql.gz | mysql -u niangpro -p niangpro
# PostgreSQL
pg_dump -Fc -U niangpro niangpro > backups/db-$(date +%F).dump
pg_restore --clean --if-exists -U niangpro -d niangpro backups/db-2026-09-27.dump
--single-transaction gives a consistent snapshot without blocking writes (InnoDB tables).
--set-gtid-purged=OFF is required with MySQL when GTIDs are enabled (replication, managed
services): without it, restoring fails on a server that already has a history; drop the option with MariaDB,
which does not know it.
Files
tar -czf backups/storage-$(date +%F).tgz --exclude='storage/framework' --exclude='storage/logs' storage
storage/app/ holds uploaded files; storage/framework/ (cache, compiled views, file
sessions) is rebuilt. With SQLite, the database also lives in storage/: back it up with
.backup, not through the archive. On an S3 disk, enable bucket
versioning instead (or replication to a second bucket).
Configuration and key
The code is in git; the .env file is not. Keep it in a vault (secrets manager, the team's password
manager), never next to the database backups. APP_KEY must survive everything:
without it, two-factor secrets and all data encrypted with
Crypt are lost, and encrypted cookies are invalidated. Restoring a database with a different key is
not enough.
Automating
# crontab: every night at 2 am (backup.sh groups the commands above),
# then delete backups older than 14 days
0 2 * * * cd /var/www/app && ./scripts/backup.sh && find backups -mtime +14 -delete
Then copy the backups off the server (another machine, another provider's object storage): a backup left on the disk it protects disappears with it. Encrypt them if they leave your infrastructure, and regularly restore one on a test machine.
Checking before/after deploying
./bin/niang doctor # static environment: PHP, extensions, .env, permissions — before starting
./bin/niang health # actual runtime state: DB, cache, storage, queue — after starting
doctor exits with a non-zero code if a critical check fails (usable as
niang doctor || exit 1 in a deployment script) and warns (without failing) if
APP_ENV=production and APP_DEBUG=true are both active. See
HealthCheck for the details of what each one
actually checks.