Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions docs/config-layering.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
Configuration Layering
======================

ArrayKit provides explicit configuration-layer semantics for applications that
compose defaults, file-backed configuration, and runtime overrides.

ConfigMerge
-----------

``ConfigMerge`` recursively merges associative configuration maps while treating
lists as atomic values. A higher-precedence list replaces the lower list instead
of merging numeric indexes.

.. code-block:: php

<?php

use Infocyph\ArrayKit\Config\ConfigMerge;

$config = ConfigMerge::merge(
[
'app' => ['name' => 'Example', 'debug' => false],
'middleware' => ['auth', 'csrf'],
],
[
'app' => ['debug' => true],
'middleware' => ['api'],
],
);

// middleware is ['api'], not ['api', 'csrf'].

Use ``ConfigMerge::mergeMany()`` when composing multiple layers in precedence
order.

LayeredLazyFileConfig
---------------------

``LayeredLazyFileConfig`` composes three layers with this precedence:

``fallback < lazy source < overrides``

Only the requested namespace is materialized. Exact path reads are resolved from
the fully merged namespace, so list replacement and scalar shadowing cannot leak
values from lower-precedence layers.

.. code-block:: php

<?php

use Infocyph\ArrayKit\Config\LayeredLazyFileConfig;

$config = new LayeredLazyFileConfig(
directory: __DIR__.'/config',
namespaceCacheDirectory: __DIR__.'/bootstrap/cache/config',
fallback: [
'app' => ['debug' => false],
],
overrides: [
'app' => ['debug' => true],
],
namespaces: ['app', 'cache', 'database'],
);

$debug = $config->get('app.debug');

``all()`` materializes the configured namespace set. ``warmNamespaceCache()``
and ``clearNamespaceCache()`` delegate generated source-cache lifecycle to the
underlying lazy source.

Resilient Lazy Source Cache
---------------------------

``ResilientLazyFileConfig`` treats generated namespace-cache corruption as a
cache miss and retries the authoritative source namespace. Invalid source files
still fail normally; resilience applies only to disposable generated cache
artifacts.

Malformed or invalid ``__flat.php`` indexes are also treated as cache misses.
This keeps an acceleration artifact from preventing source configuration from
loading.

Environment Enumeration
-----------------------

``Environment::all()`` enumerates all three runtime sources with the same
precedence used by ``Environment::get()``:

1. ``$_ENV``;
2. non-HTTP ``$_SERVER`` values;
3. process values returned by ``getenv()``.

This means process-only variables are visible during complete environment
enumeration as well as direct lookup.
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Contents
collection
config
lazy-config
config-layering
traits-and-helpers
migration
rule-reference
Expand Down
57 changes: 24 additions & 33 deletions src/Config/Concerns/LazyFileConfigCacheTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,7 @@ protected function collectFlatLeafIndex(string $namespace, array $namespaceData,
}
}

/**
* @return string[]
*/
/** @return string[] */
protected function discoverNamespaces(): array
{
$namespaces = [];
Expand Down Expand Up @@ -222,20 +220,20 @@ protected function loadFlatLeafIndex(): void
}

$this->flatLeafIndex = [];

$path = $this->flatLeafIndexPath();
if ($path === null || !is_file($path) || !is_readable($path)) {
$this->flatLeafIndexLoaded = true;

return;
}

$loaded = include $path;
if (!is_array($loaded)) {
throw new UnexpectedValueException("Config file [{$path}] must return an array.");
if ($path !== null && is_file($path) && is_readable($path)) {
try {
$loaded = include $path;
if (is_array($loaded)) {
$this->flatLeafIndex = $this->filterFlatLeafIndex($loaded);
}
} catch (\Throwable) {
// Generated flat indexes are disposable acceleration artifacts.
// A corrupt index is a cache miss; namespace/source loading remains authoritative.
}
}

$this->flatLeafIndex = $this->filterFlatLeafIndex($loaded);
$this->flatLeafIndexLoaded = true;
}

Expand Down Expand Up @@ -267,8 +265,8 @@ protected function writeFlatLeafIndexFromCacheDirectory(): void
}

$index = $this->buildFlatLeafIndexFromDirectory($directory);

ksort($index);

if (!$this->writeCacheFile($indexPath, "<?php\n\nreturn " . var_export($index, true) . ";\n")) {
throw new RuntimeException('Unable to write flat lazy-config index cache.');
}
Expand All @@ -277,9 +275,7 @@ protected function writeFlatLeafIndexFromCacheDirectory(): void
$this->flatLeafIndexLoaded = true;
}

/**
* @param array<string, scalar|null> $index
*/
/** @param array<string, scalar|null> $index */
private function addFlatLeafIndexValue(array &$index, string $path, mixed $value): void
{
if (
Expand All @@ -293,11 +289,10 @@ private function addFlatLeafIndexValue(array &$index, string $path, mixed $value
}
}

/**
* @return array<string, scalar|null>
*/
/** @return array<string, scalar|null> */
private function buildFlatLeafIndexFromDirectory(string $directory): array
{
/** @var array<string, scalar|null> $index */
$index = [];
$entries = scandir($directory);
if ($entries === false) {
Expand All @@ -311,18 +306,20 @@ private function buildFlatLeafIndexFromDirectory(string $directory): array
}

$namespace = substr($entry, 0, -strlen($suffix));
if ($namespace === '') {
if ($namespace === '' || preg_match('/^[A-Za-z0-9_-]+$/', $namespace) !== 1) {
continue;
}

if (!preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) {
$path = $directory . DIRECTORY_SEPARATOR . $entry;

try {
$loaded = include $path;
} catch (\Throwable) {
continue;
}

$path = $directory . DIRECTORY_SEPARATOR . $entry;
$loaded = include $path;
if (!is_array($loaded)) {
throw new UnexpectedValueException("Config file [{$path}] must return an array.");
continue;
}

$this->collectFlatLeafIndex($namespace, $loaded, $index);
Expand All @@ -337,6 +334,7 @@ private function buildFlatLeafIndexFromDirectory(string $directory): array
*/
private function filterFlatLeafIndex(array $loaded): array
{
/** @var array<string, scalar|null> $index */
$index = [];

foreach ($loaded as $key => $value) {
Expand All @@ -363,11 +361,7 @@ private function flushAllNamespaceCacheFiles(): void
$entries = scandir($directory);
if ($entries !== false) {
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}

if (!$this->isOwnedNamespaceCacheEntry($entry)) {
if ($entry === '.' || $entry === '..' || !$this->isOwnedNamespaceCacheEntry($entry)) {
continue;
}

Expand Down Expand Up @@ -406,9 +400,6 @@ private function isOwnedNamespaceCacheEntry(string $entry): bool
return $namespace !== '' && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1;
}

/**
* Hold one exclusive lock across namespace writes/deletes and flat-index rebuilding.
*/
private function withNamespaceCacheLock(\Closure $operation): static
{
$directory = $this->namespaceCacheDirectory;
Expand Down
60 changes: 60 additions & 0 deletions src/Config/ConfigMerge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Infocyph\ArrayKit\Config;

final class ConfigMerge
{
private function __construct() {}

public static function isMap(mixed $value): bool
{
return is_array($value) && !array_is_list($value);
}

/**
* Recursively overlay configuration maps while replacing list values atomically.
*
* @param array<array-key, mixed> $base
* @param array<array-key, mixed> $overlay
* @return array<array-key, mixed>
*/
public static function merge(array $base, array $overlay): array
{
foreach ($overlay as $key => $value) {
if (
array_key_exists($key, $base)
&& self::isMap($base[$key])
&& self::isMap($value)
) {
/** @var array<array-key, mixed> $baseValue */
$baseValue = $base[$key];
/** @var array<array-key, mixed> $overlayValue */
$overlayValue = $value;
$base[$key] = self::merge($baseValue, $overlayValue);

continue;
}

$base[$key] = $value;
}

return $base;
}

/**
* @param iterable<array<array-key, mixed>> $layers
* @return array<array-key, mixed>
*/
public static function mergeMany(iterable $layers): array
{
$merged = [];

foreach ($layers as $layer) {
$merged = self::merge($merged, $layer);
}

return $merged;
}
}
Loading