From an empty server to a running site: requirements, getting the framework, a project skeleton, the web server, and the flags that harden a new site.
.dev/install (web or php .dev/install/install.php) creates the database,
a MySQL / MariaDB config/db_setup.php, the front controllers and the first admin, and locks the project (config/installed.lock).
The manual steps below do the same by hand; they also cover PostgreSQL and store the admin password as bcrypt (the installer still writes md5,
which $PROJECT_CONF['password']['REHASH_ON_LOGIN'] upgrades on the first login).
8.2 or newer (CI tests 8.4 and 8.5) and Composer 2.
mbstring intl gd mysqli pdo_mysql sodium zip pcntl; redis igbinary memcached for those caches; pgsql pdo_pgsql for PostgreSQL.
MariaDB 10.11, MySQL 8.0 or PostgreSQL 16. Optional: Redis 7, Memcached 1.6.
nginx (or Caddy) with PHP-FPM 8.x; php -S for development.
Require the package from Packagist:
composer require yfix/yfvendor/autoload.php defines YF_PATH (the package directory). The package leaves .dev/ out, so this docs and demo site needs a git checkout.
Keep the framework as a git submodule (or a plain checkout) next to the project code:
git submodule add https://github.com/yfix/yf.git yf
git submodule update --initThe front controllers then define YF_PATH themselves: define('YF_PATH', dirname(__DIR__) . '/yf/');
myproject/
config/
db_setup.php DB constants (read by yf)
project_conf.php $CONF, $PROJECT_CONF
www/ document root
index.php user front controller
admin/index.php admin front controller
uploads/ writable
modules/ admin_modules/ classes/
models/ plugins/ your code, overrides
templates/user/ templates/admin/
storage/ writable (logs in storage/logs/)
vendor/ composer (or yf/)A class or template in the project with the framework's name overrides it; site__ / adm__ classes extend it.
<?php
define('DB_TYPE', 'mysqli'); // or pgsql
define('DB_HOST', 'localhost');
define('DB_NAME', 'myproject');
define('DB_USER', 'myproject');
define('DB_PSWD', 'secret');
define('DB_PREFIX', 't_');
define('DB_CHARSET', 'utf8mb4');<?php
require dirname(__DIR__) . '/vendor/autoload.php'; // defines YF_PATH
// define('YF_PATH', dirname(__DIR__) . '/yf/'); // submodule instead
define('PROJECT_PATH', __DIR__ . '/');
define('DEBUG_MODE', false);
require dirname(__DIR__) . '/config/project_conf.php';
$PROJECT_CONF['tpl']['REWRITE_MODE'] = true; // /object/action/id URLs
require YF_PATH . 'classes/yf_main.class.php';
new yf_main('user', $no_db_connect = false, $auto_init_all = true);<?php
require dirname(__DIR__, 2) . '/vendor/autoload.php';
define('PROJECT_PATH', dirname(__DIR__) . '/');
define('DEBUG_MODE', false);
require dirname(__DIR__, 2) . '/config/project_conf.php';
require YF_PATH . 'classes/yf_main.class.php';
new yf_main('admin', $no_db_connect = false, $auto_init_all = true);Create an empty database. yf creates its system tables on first use ($PROJECT_CONF['db']['FIX_DATA_SAFE'], on by default).
Open /admin/ once so that sys_admin exists, then add the first administrator with a bcrypt hash:
php -r 'echo password_hash("change-me", PASSWORD_BCRYPT), PHP_EOL;'
mysql myproject -e "INSERT INTO t_sys_admin (login, password, email, \`group\`, active, add_date)
VALUES ('admin', '<the hash>', '[email protected]', 1, '1', UNIX_TIMESTAMP())"Every request that is not a file goes to index.php (or admin/index.php), which reads the path itself.
nginx is the reference; the Caddy, Traefik and Envoy samples apply the same rules. Each one refuses dot files (except /.well-known/),
config/, storage/, logs/ and PHP under uploads/ and /.well-known/, in any case; remove a name your site uses as a route.
The files, each with a docker-compose.yml (PHP-FPM on a shared unix socket), are in
.dev/useful: nginx_conf, caddy_conf, traefik_conf, envoy_conf.
Apache is not supported: yf ships no .htaccess and the installer writes none (see the upgrade notes).
/etc/nginx/sites-available/myproject.conf; set the PHP-FPM socket of your PHP version. Regex locations match in order, so the denies come before the PHP handler:
server {
listen 80;
server_name example.com;
root /var/www/myproject/www;
index index.php;
# dot files and dirs (.git, .env, .htaccess, .dev ...), except /.well-known/ for ACME
location ~ /\.(?!well-known/) {
deny all;
}
# never served, also when an old layout keeps them under the web root; remove a name your site uses as a route
location ~* ^/(config|storage|logs)/ {
deny all;
}
# uploads are data and /.well-known/ is for ACME: no PHP runs from there (nginx merges // and decodes %2F first).
# Not end-anchored on purpose: a.php.jpg and x.phtml-docs/ match too, like Apache's multi-extension handling
location ~* ^/(uploads|\.well-known)/.*\.(php|phtml|phar|pht) {
deny all;
}
location / {
try_files $uri /index.php?$query_string;
}
location /admin/ {
try_files $uri /admin/index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}/etc/caddy/Caddyfile. Caddy gets and renews the Let's Encrypt certificate of the site address itself (ports 80 and 443 open); SITE_ADDRESS=:80 serves plain HTTP behind another proxy:
{$SITE_ADDRESS:example.com} {
root * /var/www/myproject/www
encode zstd gzip
# dot files and dirs (.git, .env, .htaccess, .dev ...), except /.well-known/ for ACME
@dotfiles {
path_regexp /\.
not path /.well-known/*
}
error @dotfiles 403
# never served, also when an old layout keeps them under the web root (remove a name your site uses as a route);
# uploads are data and /.well-known/ is for ACME: no PHP runs from there. Any case, and // (Caddy keeps it in the path).
# Not end-anchored on purpose: a.php.jpg and x.phtml-docs/ match too, like Apache's multi-extension handling
@protected path_regexp (?i)^/+(config|storage|logs)/|^/+(uploads|\.well-known)/.*\.(php|phtml|phar|pht)
error @protected 403
# error runs before php_fastcgi and file_server (Caddy's directive order)
@admin path /admin/*
php_fastcgi @admin unix//run/php/php8.3-fpm.sock {
try_files {path} /admin/index.php
}
@site not path /admin/*
php_fastcgi @site unix//run/php/php8.3-fpm.sock {
try_files {path} /index.php
}
file_server
handle_errors 403 {
respond "Forbidden" 403
}
}Traefik does not speak FastCGI: it is the edge proxy in front of nginx (the config of the nginx tab), which talks to PHP-FPM.
The router and the service come from the file provider, so the Traefik container needs no Docker socket. Static configuration, traefik.yml
(HTTPS through Let's Encrypt is optional: uncomment websecure and the resolver):
entryPoints:
web:
address: ":80"
http:
# X_Forwarded_Proto would reach PHP as HTTP_X_FORWARDED_PROTO, past the header Traefik sets
aliasHeadersStrategy: delete
# With HTTPS (the websecure entry point and the letsencrypt resolver below), send plain HTTP there:
# redirections:
# entryPoint:
# to: websecure
# scheme: https
# websecure:
# address: ":443"
# http:
# aliasHeadersStrategy: delete
# internal: /ping for the container health check, not published
traefik:
address: ":8080"
http:
aliasHeadersStrategy: delete
providers:
file:
filename: /etc/traefik/dynamic.yml
ping: {}
# Optional: Let's Encrypt. Publish 80 and 443, keep /letsencrypt on a volume, and move the router to websecure (dynamic.yml).
# certificatesResolvers:
# letsencrypt:
# acme:
# email: [email protected]
# storage: /letsencrypt/acme.json
# httpChallenge:
# entryPoint: web
log:
level: WARNDynamic configuration, dynamic.yml: the router, the nginx service with its health check, and the middlewares:
http:
routers:
yf:
rule: Host(`example.com`)
entryPoints: [web]
middlewares: [yf-headers, yf-compress]
service: yf
# HTTPS: entryPoints: [websecure] and
# tls:
# certResolver: letsencrypt
services:
yf:
loadBalancer:
servers:
- url: http://nginx:80
healthCheck:
path: /
interval: 10s
middlewares:
yf-headers:
headers:
# an empty value removes the request header
customRequestHeaders:
Front-End-Https: ""
X-Url-Scheme: ""
Scheme: ""
contentTypeNosniff: true
referrerPolicy: strict-origin-when-cross-origin
# with HTTPS only:
# stsSeconds: 31536000
yf-compress:
compress: {}Behind a proxy PHP sees nginx's peer address: set $CONF['TRUSTED_PROXIES'] to the proxy network (for example ['172.16.0.0/12'] with Docker) so that the client address is read from X-Forwarded-For only when the proxy sent it. The proxy sets X-Forwarded-Proto from the client connection, which yf reads for HTTPS, and drops the other scheme headers yf would trust (Front-End-Https, X-Url-Scheme, Scheme).
Envoy as the edge proxy in front of nginx (the config of the nginx tab). It refuses the protected paths itself, normalises the path first
(normalize_path, merge_slashes, %2F) and health-checks the upstream. The routes of the listener in envoy.yaml
(request_headers_to_remove: [front-end-https, x-url-scheme, scheme] sits next to them):
routes:
# never served (remove a name your site uses as a route); uploads are data and /.well-known/ is
# for ACME: no PHP runs from there. Before the /.well-known/ exception, any case. Not end-anchored on
# purpose: a.php.jpg and x.phtml-docs/ match too, like Apache's multi-extension handling
- match: { safe_regex: { regex: "(?i)/+(config|storage|logs)/.*|/+(uploads|\\.well-known)/.*\\.(php|phtml|phar|pht).*" } }
direct_response: { status: 403, body: { inline_string: "Forbidden\n" } }
# ACME and other /.well-known/ files stay reachable
- match: { prefix: "/.well-known/" }
route: { cluster: yf_web }
# dot files and dirs (.git, .env, .htaccess, .dev ...)
- match: { safe_regex: { regex: ".*/\\..*" } }
direct_response: { status: 403, body: { inline_string: "Forbidden\n" } }
- match: { prefix: "/" }
route: { cluster: yf_web, timeout: 60s }The upstream cluster with its health check:
clusters:
- name: yf_web
type: STRICT_DNS
connect_timeout: 5s
load_assignment:
cluster_name: yf_web
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: nginx, port_value: 80 }
# the upstream leaves the pool when its home page stops answering 2xx / 3xx
health_checks:
- timeout: 5s
interval: 10s
unhealthy_threshold: 3
healthy_threshold: 1
http_health_check:
path: /
expected_statuses: [{ start: 200, end: 400 }]Behind a proxy PHP sees nginx's peer address: set $CONF['TRUSTED_PROXIES'] to the proxy network (for example ['172.16.0.0/12'] with Docker) so that the client address is read from X-Forwarded-For only when the proxy sent it. The proxy sets X-Forwarded-Proto from the client connection, which yf reads for HTTPS, and drops the other scheme headers yf would trust (Front-End-Https, X-Url-Scheme, Scheme).
For HTTPS add a transport_socket with your certificate to the listener: Envoy does not run ACME.
docker-compose.yml in the yf checkout runs PHP 8.3, MariaDB 10.11, Redis 7 and Memcached 1.6. It is for development and tests, not production.
docker compose up -d # mariadb, redis, memcached
docker compose run --rm -e YF_DB_NAME=yf_for_unit_tests php php .dev/sample_app/db_init.php
docker compose up php-serve # http://127.0.0.1:33380/docs
docker compose run --rm php make testOne pod runs PHP-FPM and nginx: PHP-FPM runs your project image (the code plus composer install of yfix/yf),
nginx runs the config of the nginx tab from a ConfigMap and talks to PHP-FPM over a unix socket on a shared volume. Both run as non-root
on read-only root filesystems under the restricted Pod Security level; PHP writes only to www/uploads/ (a volume claim),
storage/, logs/ and /tmp. A Service, an Ingress (TLS optional) and a MariaDB StatefulSet to try it out complete it.
The files are in .dev/useful/k8s (plain YAML and a kustomization.yaml);
copy them and .dev/useful/php_fpm into your project (deploy/ below), set the image and the host name, and replace the change-me database password in config.yaml before the first apply (MariaDB reads it only when it initialises its volume: changing the Secret later does not change the password), then:
docker build -t yf-php-fpm:8.3 deploy/php_fpm
docker build -f deploy/k8s/Dockerfile -t registry.example.com/myproject:1.0.0 .
docker push registry.example.com/myproject:1.0.0
kubectl apply -k deploy/k8s
kubectl -n yf rollout status deployment/yf
kubectl -n yf port-forward svc/yf 8080:80 # http://127.0.0.1:8080 before DNS points at the Ingressconfig/db_setup.php reads the database from the environment (ConfigMap yf-env, Secret yf-db), for example define('DB_HOST', getenv('YF_DB_HOST') ?: 'localhost');greenlion/php-sql-parser:~4.1.2, which yf needs to create missing system tables, and set $PROJECT_CONF['services']['ALLOW_GIT_CLONE'] = false.Recreate), so the site is down for a moment; sessions live in the pod's /tmp and are lost with it; the readiness probe loads / through PHP and the database, so a database outage takes the site out of the Service.$CONF['TRUSTED_PROXIES'] to the pod network. Use a managed database in production; the MariaDB of the sample has no backups.# PHP-FPM and nginx in one pod. php runs the project image (Dockerfile); an init container copies its www/ to the
# volume nginx serves, at the same path, so try_files and $realpath_root see what PHP-FPM runs. nginx passes PHP to
# the unix socket of zz-yf.conf on the shared php-socket volume. Both run as non-root on read-only root filesystems:
# PHP writes only to www/uploads/, storage/, logs/ and /tmp.
apiVersion: apps/v1
kind: Deployment
metadata:
name: yf
labels:
app.kubernetes.io/name: yf
spec:
# uploads is a ReadWriteOnce volume: one pod at a time. More replicas need ReadWriteMany storage (or object storage)
# for uploads and shared sessions (Redis or the DB), then RollingUpdate
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: yf
template:
metadata:
labels:
app.kubernetes.io/name: yf
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
fsGroup: 33
seccompProfile:
type: RuntimeDefault
sysctls:
# nginx runs as its own user and keeps `listen 80` of the reference config
- name: net.ipv4.ip_unprivileged_port_start
value: "80"
initContainers:
- name: web-root
image: registry.example.com/myproject:1.0.0
command: [cp, -R, /var/www/myproject/www/., /web/]
securityContext:
runAsUser: 33
runAsGroup: 33
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
resources:
requests: { cpu: 50m, memory: 32Mi }
limits: { memory: 128Mi }
volumeMounts:
- { name: web, mountPath: /web }
containers:
- name: php
image: registry.example.com/myproject:1.0.0
envFrom:
- configMapRef:
name: yf-env
env:
- name: YF_DB_PSWD
valueFrom:
secretKeyRef: { name: yf-db, key: password }
securityContext:
runAsUser: 33
runAsGroup: 33
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 768Mi }
# the master accepts on its socket; the pages themselves are the readiness probe of nginx
livenessProbe:
exec:
command: [php, -r, 'exit(@stream_socket_client("unix:///run/php/php8.3-fpm.sock") ? 0 : 1);']
periodSeconds: 20
timeoutSeconds: 5
volumeMounts:
- { name: php-socket, mountPath: /run/php }
- { name: uploads, mountPath: /var/www/myproject/www/uploads }
- { name: storage, mountPath: /var/www/myproject/storage }
- { name: logs, mountPath: /var/www/myproject/logs }
- { name: tmp, mountPath: /tmp }
- { name: php-fpm, mountPath: /usr/local/etc/php-fpm.d/zz-yf-k8s.conf, subPath: zz-yf-k8s.conf }
- name: nginx
image: nginx:1.30.6-alpine@sha256:583b7f2fce1cdb8da5ad5c39ebd3c33b98b87d493190e548bdee1fa8c619c311
ports:
- { name: http, containerPort: 80 }
securityContext:
runAsUser: 101
runAsGroup: 101
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
resources:
requests: { cpu: 50m, memory: 32Mi }
limits: { memory: 128Mi }
# through PHP and the database: a pod that cannot render pages gets no traffic
readinessProbe:
httpGet: { path: /, port: http }
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
tcpSocket: { port: http }
periodSeconds: 20
volumeMounts:
- { name: nginx-conf, mountPath: /etc/nginx/conf.d/default.conf, subPath: default.conf }
- { name: web, mountPath: /var/www/myproject/www, readOnly: true }
- { name: uploads, mountPath: /var/www/myproject/www/uploads, readOnly: true }
- { name: nginx-run, mountPath: /run }
- { name: php-socket, mountPath: /run/php }
- { name: nginx-cache, mountPath: /var/cache/nginx }
volumes:
- { name: web, emptyDir: {} }
- { name: php-socket, emptyDir: { medium: Memory, sizeLimit: 1Mi } }
- { name: uploads, persistentVolumeClaim: { claimName: yf-uploads } }
# cache and logs (storage/logs/, and logs/ for the error log): gone with the pod. A PersistentVolumeClaim like
# uploads if the site keeps data there
- { name: storage, emptyDir: { sizeLimit: 1Gi } }
- { name: logs, emptyDir: { sizeLimit: 256Mi } }
# sessions and upload temp files of PHP
- { name: tmp, emptyDir: { sizeLimit: 256Mi } }
- { name: nginx-run, emptyDir: { medium: Memory, sizeLimit: 1Mi } }
- { name: nginx-cache, emptyDir: { sizeLimit: 256Mi } }
- { name: nginx-conf, configMap: { name: yf-nginx } }
- { name: php-fpm, configMap: { name: yf-php-fpm } }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: yf-uploads
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi# The project image of the Kubernetes sample: the PHP-FPM image of ../php_fpm with the project code and vendor/
# (composer.json requires yfix/yf). Code and vendor/ belong to root, PHP runs as www-data. The image has no git and its
# tree is read-only: libraries yf would clone at run time come through composer.json too, such as
# greenlion/php-sql-parser ~4.1.2, which yf needs to create missing system tables (www/index.php loads
# vendor/autoload.php). From the project directory:
# docker build -t yf-php-fpm:8.3 /path/to/yf/.dev/useful/php_fpm
# docker build -f /path/to/yf/.dev/useful/k8s/Dockerfile -t registry.example.com/myproject:1.0.0 .
# Dockerfile.dockerignore next to this file keeps .git, .env, keys, auth.json, vendor/ and the writable dirs out of the
# build. Private composer repositories: pass auth.json as a BuildKit secret (RUN --mount=type=secret, docker build
# --secret), never COPY it. The php_fpm image is PHP 8.3 on /run/php/php8.3-fpm.sock, the path of the nginx config,
# zz-yf.conf and the liveness probe of deployment.yaml: change them together with the PHP version.
ARG PHP_FPM_IMAGE=yf-php-fpm:8.3
FROM composer:2.10.3@sha256:2cf275428757e5435a667bfc0d038b14520ca02be2b1b70d44a05c8c19e2de48 AS vendor
WORKDIR /app
COPY composer.json composer.lock* ./
RUN composer install --no-dev --no-interaction --no-progress --prefer-dist --optimize-autoloader --no-scripts
FROM ${PHP_FPM_IMAGE}
# without a php.ini display_errors is on: errors end up in the page. php.ini-production logs them to stderr instead
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
WORKDIR /var/www/myproject
COPY . .
COPY --from=vendor /app/vendor vendor/
# the volumes of deployment.yaml are mounted here
RUN mkdir -p www/uploads storage logs && chown www-data:www-data www/uploads storage logs
USER www-dataFrom a yf git checkout with MariaDB or MySQL running (.dev/sample_app/config/db_setup.php reads YF_DB_HOST, YF_DB_NAME, YF_DB_USER, YF_DB_PSWD):
mysql -uroot -p -e 'CREATE DATABASE yf_for_unit_tests'
make serve-init # = php .dev/sample_app/db_init.php; YF_INSTALL_FORCE=1 to run it again
make serve # http://127.0.0.1:33380/docsThe sample app keeps its own database (yf_for_unit_tests; make serve SAMPLE_DB_NAME=... to change it): the DB test suites truncate the tests database.
make setup installs the developer tools (phars, git hooks); it does not install a site.
The sample router reads its switches from the environment. make serve sets YF_DEBUG=1, which keeps ?debug open: use it on your own machine only. On a public host leave YF_DEBUG unset and set:
YF_PUBLIC_SITE=1 # docs and demos only (other routes 404), no menus, no login, no form writes
YF_DEBUG_CODE=<code> # optional: ?debug=<code> asks for HTTP basic auth
YF_DEBUG_SALT=<long random string> # names the cookie that skips the prompt for the rest of the day
YF_DEBUG_USERS='{"dev": "<password>"}' # basic-auth accounts, JSONWithout YF_DEBUG_CODE / YF_DEBUG_USERS debug stays off. Under php-fpm pass them as fastcgi_param (or pool env[...]) and forward the Authorization header.
Each switch is off by default so that existing sites keep their behaviour; a new site can turn them on from the start.
Put them in config/project_conf.php. Details: the upgrade notes (3.9.0); the security switches demo shows each default.
| Switch | Snippet |
|---|---|
| CSRF tokens in the admin (the user side has them already) | |
| Session cookie: SameSite, HTTPS only | |
| Session strict mode: unknown session ids are replaced | |
Passwords: bcrypt (or argon2id), old hashes re-hashed on login. Widen user.password to VARCHAR(255) first | |
| Encryption: sodium with a per-install key | |
| Remember-me tokens instead of a password-derived cookie | |
| Error pages: 500 / 404 / 403 with the status code | |
| Secrets in error logs: passwords, tokens, card fields and session ids masked in the logged request | |
Query builder escapes values. On an existing site run make audit-double-escape SITE=... first | |
Template auto-escaping ({var|raw} for HTML values) | |
Behind a proxy: trust X-Forwarded-For only from it | |
| Redirects only to your own hosts | |