📦 Marketplace⭐ GitHub
Packagesv3.2

MLC

MonkeysLegion MLC is a high-performance, production-ready configuration engine for PHP 8.4+ applications. It is the official configuration format for the MonkeysLegion ecosystem, designed around a single core principle: parse once, serve from bytecode forever.


Table of Contents

  1. Core Philosophy
  2. Installation
  3. The .mlc Format
  4. Loading Configuration
  5. Reading Values
  6. OPcache Pre-compilation
  7. Standard PSR-16 Caching
  8. Dual-Layer Runtime Overrides
  9. Locking & Immutability
  10. Atomic Snapshots
  11. Schema Validation
  12. Security Features
  13. Multi-Format Support
  14. CLI Tooling (mlc-check)
  15. Event Hooks
  16. API Reference

🚀 Core Philosophy

MLC is built on four pillars:

  1. Zero-Overhead Production Mode — Configuration is compiled to static PHP arrays and served directly from OPcache shared memory. No file parsing, no deserialization, no I/O on every request.
  2. Security First — Hardened against path traversal, world-writable files, circular references, and oversized inputs.
  3. Strict Typing — Typed getters enforce value types at the boundary, catching misconfigurations early.
  4. Layered Mutability — An immutable compiled base with an opt-in, non-destructive override layer for runtime use cases like feature flags and multi-tenancy.

📦 Installation

composer require monkeyscloud/monkeyslegion-mlc

🛠️ The .mlc Format

MLC files use a clean, readable syntax inspired by .env and structured config formats.

Key-Value Pairs

Both = and whitespace are valid separators:

app_name = "My Application"
debug    true
port     8080

Sections (Nesting)

database {
    host = localhost
    port = 3306

    credentials {
        user = app_user
        pass = ${DB_PASSWORD}
    }
}

Arrays & JSON Objects

allowed_ips = ["127.0.0.1", "10.0.0.1"]

# Multi-line arrays are supported
features = [
    "caching",
    "validation",
    "security"
]

Supported Value Types

MLC syntaxPHP type
true / falsebool
nullnull
3306int
3.14float
"hello" / 'hello'string
[1, 2, 3]array
{"a": 1}array

Environment Variable Expansion

Expand environment variables or other configuration keys using the ${VAR} syntax.

Supported Fallback Syntax

You can provide a default value if the variable is not set using the :- separator:

  • Strict Types: Numbers, booleans, and null are correctly typed (e.g., ${DEBUG:-false} is a bool).
  • Quoted Defaults: Supports single or double quotes (e.g., ${NAME:-"Guest user"}).
  • Nested References: A default can be another variable (e.g., ${PORT:-\${DEFAULT_PORT:-8080}}).
  • Interpolation: Variables can be mixed with literal strings (e.g., https://\${HOST:-localhost}).

Example:

db_pass = \${DB_PASSWORD}
db_port = \${DB_PORT:-3306}
api_url = "https://\${HOST:-localhost}:\${PORT:-8080}/v1"

[!TIP] Nested references are resolved recursively. If ${PORT} is missing, it will check ${DEFAULT_PORT}, and if that's also missing, it will use 8080.

Cross-Key References

Keys can reference other keys defined in the same file:

base_url = "https://api.example.com"
health   = "${base_url}/health"

Circular reference protection: MLC uses a Dependency Graph Tracker. If a circular reference is detected (a = ${b}, b = ${a}), a CircularDependencyException is thrown immediately.

Recursive Includes

You can split your configuration into multiple files and include them using the @include statement. Paths are resolved relative to the current file.

Supported Syntaxes

At least one space is required between @include and the path.

SyntaxExampleNote
Unquoted@include base.mlcRecommended for simple filenames; cannot contain spaces.
Quotes@include "extra settings.mlc"Single or double quotes; required if path contains spaces.
Angle Brackets@include <shared.mlc>C-style inclusion; provides an alternative clear grouping.
# app.mlc
app_name = "My Application"

# Top-level inclusion
@include database.mlc

# Scoped inclusion
network {
    @include "network_defaults.mlc"
    port = 8080 # Overrides anything from network_defaults.mlc
}

Circular include protection: MLC tracks the inclusion stack. If a file tries to include itself or a file currently being parsed, a ParserException is thrown.


📖 Loading Configuration

The Loader is the primary entry point. It requires a Parser instance and a base directory containing .mlc files.

use MonkeysLegion\Mlc\Loader;
use MonkeysLegion\Mlc\Parsers\MlcParser;
use MonkeysLegion\Env\EnvManager;
use MonkeysLegion\Env\Loaders\DotenvLoader;
use MonkeysLegion\Env\Repositories\NativeEnvRepository;

// 1. Bootstrap environment for the parser
$env = new EnvManager(new DotenvLoader(), new NativeEnvRepository());
$parser = new MlcParser($env, __DIR__ . '/config');

// 2. Initialize the loader
$loader = new Loader($parser, __DIR__ . '/config');

// 3. Load and merge configuration
$config = $loader->load(['app', 'database']);

// Shorthand for a single file
$config = $loader->loadOne('app');

// Force a fresh parse, bypassing any cache
$config = $loader->reload(['app', 'database']);

Multiple files are merged left-to-right with array_replace_recursive — later files win on key conflicts.


📥 Reading Values

Dot-Notation Access

$config->get('database.host');              // mixed, null if missing
$config->get('database.port', 3306);       // with default
$config->has('database.host');             // bool
$config->getRequired('database.host');     // throws ConfigException if missing

Typed Getters

All typed getters return null (or the provided $default) when the path is absent, and throw ConfigException when the value exists but is the wrong type.

$host    = $config->getString('database.host', 'localhost');
$port    = $config->getInt('database.port', 3306);
$timeout = $config->getFloat('database.timeout', 5.0);
$debug   = $config->getBool('app.debug', false);
$drivers = $config->getArray('database.drivers', []);

Export

$config->all();      // array — compiled base data
$config->toArray();  // alias for all()
$config->toJson();   // JSON string (pretty-printed, throws JsonException on error)
$config->subset('database'); // new Config scoped to the 'database' section
$config->merge($other);      // new Config with $other merged on top

CompiledPhpCache converts your .mlc files into static PHP files (<?php return [...];). PHP's OPcache stores the resulting opcode array in shared memory — subsequent load() calls are a direct memory read with no file I/O, no parsing, and no deserialization.

Setup

use MonkeysLegion\Mlc\Cache\CompiledPhpCache;
use MonkeysLegion\Mlc\Loader;
use MonkeysLegion\Mlc\Parsers\MlcParser;
use MonkeysLegion\Env\EnvManager;
use MonkeysLegion\Env\Loaders\DotenvLoader;
use MonkeysLegion\Env\Repositories\NativeEnvRepository;

$env    = new EnvManager(new DotenvLoader(), new NativeEnvRepository());
$parser = new MlcParser($env, __DIR__ . '/config');
$cache  = new CompiledPhpCache('/var/cache/mlc');
$loader = new Loader($parser, __DIR__ . '/config', cache: $cache);

Compile once — serve forever

// ── Deployment / warm-up script (run once per deploy) ──────────────────
$loader->compile(['app', 'database', 'cors']);
// ^ Parses .mlc files, writes /var/cache/mlc/*.generated.php

// ── Every HTTP request (zero-overhead) ─────────────────────────────────
$config = $loader->load(['app', 'database', 'cors']);
// ^ require → OPcache → array. No parsing, no disk I/O.

Design contract

PropertyBehaviour
TTLDeliberately ignored. Compiled files never self-expire.
EvictionExplicit only: compile(), delete(), or clear().
Re-compileCall compile() again after a config change (e.g. in your deploy pipeline).
AtomicityWrites use a temp file + rename() to prevent half-written reads.
OPcache safetyExisting bytecode is invalidated via opcache_invalidate() before each write.

🗄️ Standard PSR-16 Caching

For environments without OPcache, or where mtime-based auto-invalidation is preferred, pass any PSR-16 implementation:

use MonkeysLegion\Cache\CacheManager;

$cache = (new CacheManager($cacheConfig))->store('redis');
$loader = new Loader(new MlcParser(), __DIR__ . '/config', cache: $cache);

$config = $loader->load(['app']); // auto-invalidates when source files change

The standard cache stores a metadata envelope {data, files, mtimes} and re-parses automatically when a source file's mtime changes.


🔄 Dual-Layer Runtime Overrides

The dual-layer engine lets you apply non-destructive runtime overrides on top of the immutable compiled base. The compiled array is never touched.

How it works

┌──────────────────────────────────────────────────────┐
│ Layer 2: $runtimeOverrides  (dormant until first use) │
│ Layer 1: $data              (compiled base, read-only) │
└──────────────────────────────────────────────────────┘

get() with dormant layer  →  direct lookup in $data (zero overhead)
get() with active layer   →  $runtimeOverrides[$path] ?? $data traversal

The override layer is lazy — it activates automatically on the first override() call. Before that, get() reads directly from the compiled base with no extra checks.

Usage

// Load from OPcache — compiled base, no locks, dual-layer dormant
$config = $loader->load(['app']);

// Apply runtime overrides — activates dual-layer on first call
$config->override('app.debug', true);
$config->override('database.host', 'replica.internal');

// get() checks override layer first
echo $config->get('app.debug');         // true  (override)
echo $config->get('app.name');          // "My Application"  (compiled base)

// all() always returns the compiled base — overrides are NOT included
$base = $config->all();                 // compiled base only

// Inspect active overrides
$overrides = $config->getOverrides();   // ['app.debug' => true, ...]

// Is the dual-layer currently active?
$active = $config->isDualLayerActive(); // true after first override()

🔒 Locking & Immutability

Two explicit locks give you fine-grained control. No automatic locking — the Loader returns an unlocked Config; you decide if and when to lock.

Lock 1 — lock() : Sealed, read-only

Prevents any override() call. Use immediately after load() when you want a permanently immutable config.

$config = $loader->load(['app']);
$config->lock(); // sealed — no overrides ever

$config->override('x', 1); // ❌ throws FrozenConfigException("config is sealed")

// Reading and snapshots always work
$config->get('app.name');    // ✅
$config->snapshot();         // ✅

Lock 2 — lockOverrides() : Override layer sealed

Prevents further override() calls after a set of overrides has been applied. Already-applied overrides remain visible.

$config = $loader->load(['app']);
$config->override('feature.dark_mode', true);
$config->override('feature.beta',      false);
$config->lockOverrides(); // no more changes

$config->override('anything', 'x'); // ❌ throws FrozenConfigException("override layer is sealed")

// Existing overrides are still readable
$config->get('feature.dark_mode'); // ✅ true

Immutability contract

OperationNo lockslock()lockOverrides()
get() / typed getters
override()
snapshot()
isDualLayerActive()

Both locks prevent override(). The difference is intent and timing: lock() seals from the start (no overrides ever); lockOverrides() seals after you have applied your desired overrides.

Introspection

$config->isLocked();           // bool — was lock() called?
$config->areOverridesLocked(); // bool — was lockOverrides() called?
$config->isDualLayerActive();  // bool — has override() been called at least once?

📸 Atomic Snapshots

snapshot() flattens the compiled base and the current override layer into a fresh, completely independent Config instance. The original is unaffected.

The new instance:

  • starts with no locks applied
  • starts with the dual-layer dormant (overrides are baked into the base)
  • is fully isolated — mutations to the original do not bleed through

This is the recommended pattern for long-running processes (RoadRunner, Swoole, ReactPHP) that need per-request state isolation:

// Boot: load once, apply global overrides
$base = $loader->load(['app']);
$base->override('app.env', getenv('APP_ENV') ?: 'production');
$base->lockOverrides(); // sealed — no further global changes

// Per-request worker
$requestConfig = $base->snapshot();           // isolated copy, unlocked
$requestConfig->override('tenant.id', $tenantId);
$requestConfig->override('locale', $request->getLocale());
// $base is completely unaffected
// Snapshot with no overrides — efficiently clones the compiled base
$snap = $config->snapshot();
$snap->isDualLayerActive(); // false — starts fresh

✅ Schema Validation

Attach a validator to the Loader to enforce structural constraints before the Config object is returned:

use MonkeysLegion\Mlc\Validator\SchemaValidator;

$schema = [
    'app' => [
        'type'     => 'array',
        'children' => [
            'env'  => ['type' => 'string', 'enum' => ['dev', 'staging', 'production']],
            'port' => ['type' => 'int',    'min'  => 1024, 'max' => 65535],
            'name' => ['type' => 'string', 'pattern' => '/^[A-Za-z ]+$/'],
        ],
    ],
    'database' => [
        'type'     => 'array',
        'required' => true,
        'children' => [
            'host' => ['type' => 'string', 'required' => true],
            'port' => ['type' => 'int',    'required' => true],
        ],
    ],
];

$loader->setValidator(new SchemaValidator($schema));
$config = $loader->load(['app', 'database']); // throws LoaderException on failure

🚨 Security Features

MLC is designed to be secure by default:

FeatureDetail
Path traversal protectionFile paths containing .. are rejected before any read.
File existence validationrealpath() is used — symlinks are resolved and checked.
Permission auditingWorld-writable files trigger a E_USER_WARNING by default.
Strict modePass strictSecurity: true to Loader to throw SecurityException instead of warning.
File size limitFiles larger than 10 MB are rejected (SecurityException).
Circular reference detectionCross-key and env-var cycles throw CircularDependencyException.

// Enable strict security mode $env = new EnvManager(new DotenvLoader(), new NativeEnvRepository()); $parser = new MlcParser($env, DIR . '/config'); $loader = new Loader($parser, DIR . '/config', strictSecurity: true);


📂 Multi-Format Support

MLC supports loading and merging configuration from multiple file formats beyond the native .mlc syntax. This is achieved using the CompositeParser that delegates to specialized native parsers based on file extensions.

Supported Formats

FormatExtensionNotes
MLC.mlcFull support (includes, env vars, etc.)
JSON.jsonDecoded via json_decode.
YAML.yaml / .ymlNative lightweight parser.
PHP.phpExecuted files that return an array.

See the Multi-Format Support Documentation for a deep dive into using the CompositeParser.


🛠️ CLI Tooling (mlc-check)

MLC includes a native, standalone CLI validator for your configuration files. It is designed to be used in development and CI/CD pipelines to catch syntax errors or security risks before deployment.

Features

  • Zero Dependencies: Built with native PHP (no symfony/console).
  • Deep Validation: Checks syntax, circular references, and file permissions.
  • Recursive Scan: Validates all .mlc, .json, .yaml, and .php files in a directory.

Usage

# Validate a single file
php bin/mlc-check ./config/app.mlc

# Validate all supported files in a directory
php bin/mlc-check ./config

🪝 Event Hooks

The Loader emits events during its lifecycle, allowing you to hook into the loading process for logging, metrics, or custom post-processing. Use the dedicated proxy methods or the LoaderHook enum for type-safe registration.

Registering Listeners

Recommended way using proxy methods:

$loader->onLoading(function(array $names) {
    // Triggered before any files are loaded or cache is checked
});

$loader->onLoaded(function(Config $config) {
    // Triggered after configuration is fully merged and validated
});

$loader->onValidationError(function(array $errors, array $data) {
    // Triggered when validation fails (before the exception is thrown)
});

Alternative way using the LoaderHook enum:

use MonkeysLegion\Mlc\Enums\LoaderHook;

$loader->on(LoaderHook::Loading, function(array $names) { ... });

Hook Reference

HookArgumentsDescription
onLoading(array $names)Fired at the start of load().
onLoaded(Config $config)Fired right before load() returns.
onValidationError(array $errors, array $data)Fired when a validator returns errors.

📚 API Reference

Loader

MethodSignatureDescription
load(string[] $names, bool $useCache = true): ConfigLoad and merge named config files.
loadOne(string $name, bool $useCache = true): ConfigLoad a single config file.
reload(string[] $names): ConfigForce fresh parse, bypass cache.
compile(string[] $names): ConfigCompile to OPcache PHP file (requires CompiledPhpCache).
hasChanges(string[] $names): boolDetect if source files have changed since last cache write.
clearCache(): voidClear all cache entries.
setValidator(?ConfigValidatorInterface $v): selfAttach a schema validator.
on(LoaderHook $hook, callable $cb): selfRegister an event listener.
onLoading(callable $cb): selfProxy for LoaderHook::Loading.
onLoaded(callable $cb): selfProxy for LoaderHook::Loaded.
onValidationError(callable $cb): selfProxy for LoaderHook::ValidationError.

Config

Read

MethodReturnsDescription
get(path, default)mixedDot-notation lookup. Override layer checked first when active.
has(path)boolTrue if path exists in either layer.
getRequired(path)mixedThrows ConfigException if missing.
getString(path, default)?stringTyped getter.
getInt(path, default)?intTyped getter.
getFloat(path, default)?floatTyped getter.
getBool(path, default)?boolTyped getter.
getArray(path, default)?arrayTyped getter.

Export

MethodReturnsDescription
all()arrayCompiled base data (overrides excluded).
toArray()arrayAlias for all().
toJson(flags)stringJSON-encoded compiled base.
subset(prefix)ConfigNew Config scoped to a sub-section.
merge(Config)ConfigNew Config with another merged on top.

Dual-Layer Overrides

MethodReturnsDescription
override(path, value)voidApply a runtime override. Activates dual-layer on first call.
getOverrides()arrayMap of all current runtime overrides.
isDualLayerActive()boolTrue after first override() call.

Locks

MethodReturnsDescription
lock()selfLock 1: seal config — no overrides allowed.
lockOverrides()selfLock 2: seal override layer — no further overrides.
isLocked()boolTrue if lock() was called.
areOverridesLocked()boolTrue if lockOverrides() was called.

Snapshots

MethodReturnsDescription
snapshot()ConfigFlatten compiled base + overrides into a new, independent, unlocked Config.

Cache internals

MethodReturnsDescription
clearCache()voidPurge internal dot-path lookup cache.
getCacheStats()array{size: int, keys: string[]} — for debugging.

CompiledPhpCache

Implements PSR-16 CacheInterface. TTL is accepted by the interface but silently ignored — the cache is immutable until explicitly evicted.

MethodDescription
get(key, default)require the compiled PHP file; returns $default if file not found.
set(key, value, ttl)Write compiled PHP file (TTL ignored). Atomic write + OPcache invalidation.
delete(key)Delete compiled file and invalidate OPcache entry.
clear()Delete all *.generated.php files in the cache directory.
has(key)is_file() check — no extra require.
getMultiple / setMultiple / deleteMultiplePSR-16 bulk helpers.

🛠️ Testing & Quality

./vendor/bin/phpunit --testdox   # Run full test suite
composer stan                    # PHPStan Level 9
composer ci                      # Full CI pipeline