Configuration loading — merging env + config files
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
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.