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:

bash
./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):

text
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:

bash
./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:

bash
./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):

php.ini (production)
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

VariableProduction value
APP_ENVproduction
APP_DEBUGfalse — otherwise the Debug Toolbar is shown to visitors and errors expose their full details.
APP_KEYgenerated 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_PASSWORDmysql 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_MAILERsmtp, 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:

.env
SESSION_DRIVER=database
CACHE_DRIVER=database
bash
./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

bash
./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

bash
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

bash
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

bash
# 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

bash
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

bash
# 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

bash
./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.

⏱ 9.02 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200