src/Mini.php source

1 <?php
2 // Important: Read MINI-STYLE.md before using Mini framework.
3
4 namespace mini;
5
6 use Closure;
7 use Fiber;
8 use mini\Util\InstanceStore;
9 use mini\Util\PathsRegistry;
10 use Psr\Container\ContainerInterface;
11 use Psr\Container\NotFoundExceptionInterface;
12 use ReflectionClass;
13 use ReflectionFunction;
14 use ReflectionMethod;
15 use ReflectionParameter;
16 use RuntimeException;
17 use WeakMap;
18
19 /**
20 * Core framework singleton that manages application configuration and service container.
21 *
22 * This class is instantiated once when Composer's autoloader loads vendor/fubber/mini/bootstrap.php.
23 * It provides:
24 * - Application configuration (root, locale, timezone, debug mode)
25 * - PSR-11 service container with Singleton, Scoped, and Transient lifetimes
26 * - Path registries for multi-location file resolution (config, routes, views)
27 * - Lifecycle phase management (Initializing → Bootstrap → Ready → Shutdown)
28 *
29 * Configuration is read from environment variables (MINI_* prefixed) or .env file in project root.
30 * All configuration happens during instantiation - the Mini instance is immutable after construction.
31 *
32 * @package mini
33 */
34 final class Mini implements ContainerInterface {
35 /**
36 * Global singleton instance of Mini framework.
37 *
38 * Instantiated automatically when Composer loads vendor/fubber/mini/bootstrap.php.
39 * Access via Mini::$mini throughout your application.
40 *
41 * @var Mini
42 */
43 public static ?Mini $mini = null;
44
45 /**
46 * Application root directory path.
47 *
48 * Detected automatically from Composer's vendor directory or set via MINI_ROOT environment variable.
49 * Typically the directory containing composer.json and vendor/.
50 */
51 public readonly string $root;
52
53 /**
54 * Path registries for multi-location file resolution.
55 *
56 * Stores PathsRegistry instances for different resource types (config, routes, views).
57 * Each registry supports multiple search paths with priority ordering, allowing applications
58 * to override framework defaults and supporting plugin/bundle architectures.
59 *
60 * Example: $paths->config searches _config/ first, then vendor/fubber/mini/config/ as fallback.
61 *
62 * @var Mini\PathRegistries
63 */
64 public readonly Mini\PathRegistries $paths;
65
66 /**
67 * Web-accessible document root directory path.
68 *
69 * Configured via MINI_DOC_ROOT environment variable or auto-detected from $_SERVER['DOCUMENT_ROOT'].
70 * Falls back to checking for html/ or public/ directories in project root. Can be null if not detected.
71 */
72 public readonly ?string $docRoot;
73
74 /**
75 * Base URL for the application.
76 *
77 * Configured via MINI_BASE_URL environment variable or auto-detected from HTTP headers.
78 * Used for generating absolute URLs. Can be null if not configured or detected.
79 */
80 public readonly ?string $baseUrl;
81
82 /**
83 * CDN base URL for static assets.
84 *
85 * Configured via MINI_CDN_URL environment variable. Falls back to baseUrl if not set.
86 * Used by url() function when $cdn parameter is true for serving static assets from CDN.
87 */
88 public readonly ?string $cdnUrl;
89
90 /**
91 * Default locale for internationalization.
92 *
93 * Configured via MINI_LOCALE environment variable, falls back to php.ini intl.default_locale,
94 * or defaults to 'en_GB.UTF-8'. Used by I18n features and automatically sets PHP's \Locale::setDefault().
95 */
96 public readonly string $locale;
97
98 /**
99 * Default timezone for date/time operations.
100 *
101 * Configured via MINI_TIMEZONE environment variable or uses PHP's default timezone.
102 * Automatically sets PHP's date_default_timezone_set() during bootstrap.
103 */
104 public readonly string $timezone;
105
106 /**
107 * Database timezone for datetime storage (offset format, e.g., '+00:00').
108 *
109 * Configured via MINI_SQL_TIMEZONE or SQL_TIMEZONE environment variable, defaults to '+00:00' (UTC).
110 * PDO connections are configured to use this timezone. DateTime values from the database are
111 * interpreted in this timezone and converted to the application timezone for display.
112 *
113 * For SQL Server (which cannot set session timezone), Mini verifies the server's timezone
114 * matches this setting and throws RuntimeException if it doesn't.
115 *
116 * Use offset format ('+00:00', '+01:00', '-05:00') to avoid DST ambiguity issues.
117 */
118 public readonly string $sqlTimezone;
119
120 /**
121 * Default language code for translations.
122 *
123 * Configured via MINI_LANG environment variable or defaults to 'en'.
124 * Used by the translation system to determine which language files to load.
125 */
126 public readonly string $defaultLanguage;
127
128 /**
129 * Debug mode flag.
130 *
131 * Enabled when DEBUG environment variable is set to any non-empty value.
132 * When true, displays detailed error pages and stack traces. When false, shows generic error pages.
133 */
134 public readonly bool $debug;
135
136 /**
137 * Application-wide cryptographic salt.
138 *
139 * Configured via MINI_SALT environment variable or generated from machine-specific fingerprint.
140 * Used for CSRF tokens and other cryptographic operations requiring a consistent salt.
141 */
142 public readonly string $salt;
143
144 /**
145 * Caches instances using a scope object; each request will
146 * have a scope object which is garbage collected when the
147 * request ends.
148 *
149 * @var WeakMap<object, object>
150 */
151 private readonly WeakMap $instanceCache;
152
153 /**
154 * Application lifecycle state machine
155 *
156 * Tracks transitions between Initializing → Bootstrap → Ready → Shutdown phases.
157 * The Ready phase handles all request processing (one or many concurrent requests).
158 *
159 * ## Phase Lifecycle Hooks
160 *
161 * Hook into lifecycle transitions using the StateMachine methods:
162 *
163 * ```php
164 * use mini\Mini;
165 * use mini\Phase;
166 *
167 * // Before Ready phase (before error handlers, output buffering)
168 * Mini::$mini->phase->onEnteringState(Phase::Ready, function() {
169 * // Authentication, CORS headers, rate limiting
170 * });
171 *
172 * // After Ready phase entered (after bootstrap completes)
173 * Mini::$mini->phase->onEnteredState(Phase::Ready, function() {
174 * // Output buffering, response processing
175 * });
176 * ```
177 *
178 * @var Hooks\StateMachine<Phase>
179 */
180 public readonly Hooks\StateMachine $phase;
181
182 /**
183 * Service definitions (factory closures + lifetime)
184 *
185 * @var array<string, array{factory: Closure, lifetime: Lifetime}>
186 */
187 private array $services = [];
188
189 public function __construct() {
190 if (self::$mini !== null) {
191 throw new RuntimeException("Can't have two Mini instances");
192 }
193 self::$mini = $this;
194 $this->instanceCache = new WeakMap();
195
196 // Initialize lifecycle state machine
197 // The phase tracks application state, not individual request state
198 // Ready phase can handle many concurrent requests
199 $this->phase = new Hooks\StateMachine([
200 [Phase::Initializing, Phase::Bootstrap, Phase::Failed], // Must bootstrap (or fail trying)
201 [Phase::Bootstrap, Phase::Ready, Phase::Failed], // Bootstrap completes or fails
202 [Phase::Ready, Phase::Shutdown], // Ready handles requests, eventually shuts down
203 [Phase::Failed, Phase::Shutdown], // Failed must shutdown
204 [Phase::Shutdown], // Terminal state
205 ], 'application-lifecycle');
206
207 // Transition to Bootstrap phase and run initialization
208 $this->phase->trigger(Phase::Bootstrap);
209 $this->bootstrap();
210 }
211
212 /**
213 * An object that uniquely identifies the current request scope.
214 * This function is intended to be used for caching in WeakMap, instances
215 * that are request specific and should survive for the duration
216 * of the request only.
217 *
218 * Returns:
219 * - Current Fiber if in fiber context (Swerve, ReactPHP, RoadRunner)
220 * - $this if in traditional PHP-FPM request (after bootstrap() called)
221 *
222 * @return object
223 * @throws \LogicException If called in Bootstrap phase (before mini\bootstrap())
224 */
225 public function getRequestScope(): object {
226 $fiber = Fiber::getCurrent();
227 if ($fiber !== null) {
228 return $fiber;
229 }
230
231 // Not in fiber - check if we're in Ready phase (request handling enabled)
232 if ($this->phase->getCurrentState() !== Phase::Ready) {
233 throw new \LogicException(
234 'Cannot get request scope outside of Ready phase. ' .
235 'Current phase: ' . $this->phase
236 );
237 }
238
239 return $this;
240 }
241
242 /**
243 * Load a configuration file using the path registry system.
244 *
245 * Searches for the config file in registered paths with priority ordering:
246 * 1. Application config (_config/ or MINI_CONFIG_ROOT)
247 * 2. Framework config (vendor/fubber/mini/config/)
248 *
249 * This allows applications to override framework defaults and supports plugin/bundle architectures
250 * where multiple packages can contribute config files.
251 *
252 * @param string $filename Relative path to config file (e.g., 'routes.php', 'PDO.php')
253 * @param mixed $default Return this if file not found (omit to throw exception)
254 * @return mixed The value returned by the config file (usually an object or array)
255 * @throws \Exception If file not found and no default provided
256 */
257 public function loadConfig(string $filename, mixed $default = null): mixed {
258 // Get config path registry (throws if not initialized via __get)
259 $configPaths = $this->paths->config;
260
261 // Search for config file in registered paths (application first, then plugins)
262 $path = $configPaths->findFirst($filename);
263
264 if (!$path) {
265 if (func_num_args() === 1) {
266 $searchedPaths = implode(', ', $configPaths->getPaths());
267 throw new \Exception("Config file not found: $filename (searched in: $searchedPaths)");
268 }
269 return $default;
270 }
271
272 // Load and return
273 return Closure::fromCallable(static function() use ($path) {
274 return require $path;
275 })->bindTo(null, null)();
276 }
277
278 /**
279 * Load service configuration by class name using path registry.
280 *
281 * Converts class name to config file path by replacing namespace separators with directory separators:
282 * - PDO → '_config/PDO.php'
283 * - Psr\SimpleCache\CacheInterface → '_config/Psr/SimpleCache/CacheInterface.php'
284 * - mini\UUID\FactoryInterface → '_config/mini/UUID/FactoryInterface.php'
285 *
286 * Uses the path registry system, so application configs (_config/) take precedence over
287 * framework defaults (vendor/fubber/mini/config/).
288 *
289 * @param string $className Fully qualified class name (with or without leading backslash)
290 * @param mixed $default Return this if file not found (omit to throw exception)
291 * @return mixed The value returned by the config file (typically a service instance)
292 * @throws \Exception If file not found and no default provided
293 */
294 public function loadServiceConfig(string $className, mixed $default = null): mixed {
295 $configPath = str_replace('\\', '/', ltrim($className, '\\')) . '.php';
296 return $this->loadConfig($configPath, ...array_slice(func_get_args(), 1));
297 }
298
299 /**
300 * Returns an object that will survive for the duration of the current
301 * request and on which instances can be cached.
302 *
303 * @return object
304 */
305 private function getRequestScopeCache(): object {
306 $scope = $this->getRequestScope();
307 if (!isset($this->instanceCache[$scope])) {
308 $this->instanceCache[$scope] = new \stdClass();
309 }
310 return $this->instanceCache[$scope];
311 }
312
313 /**
314 * Register a service with the container
315 *
316 * Can only be called in Bootstrap phase (before mini\bootstrap()).
317 * The container is locked once request handling begins.
318 *
319 * @param string $id Service identifier (typically class name)
320 * @param Lifetime $lifetime Service lifetime (Singleton, Scoped, or Transient)
321 * @param Closure $factory Factory function that creates the service instance
322 * @throws \LogicException If called in Request phase or if service already registered
323 */
324 public function addService(string $id, Lifetime $lifetime, Closure $factory): void {
325 if ($this->phase->getCurrentState() !== Phase::Bootstrap) {
326 throw new \LogicException(
327 "Cannot register services in Request phase. " .
328 "Services must be registered during application bootstrap (before calling mini\bootstrap()). " .
329 "Attempted to register: $id"
330 );
331 }
332
333 if (isset($this->services[$id])) {
334 throw new \LogicException("Service already registered: $id");
335 }
336
337 $this->services[$id] = ['factory' => $factory, 'lifetime' => $lifetime];
338 }
339
340 /**
341 * Set a service instance directly, bypassing factory creation.
342 *
343 * Can be used during bootstrap to register pre-instantiated services,
344 * or during Ready phase for testing (logs a warning).
345 *
346 * @param string $id Service identifier
347 * @param mixed $instance The instance to inject
348 * @throws \LogicException If service was already instantiated and cached
349 */
350 public function set(string $id, mixed $instance): void {
351 $isTesting = ($_ENV['MINI_TESTING'] ?? $_SERVER['MINI_TESTING'] ?? false);
352
353 // Check if already instantiated in singleton cache
354 $singletonCache = $this->instanceCache[$this] ?? null;
355 if ($singletonCache !== null && property_exists($singletonCache, $id)) {
356 if (!$isTesting) {
357 throw new \LogicException("Cannot shadow service '$id': already instantiated");
358 }
359 // In testing mode, allow replacing - just update the cached instance
360 }
361
362 // Check scoped cache if in Ready phase (scope exists)
363 if ($this->phase->getCurrentState() === Phase::Ready) {
364 $scopeObject = $this->getRequestScope();
365 if ($scopeObject !== $this && $this->instanceCache->offsetExists($scopeObject)) {
366 $scopedCache = $this->instanceCache[$scopeObject];
367 $cacheKey = 'service:' . $id;
368 if (property_exists($scopedCache, $cacheKey)) {
369 if (!$isTesting) {
370 throw new \LogicException("Cannot shadow service '$id': already instantiated in current scope");
371 }
372 // In testing mode, clear scoped cache entry
373 unset($scopedCache->{$cacheKey});
374 }
375 }
376
377 if (!$isTesting) {
378 trigger_error("set('$id') called during Ready phase - intended for testing only", E_USER_WARNING);
379 }
380 }
381
382 // Store in singleton cache
383 if ($singletonCache === null) {
384 $singletonCache = new \stdClass();
385 $this->instanceCache[$this] = $singletonCache;
386 }
387 $singletonCache->{$id} = $instance;
388
389 // Register service definition if not exists
390 if (!isset($this->services[$id])) {
391 $this->services[$id] = ['factory' => fn() => $instance, 'lifetime' => Lifetime::Singleton];
392 }
393 }
394
395 /**
396 * Check if a service is registered in the container
397 *
398 * Returns false if the service was explicitly set to null via set($id, null),
399 * which allows tests to simulate "not configured" scenarios.
400 *
401 * @param string $id Service identifier
402 * @return bool True if service is registered and not null
403 */
404 public function has(string $id): bool {
405 if (!array_key_exists($id, $this->services)) {
406 return false;
407 }
408 // Check if explicitly set to null in singleton cache
409 $singletonCache = $this->instanceCache[$this] ?? null;
410 if ($singletonCache !== null && property_exists($singletonCache, $id) && $singletonCache->{$id} === null) {
411 return false;
412 }
413 return true;
414 }
415
416 /**
417 * Get a service from the container
418 *
419 * Creates instances based on lifetime:
420 * - Singleton: One instance stored in instanceCache[$this]
421 * - Scoped: One instance per request stored in instanceCache[getRequestScope()]
422 * - Transient: New instance every time
423 *
424 * @template T
425 * @param class-string<T> $id Service identifier (typically a class or interface name)
426 * @return T The service instance
427 * @throws Exceptions\NotFoundException If service is not registered
428 */
429 public function get(string $id): mixed {
430 if (!array_key_exists($id, $this->services)) {
431 throw new Exceptions\NotFoundException("Service not found: $id");
432 }
433
434 $service = $this->services[$id];
435 $factory = $service['factory'];
436 $lifetime = $service['lifetime'];
437
438 // Transient: Always create new instance
439 if ($lifetime === Lifetime::Transient) {
440 return $factory();
441 }
442
443 // Singleton: Store in instanceCache[$this]
444 if ($lifetime === Lifetime::Singleton) {
445 $singletonCache = $this->instanceCache[$this] ?? null;
446 if ($singletonCache === null) {
447 $singletonCache = new \stdClass();
448 $this->instanceCache[$this] = $singletonCache;
449 }
450
451 if (!property_exists($singletonCache, $id)) {
452 $singletonCache->{$id} = $factory();
453 }
454
455 // Treat null as "not registered" (allows tests to simulate missing services)
456 if ($singletonCache->{$id} === null) {
457 throw new Exceptions\NotFoundException("Service not found: $id");
458 }
459
460 return $singletonCache->{$id};
461 }
462
463 // Scoped: Store in instanceCache[getRequestScope()]
464 if ($lifetime === Lifetime::Scoped) {
465 if ($this->phase->getCurrentState() !== Phase::Ready) {
466 throw new \LogicException(
467 "Cannot access Scoped service '$id' outside of Ready phase. " .
468 "Scoped services can only be accessed after calling mini\\bootstrap(). " .
469 "Current phase: " . $this->phase
470 );
471 }
472 $scopedCache = $this->getRequestScopeCache();
473 $cacheKey = 'service:' . $id;
474
475 if (!property_exists($scopedCache, $cacheKey)) {
476 $scopedCache->{$cacheKey} = $factory();
477 }
478
479 return $scopedCache->{$cacheKey};
480 }
481
482 // Should never reach here
483 throw new \LogicException("Unknown lifetime: " . $lifetime->name);
484 }
485
486 /**
487 * Create a closure that invokes a callable or constructs a class with dependency injection.
488 *
489 * Returns a closure that, when called, resolves dependencies and invokes the target:
490 * - For class-string: constructs the class via its constructor
491 * - For callable: invokes the callable directly
492 *
493 * Parameter resolution priority:
494 * 1. Named arguments from $namedArguments (trusted as correct type)
495 * 2. Service from container if registered for the parameter's type
496 * 3. DependencyInjectionException if neither is available
497 *
498 * @template T of object
499 * @param class-string<T>|callable $target Class name to construct or callable to invoke
500 * @param array<string, mixed> $namedArguments Named arguments to inject (variadic params require array values)
501 * @return ($target is class-string<T> ? Closure(): T : Closure(): mixed)
502 * @throws Exceptions\DependencyInjectionException If a required dependency cannot be resolved
503 *
504 * @example Constructor injection
505 * ```php
506 * $factory = Mini::$mini->inject(MyService::class, ['config' => $myConfig]);
507 * $service = $factory(); // Constructs MyService with injected dependencies
508 * ```
509 *
510 * @example Method injection
511 * ```php
512 * $invoker = Mini::$mini->inject([$migration, 'up']);
513 * $invoker(); // Calls $migration->up() with injected dependencies
514 * ```
515 *
516 * @example Closure injection
517 * ```php
518 * $invoker = Mini::$mini->inject(function(PDO $db, Logger $log) { ... });
519 * $invoker(); // Calls closure with PDO and Logger from container
520 * ```
521 */
522 public function inject(string|callable $target, array $namedArguments = []): Closure {
523 // Determine reflection source
524 if (is_string($target) && class_exists($target)) {
525 $reflectionClass = new ReflectionClass($target);
526 $constructor = $reflectionClass->getConstructor();
527 $parameters = $constructor ? $constructor->getParameters() : [];
528 $isConstructor = true;
529 } elseif (is_array($target) && count($target) === 2) {
530 $reflectionMethod = new ReflectionMethod($target[0], $target[1]);
531 $parameters = $reflectionMethod->getParameters();
532 $isConstructor = false;
533 } elseif ($target instanceof Closure || is_callable($target)) {
534 $reflectionFunction = new ReflectionFunction(Closure::fromCallable($target));
535 $parameters = $reflectionFunction->getParameters();
536 $isConstructor = false;
537 } else {
538 throw new Exceptions\DependencyInjectionException(
539 "Target must be a class-string or callable, got: " . gettype($target)
540 );
541 }
542
543 // Build arguments array
544 $args = $this->resolveParameters($parameters, $namedArguments);
545
546 // Return closure that constructs/invokes with resolved args
547 if ($isConstructor) {
548 return fn() => new $target(...$args);
549 } else {
550 return fn() => $target(...$args);
551 }
552 }
553
554 /**
555 * Resolve parameters for dependency injection.
556 *
557 * @param ReflectionParameter[] $parameters
558 * @param array<string, mixed> $namedArguments
559 * @return array<int, mixed>
560 * @throws Exceptions\DependencyInjectionException
561 */
562 private function resolveParameters(array $parameters, array $namedArguments): array {
563 $args = [];
564
565 foreach ($parameters as $param) {
566 $name = $param->getName();
567 $type = $param->getType();
568
569 // Priority 1: Named argument provided
570 if (array_key_exists($name, $namedArguments)) {
571 $value = $namedArguments[$name];
572
573 // For variadic parameters, the value must be an array
574 if ($param->isVariadic()) {
575 if (!is_array($value)) {
576 throw new Exceptions\DependencyInjectionException(
577 "Variadic parameter '\${$name}' requires array value, got: " . gettype($value)
578 );
579 }
580 // Spread variadic arguments
581 foreach ($value as $v) {
582 $args[] = $v;
583 }
584 } else {
585 $args[] = $value;
586 }
587 continue;
588 }
589
590 // Variadic without named argument: skip (empty spread)
591 if ($param->isVariadic()) {
592 continue;
593 }
594
595 // Priority 2: Service resolution by type
596 if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
597 $typeName = $type->getName();
598 if ($this->has($typeName)) {
599 $args[] = $this->get($typeName);
600 continue;
601 }
602 }
603
604 // Priority 3: Default value if available
605 if ($param->isDefaultValueAvailable()) {
606 $args[] = $param->getDefaultValue();
607 continue;
608 }
609
610 // Priority 4: Nullable type gets null
611 if ($type !== null && $type->allowsNull()) {
612 $args[] = null;
613 continue;
614 }
615
616 // Cannot resolve - throw
617 $typeHint = $type ? (string)$type : 'mixed';
618 throw new Exceptions\DependencyInjectionException(
619 "Cannot resolve parameter '\${$name}' ({$typeHint}): " .
620 "no named argument provided and no service registered for type '{$typeHint}'"
621 );
622 }
623
624 return $args;
625 }
626
627 /**
628 * Detect the project root directory.
629 *
630 * Priority:
631 * 1. MINI_ROOT environment variable (explicit override)
632 * 2. CWD if it contains composer.json with name "fubber/mini" (dev mode)
633 * 3. ClassLoader reflection (standard: 3 levels up from vendor/composer/)
634 */
635 private function detectProjectRoot(): string {
636 // 1. Explicit environment variable takes precedence
637 $envRoot = getenv('MINI_ROOT');
638 if ($envRoot !== false && $envRoot !== '') {
639 return $envRoot;
640 }
641
642 // 2. Check if CWD is fubber/mini itself (development mode)
643 $cwd = getcwd();
644 if ($cwd !== false) {
645 $composerJson = $cwd . '/composer.json';
646 if (is_readable($composerJson)) {
647 $composer = json_decode(file_get_contents($composerJson), true);
648 if (($composer['name'] ?? '') === 'fubber/mini') {
649 return $cwd;
650 }
651 }
652 }
653
654 // 3. Standard detection via ClassLoader location
655 return \dirname((new ReflectionClass(\Composer\Autoload\ClassLoader::class))->getFileName(), 3);
656 }
657
658 private function bootstrap(): void {
659 $this->root = $this->detectProjectRoot();
660 if (is_readable($this->root . '/.env')) {
661 (static function(string $path): void {
662 $re = '/^\s*(?!\#)(?<k>[^\s=]+)\s*=\s*+(?<v>("(\\\\.|[^"\\\\]|"")*+"|\'(\\\\.|[^\'\\\\]|\'\')*+\'|[^\r\n#]*))\s*(\#[^\r\n]*)?(\R|$)/mu';
663 if (preg_match_all($re, file_get_contents($path), $matches, PREG_SET_ORDER)) {
664 foreach ($matches as $m) {
665 $k = $m['k'];
666 // Don't overwrite existing env vars (match Symfony behavior)
667 if (isset($_ENV[$k])) {
668 continue;
669 }
670 $v = trim($m['v']);
671 // Strip quotes and process escapes
672 if (($v[0] ?? '') === '"' && str_ends_with($v, '"')) {
673 $v = substr($v, 1, -1);
674 $v = str_replace(['\\\\', '\\"', '\\n', '\\r', '""'], ['\\', '"', "\n", "\r", '"'], $v);
675 } elseif (($v[0] ?? '') === "'" && str_ends_with($v, "'")) {
676 $v = substr($v, 1, -1);
677 $v = str_replace("''", "'", $v);
678 }
679 $_ENV[$k] = $v;
680 if (!str_starts_with($k, 'HTTP_')) {
681 $_SERVER[$k] = $v;
682 }
683 putenv("$k=$v");
684 }
685 }
686 })($this->root . '/.env');
687 }
688
689 // Initialize paths registries directly (too fundamental to be a configurable service)
690 $this->paths = new Mini\PathRegistries();
691
692 // Register as singleton service for consistency and future DI
693 $this->addService(Mini\PathRegistries::class, Lifetime::Singleton, fn() => $this->paths);
694
695 // Config registry: application config first, framework config as fallback
696 $primaryConfigPath = $_ENV['MINI_CONFIG_ROOT'] ?? ($this->root . '/_config');
697 $this->paths->config = new Util\PathsRegistry($primaryConfigPath);
698 $frameworkConfigPath = \dirname((new \ReflectionClass(self::class))->getFileName(), 2) . '/config';
699 $this->paths->config->addPath($frameworkConfigPath);
700
701 $this->debug = !empty($_ENV['DEBUG']);
702
703 $docRoot = $_ENV['MINI_DOC_ROOT'] ?? null;
704 if (!$docRoot && isset($_SERVER['DOCUMENT_ROOT']) && is_dir($_SERVER['DOCUMENT_ROOT'])) {
705 $docRoot = $_SERVER['DOCUMENT_ROOT'];
706 }
707 if (!$docRoot && is_dir($this->root . '/html')) {
708 $docRoot = $this->root . '/html';
709 }
710 if (!$docRoot && is_dir($this->root . '/public')) {
711 $docRoot = $this->root . '/public';
712 }
713 $this->docRoot = $docRoot;
714
715 $baseUrl = $_ENV['MINI_BASE_URL'] ?? null;
716 if ($baseUrl === null && PHP_SAPI !== 'cli' && isset($_SERVER['HTTP_HOST'])) {
717 $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
718 $host = $_SERVER['HTTP_HOST'];
719
720 $scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
721 $scriptDir = dirname($scriptName);
722 $basePath = ($scriptDir !== '/' && $scriptDir !== '\\') ? $scriptDir : '';
723
724 $baseUrl = $scheme . '://' . $host . $basePath;
725 }
726 $this->baseUrl = $baseUrl;
727
728 // CDN base URL for static assets (falls back to baseUrl if not configured)
729 $this->cdnUrl = $_ENV['MINI_CDN_URL'] ?? $baseUrl;
730
731 // Application default locale (respects php.ini, can override with MINI_LOCALE)
732 $locale = $_ENV['MINI_LOCALE'] ?? \ini_get('intl.default_locale') ?: 'en_GB.UTF-8';
733 $this->locale = \Locale::canonicalize($locale);
734 \Locale::setDefault($this->locale);
735
736 // Application default timezone (respects PHP default, can override with MINI_TIMEZONE)
737 $this->timezone = $_ENV['MINI_TIMEZONE'] ?? \date_default_timezone_get();
738 \date_default_timezone_set($this->timezone);
739
740 // Database timezone in offset format (MINI_SQL_TIMEZONE takes precedence over SQL_TIMEZONE)
741 $this->sqlTimezone = $_ENV['MINI_SQL_TIMEZONE'] ?? $_ENV['SQL_TIMEZONE'] ?? '+00:00';
742
743 // Application default language for translations (can override with MINI_LANG)
744 $this->defaultLanguage = $_ENV['MINI_LANG'] ?? 'en';
745
746 // Application salt for cryptographic operations (CSRF tokens, etc.)
747 // Uses machine-specific fingerprint + persistent random salt if MINI_SALT not set
748 $this->salt = $_ENV['MINI_SALT'] ?? Util\MachineSalt::get();
749 }
750
751 }
752