Install yf

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.

The installer in .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).
  1. Requirements
  2. Get yf
  3. Create a project
  4. Web server
  5. Docker, Kubernetes or run the sample app
  6. Harden

1. Requirements

PHP

8.2 or newer (CI tests 8.4 and 8.5) and Composer 2.

Extensions

mbstring intl gd mysqli pdo_mysql sodium zip pcntl; redis igbinary memcached for those caches; pgsql pdo_pgsql for PostgreSQL.

Database

MariaDB 10.11, MySQL 8.0 or PostgreSQL 16. Optional: Redis 7, Memcached 1.6.

Web server

nginx (or Caddy) with PHP-FPM 8.x; php -S for development.

2. Get yf

Require the package from Packagist:

composer require yfix/yf

vendor/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 --init

The front controllers then define YF_PATH themselves: define('YF_PATH', dirname(__DIR__) . '/yf/');

3. Create a project

Layout
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.

config/db_setup.php
<?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');
www/index.php
<?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);
www/admin/index.php
<?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);
Database and the first admin

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())"

4. Web server

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

Dynamic 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 (development stack)

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 test

Kubernetes (minimal setup)

One 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 Ingress
  • config/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');
  • The image has no git and its tree is read-only: require the libraries yf would clone at run time with composer, such as greenlion/php-sql-parser:~4.1.2, which yf needs to create missing system tables, and set $PROJECT_CONF['services']['ALLOW_GIT_CLONE'] = false.
  • One replica: uploads is a ReadWriteOnce volume. More replicas need ReadWriteMany (or object) storage for uploads and shared sessions (Redis or the database).
  • Each rollout restarts the pod (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.
  • A NetworkPolicy lets only the yf pods reach MariaDB, where the cluster's network plugin enforces policies.
  • Behind the Ingress set $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-data

Run the sample app (this site)

From 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/docs

The 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.

Public docs 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, JSON

Without 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.

5. Harden

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.

SwitchSnippet
CSRF tokens in the admin (the user side has them already)
$PROJECT_CONF['csrf_guard']['ENABLED_FOR'] = ['admin' => true, 'user' => true];
Session cookie: SameSite, HTTPS only
$PROJECT_CONF['session']['COOKIE_SAMESITE'] = 'Lax';
$PROJECT_CONF['session']['COOKIE_SECURE'] = true;
Session strict mode: unknown session ids are replaced
$PROJECT_CONF['session']['USE_STRICT_MODE'] = true;
Passwords: bcrypt (or argon2id), old hashes re-hashed on login. Widen user.password to VARCHAR(255) first
$PROJECT_CONF['password']['ALGO'] = 'bcrypt';
$PROJECT_CONF['password']['HASH_ON_WRITE'] = true;
$PROJECT_CONF['password']['REHASH_ON_LOGIN'] = true;
Encryption: sodium with a per-install key
define('YF_SECRET_KEY', '<32+ random bytes, per install>');
$PROJECT_CONF['encryption']['DRIVER'] = 'sodium';
Remember-me tokens instead of a password-derived cookie
$PROJECT_CONF['auth_user']['REMEMBER_ME_TOKENS'] = true;
Error pages: 500 / 404 / 403 with the status code
$PROJECT_CONF['core_errors']['ERROR_PAGES'] = true;
Secrets in error logs: passwords, tokens, card fields and session ids masked in the logged request
$PROJECT_CONF['core_errors']['LOG_REDACT'] = true;
Query builder escapes values. On an existing site run make audit-double-escape SITE=... first
$PROJECT_CONF['db']['ESCAPE_BUILDER_VALUES'] = true;
Template auto-escaping ({var|raw} for HTML values)
$PROJECT_CONF['tpl']['AUTO_ESCAPE'] = true;
$PROJECT_CONF['tpl']['EXPOSE_SUPERGLOBALS'] = false;
Behind a proxy: trust X-Forwarded-For only from it
$PROJECT_CONF['client_utils']['TRUSTED_PROXIES'] = ['10.0.0.0/8'];
Redirects only to your own hosts
$CONF['DOMAINS'] = ['example.com', 'www.example.com'];
$PROJECT_CONF['redirect']['RESTRICT_HOSTS'] = true;