• Configuration Is a Contract, Not a Bag of Variables

    Most applications do not need a clever configuration system.

    They need a boring contract between code and the environment where the code runs.

    That contract should answer a few simple questions:

    • which values can change between deploys;
    • where each value comes from;
    • what type each value has;
    • whether a value is required;
    • how secrets are delivered;
    • what happens when the config is invalid.

    If those answers are scattered across framework defaults, shell scripts, Docker Compose files, example YAML, and tribal memory, the application does not have configuration. It has folklore.

    What Belongs In Config

    Configuration is the part of the application that changes between deploys while the code stays the same.

    Good examples:

    • database DSNs and resource handles;
    • hostnames, ports, public base URLs;
    • credentials and secret references;
    • per-deploy feature flags;
    • cache, queue, and storage endpoints;
    • operational limits such as timeout values.

    Bad examples:

    • routes;
    • class wiring;
    • internal module names;
    • business rules that are versioned with code;
    • values that can be computed from other config values.

    The difference matters. A route table belongs in code because changing it changes the application. A database DSN belongs in config because the same application can run against different databases in local, staging, and production.

    Do Not Invent A Format

    Custom config formats feel pleasant for the first afternoon and expensive for the next several years.

    The parser is yours. The escaping rules are yours. The documentation is yours. The edge cases are yours. The migration path is yours.

    Use a format that already has parsers and tooling:

    • JSON when machines write it or strict syntax is useful;
    • YAML when humans edit structured values and the project already uses YAML;
    • TOML when you want a small typed config format;
    • INI only for simple flat configuration;
    • native language files only for internal code wiring, not deploy-specific values.

    The exact format matters less than the contract around it. A validated JSON file is better than an unvalidated YAML file. A tiny environment-based setup is better than a beautiful config hierarchy nobody understands.

    Environment Variables Are Transport

    Environment variables are a good way to pass deploy-specific values into an application. They are language-agnostic, work naturally with containers, and keep configuration outside the build artifact.

    They are not a type system.

    Every environment variable arrives as text. Your application must parse it, validate it, and fail early when it is wrong.

    This is the shape I want in PHP:

    <?php
    
    final readonly class AppConfig
    {
        public function __construct(
            public string $appEnv,
            public bool $debug,
            public string $databaseDsn,
            public int $httpPort,
            public string $appSecret,
        ) {}
    
        public static function fromEnvironment(): self
        {
            return new self(
                appEnv: self::required('APP_ENV'),
                debug: self::boolean('APP_DEBUG', false),
                databaseDsn: self::required('DATABASE_DSN'),
                httpPort: self::integer('HTTP_PORT', 8080, min: 1, max: 65535),
                appSecret: self::secret('APP_SECRET', 'APP_SECRET_FILE'),
            );
        }
    
        private static function required(string $name): string
        {
            $value = getenv($name);
    
            if ($value === false || $value === '') {
                throw new RuntimeException("Missing required config: {$name}");
            }
    
            return $value;
        }
    
        private static function boolean(string $name, bool $default): bool
        {
            $value = getenv($name);
    
            if ($value === false || $value === '') {
                return $default;
            }
    
            $parsed = filter_var(
                $value,
                FILTER_VALIDATE_BOOLEAN,
                FILTER_NULL_ON_FAILURE,
            );
    
            if ($parsed === null) {
                throw new RuntimeException("Invalid boolean config: {$name}");
            }
    
            return $parsed;
        }
    
        private static function integer(
            string $name,
            int $default,
            int $min,
            int $max,
        ): int {
            $value = getenv($name);
    
            if ($value === false || $value === '') {
                return $default;
            }
    
            $parsed = filter_var(
                $value,
                FILTER_VALIDATE_INT,
                ['options' => ['min_range' => $min, 'max_range' => $max]],
            );
    
            if ($parsed === false) {
                throw new RuntimeException("Invalid integer config: {$name}");
            }
    
            return $parsed;
        }
    
        private static function secret(string $name, string $fileName): string
        {
            $file = getenv($fileName);
    
            if ($file !== false && $file !== '') {
                $value = file_get_contents($file);
    
                if ($value === false || trim($value) === '') {
                    throw new RuntimeException("Invalid secret file: {$fileName}");
                }
    
                return trim($value);
            }
    
            return self::required($name);
        }
    }

    Then build the config once at startup:

    $config = AppConfig::fromEnvironment();

    Do not call getenv() from random services. Do not parse booleans in controllers. Do not let half the codebase decide what APP_DEBUG=yes means.

    One loader. One typed object. One place to fail.

    Defaults Are A Promise

    Defaults are not harmless.

    This is fine:

    httpPort: self::integer('HTTP_PORT', 8080, min: 1, max: 65535)

    A local HTTP port has a reasonable default.

    This is not fine:

    databaseDsn: getenv('DATABASE_DSN') ?: 'sqlite:///:memory:'

    The application may start successfully against the wrong database. That is worse than failing.

    Use defaults only when the default is safe, explicit, and unsurprising. Required production values should stop the process when missing.

    Make Precedence Explicit

    Every mature system eventually gets multiple config sources:

    • code defaults;
    • .env files for local development;
    • shell environment;
    • Docker Compose interpolation;
    • orchestrator variables;
    • secret files;
    • command-line flags.

    That is fine if precedence is documented and testable. It is dangerous when nobody knows which layer wins.

    For Docker Compose, remember two separate ideas:

    • interpolation fills values in compose.yaml;
    • environment or env_file sets variables inside the container.

    An .env file used by Compose is not automatically a complete application config system. It is input to Compose. If the container needs a variable, pass it explicitly.

    For example:

    services:
      app:
        image: registry.example.com/acme/app:${APP_VERSION:?set APP_VERSION}
        environment:
          APP_ENV: ${APP_ENV:-production}
          APP_DEBUG: ${APP_DEBUG:-false}
          DATABASE_DSN: ${DATABASE_DSN:?set DATABASE_DSN}
          HTTP_PORT: ${HTTP_PORT:-8080}
          APP_SECRET_FILE: /run/secrets/app_secret
        secrets:
          - app_secret
    
    secrets:
      app_secret:
        file: ./run/secrets/app_secret

    The ${NAME:?message} form is useful because Compose fails before starting containers when the value is missing.

    To inspect what Compose resolved, use:

    docker compose config --environment

    That command is a good review tool. It shows which values Compose used for interpolation before you discover a typo at runtime.

    Secrets Are Not Just Long Config Values

    Secrets deserve a stricter path than normal config.

    Putting a password in an environment variable is often better than committing it to a repository, but it is not magic. Environment variables can leak through debugging output, process inspection, crash reports, logs, shell history, and careless support bundles.

    Prefer the secret mechanism of the platform you deploy on:

    • Docker Compose or Swarm secrets;
    • Kubernetes Secrets or an external secrets operator;
    • a cloud secret manager;
    • a CI/CD secret store;
    • encrypted local files for development when the team has a clear workflow.

    In containers, a common pattern is to mount the secret as a file and pass the file path through config:

    APP_SECRET_FILE=/run/secrets/app_secret

    Then the application reads the file at startup. This keeps the secret value out of the normal environment and makes secret delivery explicit.

    Also: never print the full config object. Redact secrets before logging.

    Config Files Still Have A Place

    “Use environment variables” does not mean “ban config files.”

    Config files are fine when they describe application structure that is versioned with code:

    return [
        'routes' => require __DIR__ . '/routes.php',
        'services' => require __DIR__ . '/services.php',
    ];

    They are also fine for non-secret defaults:

    pagination:
      defaultPageSize: 50
      maxPageSize: 200

    The key rule is simple:

    • if a value varies per deploy, inject it at deploy time;
    • if a value changes application behavior and should be reviewed with code, keep it in code or versioned config;
    • if a value is secret, do not commit it.

    Avoid Environment Copies

    The old pattern was to keep files such as:

    development.yml
    staging.yml
    production.yml
    production-eu.yml
    production-eu-hotfix.yml

    This scales badly. Files drift. Secrets leak. New deploys require copying old deploys and hoping nobody copied the wrong value.

    Prefer granular config values:

    APP_ENV=production
    DATABASE_DSN=...
    REDIS_URL=...
    FEATURE_NEW_CHECKOUT=true
    PUBLIC_BASE_URL=https://example.com

    The environment name can still exist as a label. It should not be the only switch that secretly selects a giant config bundle.

    Validate The Contract

    Configuration needs tests like any other public interface.

    At minimum:

    • the app fails when each required value is missing;
    • booleans reject unknown strings;
    • integers reject values outside the valid range;
    • URLs and DSNs are syntactically checked;
    • secrets are present but redacted in diagnostic output;
    • local example files match the actual required keys.

    This can be a unit test around AppConfig::fromEnvironment(). It can also be a startup health check, a schema validation step, or a deployment gate.

    The important point is that configuration errors should appear before the application starts serving traffic.

    A Practical Layout

    For a small service, I would start with this:

    config/
      services.php
      routes.php
    src/
      AppConfig.php
    .env.example
    compose.yaml

    .env.example documents local development values without containing secrets:

    APP_ENV=local
    APP_DEBUG=true
    DATABASE_DSN=mysql://user:password@db:3306/app
    HTTP_PORT=8080
    APP_SECRET_FILE=./run/secrets/app_secret

    The real local .env stays out of Git. Production values come from the deployment platform.

    That is enough for a lot of applications.

    The Rule

    Configuration is not where you hide uncertainty.

    It is the contract that lets the same code run in different places. Keep that contract small, typed, validated, documented, and separated from secrets.

    When configuration is treated this way, the application becomes easier to deploy and easier to reason about. When it is not, every deploy becomes a small act of archaeology.