Mini.php
PHP
Path: src/Mini.php
<?php
// Important: Read MINI-STYLE.md before using Mini framework.
namespace mini;
use Closure;
use Fiber;
use mini\Util\InstanceStore;
use mini\Util\PathsRegistry;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use ReflectionClass;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionParameter;
use RuntimeException;
use WeakMap;
/**
* Core framework singleton that manages application configuration and service container.
*
* This class is instantiated once when Composer's autoloader loads vendor/fubber/mini/bootstrap.php.
* It provides:
* - Application configuration (root, locale, timezone, debug mode)
* - PSR-11 service container with Singleton, Scoped, and Transient lifetimes
* - Path registries for multi-location file resolution (config, routes, views)
* - Lifecycle phase management (Initializing → Bootstrap → Ready → Shutdown)
*
* Configuration is read from environment variables (MINI_* prefixed) or .env file in project root.
* All configuration happens during instantiation - the Mini instance is immutable after construction.
*
* @package mini
*/
final class Mini implements ContainerInterface {
/**
* Global singleton instance of Mini framework.
*
* Instantiated automatically when Composer loads vendor/fubber/mini/bootstrap.php.
* Access via Mini::$mini throughout your application.
*
* @var Mini
*/
public static ?Mini $mini = null;
/**
* Application root directory path.
*
* Detected automatically from Composer's vendor directory or set via MINI_ROOT environment variable.
* Typically the directory containing composer.json and vendor/.
*/
public readonly string $root;
/**
* Path registries for multi-location file resolution.
*
* Stores PathsRegistry instances for different resource types (config, routes, views).
* Each registry supports multiple search paths with priority ordering, allowing applications
* to override framework defaults and supporting plugin/bundle architectures.
*
* Example: $paths->config searches _config/ first, then vendor/fubber/mini/config/ as fallback.
*
* @var Mini\PathRegistries
*/
public readonly Mini\PathRegistries $paths;
/**
* Web-accessible document root directory path.
*
* Configured via MINI_DOC_ROOT environment variable or auto-detected from $_SERVER['DOCUMENT_ROOT'].
* Falls back to checking for html/ or public/ directories in project root. Can be null if not detected.
*/
public readonly ?string $docRoot;
/**
* Base URL for the application.
*
* Configured via MINI_BASE_URL environment variable or auto-detected from HTTP headers.
* Used for generating absolute URLs. Can be null if not configured or detected.
*/
public readonly ?string $baseUrl;
/**
* CDN base URL for static assets.
*
* Configured via MINI_CDN_URL environment variable. Falls back to baseUrl if not set.
* Used by url() function when $cdn parameter is true for serving static assets from CDN.
*/
public readonly ?string $cdnUrl;
/**
* Default locale for internationalization.
*
* Configured via MINI_LOCALE environment variable, falls back to php.ini intl.default_locale,
* or defaults to 'en_GB.UTF-8'. Used by I18n features and automatically sets PHP's \Locale::setDefault().
*/
public readonly string $locale;
/**
* Default timezone for date/time operations.
*
* Configured via MINI_TIMEZONE environment variable or uses PHP's default timezone.
* Automatically sets PHP's date_default_timezone_set() during bootstrap.
*/
public readonly string $timezone;
/**
* Database timezone for datetime storage (offset format, e.g., '+00:00').
*
* Configured via MINI_SQL_TIMEZONE or SQL_TIMEZONE environment variable, defaults to '+00:00' (UTC).
* PDO connections are configured to use this timezone. DateTime values from the database are
* interpreted in this timezone and converted to the application timezone for display.
*
* For SQL Server (which cannot set session timezone), Mini verifies the server's timezone
* matches this setting and throws RuntimeException if it doesn't.
*
* Use offset format ('+00:00', '+01:00', '-05:00') to avoid DST ambiguity issues.
*/
public readonly string $sqlTimezone;
/**
* Default language code for translations.
*
* Configured via MINI_LANG environment variable or defaults to 'en'.
* Used by the translation system to determine which language files to load.
*/
public readonly string $defaultLanguage;
/**
* Debug mode flag.
*
* Enabled when DEBUG environment variable is set to any non-empty value.
* When true, displays detailed error pages and stack traces. When false, shows generic error pages.
*/
public readonly bool $debug;
/**
* Application-wide cryptographic salt.
*
* Configured via MINI_SALT environment variable or generated from machine-specific fingerprint.
* Used for CSRF tokens and other cryptographic operations requiring a consistent salt.
*/
public readonly string $salt;
/**
* Caches instances using a scope object; each request will
* have a scope object which is garbage collected when the
* request ends.
*
* @var WeakMap<object, object>
*/
private readonly WeakMap $instanceCache;
/**
* Application lifecycle state machine
*
* Tracks transitions between Initializing → Bootstrap → Ready → Shutdown phases.
* The Ready phase handles all request processing (one or many concurrent requests).
*
* ## Phase Lifecycle Hooks
*
* Hook into lifecycle transitions using the StateMachine methods:
*
* ```php
* use mini\Mini;
* use mini\Phase;
*
* // Before Ready phase (before error handlers, output buffering)
* Mini::$mini->phase->onEnteringState(Phase::Ready, function() {
* // Authentication, CORS headers, rate limiting
* });
*
* // After Ready phase entered (after bootstrap completes)
* Mini::$mini->phase->onEnteredState(Phase::Ready, function() {
* // Output buffering, response processing
* });
* ```
*
* @var Hooks\StateMachine<Phase>
*/
public readonly Hooks\StateMachine $phase;
/**
* Service definitions (factory closures + lifetime)
*
* @var array<string, array{factory: Closure, lifetime: Lifetime}>
*/
private array $services = [];
public function __construct() {
if (self::$mini !== null) {
throw new RuntimeException("Can't have two Mini instances");
}
self::$mini = $this;
$this->instanceCache = new WeakMap();
// Initialize lifecycle state machine
// The phase tracks application state, not individual request state
// Ready phase can handle many concurrent requests
$this->phase = new Hooks\StateMachine([
[Phase::Initializing, Phase::Bootstrap, Phase::Failed], // Must bootstrap (or fail trying)
[Phase::Bootstrap, Phase::Ready, Phase::Failed], // Bootstrap completes or fails
[Phase::Ready, Phase::Shutdown], // Ready handles requests, eventually shuts down
[Phase::Failed, Phase::Shutdown], // Failed must shutdown
[Phase::Shutdown], // Terminal state
], 'application-lifecycle');
// Transition to Bootstrap phase and run initialization
$this->phase->trigger(Phase::Bootstrap);
$this->bootstrap();
}
/**
* An object that uniquely identifies the current request scope.
* This function is intended to be used for caching in WeakMap, instances
* that are request specific and should survive for the duration
* of the request only.
*
* Returns:
* - Current Fiber if in fiber context (Swerve, ReactPHP, RoadRunner)
* - $this if in traditional PHP-FPM request (after bootstrap() called)
*
* @return object
* @throws \LogicException If called in Bootstrap phase (before mini\bootstrap())
*/
public function getRequestScope(): object {
$fiber = Fiber::getCurrent();
if ($fiber !== null) {
return $fiber;
}
// Not in fiber - check if we're in Ready phase (request handling enabled)
if ($this->phase->getCurrentState() !== Phase::Ready) {
throw new \LogicException(
'Cannot get request scope outside of Ready phase. ' .
'Current phase: ' . $this->phase
);
}
return $this;
}
/**
* Load a configuration file using the path registry system.
*
* Searches for the config file in registered paths with priority ordering:
* 1. Application config (_config/ or MINI_CONFIG_ROOT)
* 2. Framework config (vendor/fubber/mini/config/)
*
* This allows applications to override framework defaults and supports plugin/bundle architectures
* where multiple packages can contribute config files.
*
* @param string $filename Relative path to config file (e.g., 'routes.php', 'PDO.php')
* @param mixed $default Return this if file not found (omit to throw exception)
* @return mixed The value returned by the config file (usually an object or array)
* @throws \Exception If file not found and no default provided
*/
public function loadConfig(string $filename, mixed $default = null): mixed {
// Get config path registry (throws if not initialized via __get)
$configPaths = $this->paths->config;
// Search for config file in registered paths (application first, then plugins)
$path = $configPaths->findFirst($filename);
if (!$path) {
if (func_num_args() === 1) {
$searchedPaths = implode(', ', $configPaths->getPaths());
throw new \Exception("Config file not found: $filename (searched in: $searchedPaths)");
}
return $default;
}
// Load and return
return Closure::fromCallable(static function() use ($path) {
return require $path;
})->bindTo(null, null)();
}
/**
* Load service configuration by class name using path registry.
*
* Converts class name to config file path by replacing namespace separators with directory separators:
* - PDO → '_config/PDO.php'
* - Psr\SimpleCache\CacheInterface → '_config/Psr/SimpleCache/CacheInterface.php'
* - mini\UUID\FactoryInterface → '_config/mini/UUID/FactoryInterface.php'
*
* Uses the path registry system, so application configs (_config/) take precedence over
* framework defaults (vendor/fubber/mini/config/).
*
* @param string $className Fully qualified class name (with or without leading backslash)
* @param mixed $default Return this if file not found (omit to throw exception)
* @return mixed The value returned by the config file (typically a service instance)
* @throws \Exception If file not found and no default provided
*/
public function loadServiceConfig(string $className, mixed $default = null): mixed {
$configPath = str_replace('\\', '/', ltrim($className, '\\')) . '.php';
return $this->loadConfig($configPath, ...array_slice(func_get_args(), 1));
}
/**
* Returns an object that will survive for the duration of the current
* request and on which instances can be cached.
*
* @return object
*/
private function getRequestScopeCache(): object {
$scope = $this->getRequestScope();
if (!isset($this->instanceCache[$scope])) {
$this->instanceCache[$scope] = new \stdClass();
}
return $this->instanceCache[$scope];
}
/**
* Register a service with the container
*
* Can only be called in Bootstrap phase (before mini\bootstrap()).
* The container is locked once request handling begins.
*
* @param string $id Service identifier (typically class name)
* @param Lifetime $lifetime Service lifetime (Singleton, Scoped, or Transient)
* @param Closure $factory Factory function that creates the service instance
* @throws \LogicException If called in Request phase or if service already registered
*/
public function addService(string $id, Lifetime $lifetime, Closure $factory): void {
if ($this->phase->getCurrentState() !== Phase::Bootstrap) {
throw new \LogicException(
"Cannot register services in Request phase. " .
"Services must be registered during application bootstrap (before calling mini\bootstrap()). " .
"Attempted to register: $id"
);
}
if (isset($this->services[$id])) {
throw new \LogicException("Service already registered: $id");
}
$this->services[$id] = ['factory' => $factory, 'lifetime' => $lifetime];
}
/**
* Set a service instance directly, bypassing factory creation.
*
* Can be used during bootstrap to register pre-instantiated services,
* or during Ready phase for testing (logs a warning).
*
* @param string $id Service identifier
* @param mixed $instance The instance to inject
* @throws \LogicException If service was already instantiated and cached
*/
public function set(string $id, mixed $instance): void {
$isTesting = ($_ENV['MINI_TESTING'] ?? $_SERVER['MINI_TESTING'] ?? false);
// Check if already instantiated in singleton cache
$singletonCache = $this->instanceCache[$this] ?? null;
if ($singletonCache !== null && property_exists($singletonCache, $id)) {
if (!$isTesting) {
throw new \LogicException("Cannot shadow service '$id': already instantiated");
}
// In testing mode, allow replacing - just update the cached instance
}
// Check scoped cache if in Ready phase (scope exists)
if ($this->phase->getCurrentState() === Phase::Ready) {
$scopeObject = $this->getRequestScope();
if ($scopeObject !== $this && $this->instanceCache->offsetExists($scopeObject)) {
$scopedCache = $this->instanceCache[$scopeObject];
$cacheKey = 'service:' . $id;
if (property_exists($scopedCache, $cacheKey)) {
if (!$isTesting) {
throw new \LogicException("Cannot shadow service '$id': already instantiated in current scope");
}
// In testing mode, clear scoped cache entry
unset($scopedCache->{$cacheKey});
}
}
if (!$isTesting) {
trigger_error("set('$id') called during Ready phase - intended for testing only", E_USER_WARNING);
}
}
// Store in singleton cache
if ($singletonCache === null) {
$singletonCache = new \stdClass();
$this->instanceCache[$this] = $singletonCache;
}
$singletonCache->{$id} = $instance;
// Register service definition if not exists
if (!isset($this->services[$id])) {
$this->services[$id] = ['factory' => fn() => $instance, 'lifetime' => Lifetime::Singleton];
}
}
/**
* Check if a service is registered in the container
*
* Returns false if the service was explicitly set to null via set($id, null),
* which allows tests to simulate "not configured" scenarios.
*
* @param string $id Service identifier
* @return bool True if service is registered and not null
*/
public function has(string $id): bool {
if (!array_key_exists($id, $this->services)) {
return false;
}
// Check if explicitly set to null in singleton cache
$singletonCache = $this->instanceCache[$this] ?? null;
if ($singletonCache !== null && property_exists($singletonCache, $id) && $singletonCache->{$id} === null) {
return false;
}
return true;
}
/**
* Get a service from the container
*
* Creates instances based on lifetime:
* - Singleton: One instance stored in instanceCache[$this]
* - Scoped: One instance per request stored in instanceCache[getRequestScope()]
* - Transient: New instance every time
*
* @template T
* @param class-string<T> $id Service identifier (typically a class or interface name)
* @return T The service instance
* @throws Exceptions\NotFoundException If service is not registered
*/
public function get(string $id): mixed {
if (!array_key_exists($id, $this->services)) {
throw new Exceptions\NotFoundException("Service not found: $id");
}
$service = $this->services[$id];
$factory = $service['factory'];
$lifetime = $service['lifetime'];
// Transient: Always create new instance
if ($lifetime === Lifetime::Transient) {
return $factory();
}
// Singleton: Store in instanceCache[$this]
if ($lifetime === Lifetime::Singleton) {
$singletonCache = $this->instanceCache[$this] ?? null;
if ($singletonCache === null) {
$singletonCache = new \stdClass();
$this->instanceCache[$this] = $singletonCache;
}
if (!property_exists($singletonCache, $id)) {
$singletonCache->{$id} = $factory();
}
// Treat null as "not registered" (allows tests to simulate missing services)
if ($singletonCache->{$id} === null) {
throw new Exceptions\NotFoundException("Service not found: $id");
}
return $singletonCache->{$id};
}
// Scoped: Store in instanceCache[getRequestScope()]
if ($lifetime === Lifetime::Scoped) {
if ($this->phase->getCurrentState() !== Phase::Ready) {
throw new \LogicException(
"Cannot access Scoped service '$id' outside of Ready phase. " .
"Scoped services can only be accessed after calling mini\\bootstrap(). " .
"Current phase: " . $this->phase
);
}
$scopedCache = $this->getRequestScopeCache();
$cacheKey = 'service:' . $id;
if (!property_exists($scopedCache, $cacheKey)) {
$scopedCache->{$cacheKey} = $factory();
}
return $scopedCache->{$cacheKey};
}
// Should never reach here
throw new \LogicException("Unknown lifetime: " . $lifetime->name);
}
/**
* Create a closure that invokes a callable or constructs a class with dependency injection.
*
* Returns a closure that, when called, resolves dependencies and invokes the target:
* - For class-string: constructs the class via its constructor
* - For callable: invokes the callable directly
*
* Parameter resolution priority:
* 1. Named arguments from $namedArguments (trusted as correct type)
* 2. Service from container if registered for the parameter's type
* 3. DependencyInjectionException if neither is available
*
* @template T of object
* @param class-string<T>|callable $target Class name to construct or callable to invoke
* @param array<string, mixed> $namedArguments Named arguments to inject (variadic params require array values)
* @return ($target is class-string<T> ? Closure(): T : Closure(): mixed)
* @throws Exceptions\DependencyInjectionException If a required dependency cannot be resolved
*
* @example Constructor injection
* ```php
* $factory = Mini::$mini->inject(MyService::class, ['config' => $myConfig]);
* $service = $factory(); // Constructs MyService with injected dependencies
* ```
*
* @example Method injection
* ```php
* $invoker = Mini::$mini->inject([$migration, 'up']);
* $invoker(); // Calls $migration->up() with injected dependencies
* ```
*
* @example Closure injection
* ```php
* $invoker = Mini::$mini->inject(function(PDO $db, Logger $log) { ... });
* $invoker(); // Calls closure with PDO and Logger from container
* ```
*/
public function inject(string|callable $target, array $namedArguments = []): Closure {
// Determine reflection source
if (is_string($target) && class_exists($target)) {
$reflectionClass = new ReflectionClass($target);
$constructor = $reflectionClass->getConstructor();
$parameters = $constructor ? $constructor->getParameters() : [];
$isConstructor = true;
} elseif (is_array($target) && count($target) === 2) {
$reflectionMethod = new ReflectionMethod($target[0], $target[1]);
$parameters = $reflectionMethod->getParameters();
$isConstructor = false;
} elseif ($target instanceof Closure || is_callable($target)) {
$reflectionFunction = new ReflectionFunction(Closure::fromCallable($target));
$parameters = $reflectionFunction->getParameters();
$isConstructor = false;
} else {
throw new Exceptions\DependencyInjectionException(
"Target must be a class-string or callable, got: " . gettype($target)
);
}
// Build arguments array
$args = $this->resolveParameters($parameters, $namedArguments);
// Return closure that constructs/invokes with resolved args
if ($isConstructor) {
return fn() => new $target(...$args);
} else {
return fn() => $target(...$args);
}
}
/**
* Resolve parameters for dependency injection.
*
* @param ReflectionParameter[] $parameters
* @param array<string, mixed> $namedArguments
* @return array<int, mixed>
* @throws Exceptions\DependencyInjectionException
*/
private function resolveParameters(array $parameters, array $namedArguments): array {
$args = [];
foreach ($parameters as $param) {
$name = $param->getName();
$type = $param->getType();
// Priority 1: Named argument provided
if (array_key_exists($name, $namedArguments)) {
$value = $namedArguments[$name];
// For variadic parameters, the value must be an array
if ($param->isVariadic()) {
if (!is_array($value)) {
throw new Exceptions\DependencyInjectionException(
"Variadic parameter '\${$name}' requires array value, got: " . gettype($value)
);
}
// Spread variadic arguments
foreach ($value as $v) {
$args[] = $v;
}
} else {
$args[] = $value;
}
continue;
}
// Variadic without named argument: skip (empty spread)
if ($param->isVariadic()) {
continue;
}
// Priority 2: Service resolution by type
if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
$typeName = $type->getName();
if ($this->has($typeName)) {
$args[] = $this->get($typeName);
continue;
}
}
// Priority 3: Default value if available
if ($param->isDefaultValueAvailable()) {
$args[] = $param->getDefaultValue();
continue;
}
// Priority 4: Nullable type gets null
if ($type !== null && $type->allowsNull()) {
$args[] = null;
continue;
}
// Cannot resolve - throw
$typeHint = $type ? (string)$type : 'mixed';
throw new Exceptions\DependencyInjectionException(
"Cannot resolve parameter '\${$name}' ({$typeHint}): " .
"no named argument provided and no service registered for type '{$typeHint}'"
);
}
return $args;
}
/**
* Detect the project root directory.
*
* Priority:
* 1. MINI_ROOT environment variable (explicit override)
* 2. CWD if it contains composer.json with name "fubber/mini" (dev mode)
* 3. ClassLoader reflection (standard: 3 levels up from vendor/composer/)
*/
private function detectProjectRoot(): string {
// 1. Explicit environment variable takes precedence
$envRoot = getenv('MINI_ROOT');
if ($envRoot !== false && $envRoot !== '') {
return $envRoot;
}
// 2. Check if CWD is fubber/mini itself (development mode)
$cwd = getcwd();
if ($cwd !== false) {
$composerJson = $cwd . '/composer.json';
if (is_readable($composerJson)) {
$composer = json_decode(file_get_contents($composerJson), true);
if (($composer['name'] ?? '') === 'fubber/mini') {
return $cwd;
}
}
}
// 3. Standard detection via ClassLoader location
return \dirname((new ReflectionClass(\Composer\Autoload\ClassLoader::class))->getFileName(), 3);
}
private function bootstrap(): void {
$this->root = $this->detectProjectRoot();
if (is_readable($this->root . '/.env')) {
(static function(string $path): void {
$re = '/^\s*(?!\#)(?<k>[^\s=]+)\s*=\s*+(?<v>("(\\\\.|[^"\\\\]|"")*+"|\'(\\\\.|[^\'\\\\]|\'\')*+\'|[^\r\n#]*))\s*(\#[^\r\n]*)?(\R|$)/mu';
if (preg_match_all($re, file_get_contents($path), $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$k = $m['k'];
// Don't overwrite existing env vars (match Symfony behavior)
if (isset($_ENV[$k])) {
continue;
}
$v = trim($m['v']);
// Strip quotes and process escapes
if (($v[0] ?? '') === '"' && str_ends_with($v, '"')) {
$v = substr($v, 1, -1);
$v = str_replace(['\\\\', '\\"', '\\n', '\\r', '""'], ['\\', '"', "\n", "\r", '"'], $v);
} elseif (($v[0] ?? '') === "'" && str_ends_with($v, "'")) {
$v = substr($v, 1, -1);
$v = str_replace("''", "'", $v);
}
$_ENV[$k] = $v;
if (!str_starts_with($k, 'HTTP_')) {
$_SERVER[$k] = $v;
}
putenv("$k=$v");
}
}
})($this->root . '/.env');
}
// Initialize paths registries directly (too fundamental to be a configurable service)
$this->paths = new Mini\PathRegistries();
// Register as singleton service for consistency and future DI
$this->addService(Mini\PathRegistries::class, Lifetime::Singleton, fn() => $this->paths);
// Config registry: application config first, framework config as fallback
$primaryConfigPath = $_ENV['MINI_CONFIG_ROOT'] ?? ($this->root . '/_config');
$this->paths->config = new Util\PathsRegistry($primaryConfigPath);
$frameworkConfigPath = \dirname((new \ReflectionClass(self::class))->getFileName(), 2) . '/config';
$this->paths->config->addPath($frameworkConfigPath);
$this->debug = !empty($_ENV['DEBUG']);
$docRoot = $_ENV['MINI_DOC_ROOT'] ?? null;
if (!$docRoot && isset($_SERVER['DOCUMENT_ROOT']) && is_dir($_SERVER['DOCUMENT_ROOT'])) {
$docRoot = $_SERVER['DOCUMENT_ROOT'];
}
if (!$docRoot && is_dir($this->root . '/html')) {
$docRoot = $this->root . '/html';
}
if (!$docRoot && is_dir($this->root . '/public')) {
$docRoot = $this->root . '/public';
}
$this->docRoot = $docRoot;
$baseUrl = $_ENV['MINI_BASE_URL'] ?? null;
if ($baseUrl === null && PHP_SAPI !== 'cli' && isset($_SERVER['HTTP_HOST'])) {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
$scriptDir = dirname($scriptName);
$basePath = ($scriptDir !== '/' && $scriptDir !== '\\') ? $scriptDir : '';
$baseUrl = $scheme . '://' . $host . $basePath;
}
$this->baseUrl = $baseUrl;
// CDN base URL for static assets (falls back to baseUrl if not configured)
$this->cdnUrl = $_ENV['MINI_CDN_URL'] ?? $baseUrl;
// Application default locale (respects php.ini, can override with MINI_LOCALE)
$locale = $_ENV['MINI_LOCALE'] ?? \ini_get('intl.default_locale') ?: 'en_GB.UTF-8';
$this->locale = \Locale::canonicalize($locale);
\Locale::setDefault($this->locale);
// Application default timezone (respects PHP default, can override with MINI_TIMEZONE)
$this->timezone = $_ENV['MINI_TIMEZONE'] ?? \date_default_timezone_get();
\date_default_timezone_set($this->timezone);
// Database timezone in offset format (MINI_SQL_TIMEZONE takes precedence over SQL_TIMEZONE)
$this->sqlTimezone = $_ENV['MINI_SQL_TIMEZONE'] ?? $_ENV['SQL_TIMEZONE'] ?? '+00:00';
// Application default language for translations (can override with MINI_LANG)
$this->defaultLanguage = $_ENV['MINI_LANG'] ?? 'en';
// Application salt for cryptographic operations (CSRF tokens, etc.)
// Uses machine-specific fingerprint + persistent random salt if MINI_SALT not set
$this->salt = $_ENV['MINI_SALT'] ?? Util\MachineSalt::get();
}
}