0

Configuration loading — merging env + config files

Intermediate5 min read·fw-01-006

Concept

Configuration loading sits above environment loading in the bootstrap hierarchy. While .env provides raw key-value pairs for secrets and environment-specific settings, configuration files provide structured PHP arrays with default values, type validation, and documentation. The relationship between the two is: config/database.php references env('DB_HOST', '127.0.0.1') — the config file provides the default and documents the key, while .env overrides the value per environment.

The config system must solve two problems: loading and merging files lazily (you do not want to parse every config file on every request if only one is needed), and providing a dot-notation access API so config('database.connections.mysql.host') traverses the nested array without manual $config['database']['connections']['mysql']['host'] syntax.

Lazy loading is important for performance. A typical Laravel application has 15+ config files. Parsing and evaluating all of them on every request adds meaningful overhead. Our config loader will read a file only when its top-level key is first accessed. This is the same approach Laravel uses in Illuminate\Config\Repository — the get() method checks if the file is already loaded and calls the loader only if not.

The dot-notation traversal is an elegant recursive algorithm: split the key by ., take the first segment as the array key, recursively descend. With PHP's array_key_exists and null-safe access, this becomes a neat helper. The Arr::get() method in Laravel's Illuminate\Support\Arr is the canonical implementation; it handles nested arrays, dot notation, and null defaults in under 20 lines.

Config files should return plain PHP arrays, not objects. This makes them serialisable by var_export() for caching, evaluable without a framework present, and trivially readable by any developer who knows PHP. Laravel's config files follow this exact pattern: every file returns ['key' => value, ...].

Code Example

php
<?php
declare(strict_types=1);

namespace Lumen\Foundation\Config;

/**
 * Configuration repository with lazy file loading and dot-notation access.
 *
 * Usage:
 *   $config = new Repository('/path/to/config');
 *   $config->get('database.connections.sqlite.path');
 *   $config->set('app.debug', true);
 *
 * Laravel equivalent: Illuminate\Config\Repository
 */
class Repository
{
    /** Parsed config values, keyed by top-level filename (without .php). */
    private array $items = [];

    /** Already-loaded file names to avoid double-reading. */
    private array $loaded = [];

    public function __construct(
        private readonly string $configPath
    ) {}

    /**
     * Get a configuration value using dot notation.
     *
     * @param  string  $key     e.g. 'database.connections.sqlite.path'
     * @param  mixed   $default Returned when the key does not exist
     */
    public function get(string $key, mixed $default = null): mixed
    {
        // Load the file for the top-level key if not yet loaded
        $this->ensureLoaded($this->topKey($key));

        return $this->dotGet($this->items, $key, $default);
    }

    /**
     * Set a configuration value (in-memory only, does not write to disk).
     */
    public function set(string $key, mixed $value): void
    {
        $this->ensureLoaded($this->topKey($key));
        $this->dotSet($this->items, $key, $value);
    }

    /**
     * Check whether a configuration key exists.
     */
    public function has(string $key): bool
    {
        return $this->get($key) !== null;
    }

    /**
     * Load all config files into memory at once.
     * Call this in production after config:cache generates the compiled file.
     */
    public function loadAll(): void
    {
        $files = glob($this->configPath . DIRECTORY_SEPARATOR . '*.php') ?: [];

        foreach ($files as $file) {
            $key = basename($file, '.php');
            $this->load($key);
        }
    }

    // ------------------------------------------------------------------
    // Private helpers
    // ------------------------------------------------------------------

    private function ensureLoaded(string $key): void
    {
        if (!isset($this->loaded[$key])) {
            $this->load($key);
        }
    }

    private function load(string $key): void
    {
        $this->loaded[$key] = true;
        $file = $this->configPath . DIRECTORY_SEPARATOR . $key . '.php';

        if (is_file($file)) {
            $values = require $file;
            $this->items[$key] = is_array($values) ? $values : [];
        }
    }

    private function topKey(string $key): string
    {
        return str_contains($key, '.') ? strstr($key, '.', true) : $key;
    }

    /**
     * Traverse a nested array using dot-notation.
     */
    private function dotGet(array $array, string $key, mixed $default): mixed
    {
        if (array_key_exists($key, $array)) {
            return $array[$key];
        }

        $segments = explode('.', $key);

        foreach ($segments as $segment) {
            if (!is_array($array) || !array_key_exists($segment, $array)) {
                return $default;
            }
            $array = $array[$segment];
        }

        return $array;
    }

    private function dotSet(array &$array, string $key, mixed $value): void
    {
        $segments = explode('.', $key);
        $last     = array_pop($segments);

        foreach ($segments as $segment) {
            if (!isset($array[$segment]) || !is_array($array[$segment])) {
                $array[$segment] = [];
            }
            $array = &$array[$segment];
        }

        $array[$last] = $value;
    }
}

Interview Q&A

Q: Why are config files PHP arrays rather than YAML or JSON?

PHP array config files have three advantages over YAML or JSON: (1) They can call env() to reference environment variables at load time — YAML/JSON are static strings. (2) They are natively parseable by require, which uses OPcache — reading a compiled array from opcache is orders of magnitude faster than parsing YAML or JSON. (3) They support PHP expressions: 'expiry' => 60 * 60 * 24 is more readable than 86400. Laravel uses PHP arrays exclusively for config. Symfony uses YAML by default, which requires a separate parser and cannot reference env vars inline without its custom !env YAML tag. The trade-off is that PHP config files are not easily consumed by non-PHP tooling (shell scripts, deployment pipelines), but for application configuration this is rarely a problem.


Q: What is php artisan config:cache doing and what is the risk?

config:cache calls Repository::loadAll(), merges all config arrays into a single large PHP array, and writes it to bootstrap/cache/config.php. On the next request, the Application loads this single file instead of lazy-loading individual config files. The performance gain is significant on large apps with many config files. The risk: after caching, env() calls inside config files are no longer evaluated — they are baked in. If you change your .env and forget to re-run config:cache, the app will use stale config values. The common debugging scenario: developer changes APP_ENV=production in .env but the cached config still has APP_ENV=local, causing confusing behaviour. Always run config:cache again after any .env change in production.


Q: Explain the dot-notation access pattern — what makes it elegant for nested configuration?

Dot notation (database.connections.mysql.host) converts a deeply nested array access into a flat string key. Without it, you write $config['database']['connections']['mysql']['host'] — verbose and error-prone (no default handling). The dot-notation traversal splits the key by ., then iterates segment by segment through nested arrays. If any segment is missing, return the default immediately. This provides safe deep-access in one line: $config->get('database.connections.mysql.host', '127.0.0.1'). Laravel's Arr::get() in Illuminate\Support\Arr is 15 lines implementing exactly this. The same pattern is used by data_get(), config(), session(), request(), and many other Laravel helpers — it is the universal nested-array accessor pattern in the framework.