*/ private const REQUIRED_KEYS = [ 'APP_NAME', 'APP_ENV', 'APP_DEBUG', 'APP_TIMEZONE', 'APP_STORAGE_PATH', 'APP_LOCALE', 'APP_LOCALES', 'APP_I18N_DOMAIN', 'SESSION_NAME', 'TENANT_SCOPE_STRICT', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS', 'CACHE_SERVERS', 'FIREWALL_CONCURRENCY', 'FIREWALL_SPINLOCK_SECONDS', 'FIREWALL_INTERVAL_SECONDS', 'FIREWALL_CACHE_PREFIX', 'FIREWALL_REVERSE_PROXY', ]; public static function validate(): void { if (!self::isValidationEnabled()) { return; } $env = []; foreach (array_merge(self::REQUIRED_KEYS, ['APP_URL', 'APP_CRYPTO_KEY', 'APP_VENDOR_PHP_ISSUES_MODE']) as $key) { $env[$key] = getenv($key); } $errors = self::validateArray($env); if ($errors === []) { return; } throw new \RuntimeException( "Configuration validation failed:\n - " . implode("\n - ", $errors) . "\nPlease verify your .env based on .env.example" ); } // Validation is on by default; set APP_CONFIG_VALIDATE=false to skip (e.g. in CI without full env). public static function isValidationEnabled(): bool { $raw = getenv('APP_CONFIG_VALIDATE'); if ($raw === false || trim((string) $raw) === '') { return true; } return self::parseBool((string) $raw) ?? true; } /** * @param array $env * @return list */ public static function validateArray(array $env): array { $errors = []; foreach (self::REQUIRED_KEYS as $key) { if (!self::hasEnvKey($env, $key)) { $errors[] = "Missing required env key '{$key}'"; continue; } if (self::value($env, $key) === '') { $errors[] = "Env key '{$key}' must not be empty"; } } $appEnv = strtolower(self::value($env, 'APP_ENV')); if ($appEnv !== '' && !in_array($appEnv, ['dev', 'prod', 'test'], true)) { $errors[] = "APP_ENV must be one of: dev, prod, test"; } $appDebug = self::value($env, 'APP_DEBUG'); if ($appDebug !== '' && self::parseBool($appDebug) === null) { $errors[] = "APP_DEBUG must be a boolean value (true/false/1/0)"; } $tenantScopeStrict = self::value($env, 'TENANT_SCOPE_STRICT'); if ($tenantScopeStrict !== '' && self::parseBool($tenantScopeStrict) === null) { $errors[] = "TENANT_SCOPE_STRICT must be a boolean value (true/false/1/0)"; } $vendorPhpIssuesMode = strtolower(self::value($env, 'APP_VENDOR_PHP_ISSUES_MODE')); if ( $vendorPhpIssuesMode !== '' && !in_array($vendorPhpIssuesMode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true) ) { $errors[] = 'APP_VENDOR_PHP_ISSUES_MODE must be one of: strict, suppress_deprecations, suppress_deprecations_warnings'; } $reverseProxy = self::value($env, 'FIREWALL_REVERSE_PROXY'); if ($reverseProxy !== '' && self::parseBool($reverseProxy) === null) { $errors[] = "FIREWALL_REVERSE_PROXY must be a boolean value (true/false/1/0)"; } $timezone = self::value($env, 'APP_TIMEZONE'); if ($timezone !== '' && !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) { $errors[] = "APP_TIMEZONE is invalid: '{$timezone}'"; } $locales = self::parseCsv(self::value($env, 'APP_LOCALES')); if ($locales === []) { $errors[] = 'APP_LOCALES must contain at least one locale'; } $locale = self::value($env, 'APP_LOCALE'); if ($locale !== '' && $locales !== [] && !in_array($locale, $locales, true)) { $errors[] = "APP_LOCALE '{$locale}' is not part of APP_LOCALES"; } $dbPort = self::parseInt(self::value($env, 'DB_PORT')); if ($dbPort === null || $dbPort < 1 || $dbPort > 65535) { $errors[] = 'DB_PORT must be an integer between 1 and 65535'; } $firewallConcurrency = self::parseInt(self::value($env, 'FIREWALL_CONCURRENCY')); if ($firewallConcurrency === null || $firewallConcurrency < 1) { $errors[] = 'FIREWALL_CONCURRENCY must be an integer >= 1'; } $firewallInterval = self::parseInt(self::value($env, 'FIREWALL_INTERVAL_SECONDS')); if ($firewallInterval === null || $firewallInterval < 1) { $errors[] = 'FIREWALL_INTERVAL_SECONDS must be an integer >= 1'; } $firewallSpinLock = self::parseFloat(self::value($env, 'FIREWALL_SPINLOCK_SECONDS')); if ($firewallSpinLock === null || $firewallSpinLock <= 0) { $errors[] = 'FIREWALL_SPINLOCK_SECONDS must be a float > 0'; } $cacheServers = self::parseCsv(self::value($env, 'CACHE_SERVERS')); if ($cacheServers === []) { $errors[] = 'CACHE_SERVERS must contain at least one host[:port] entry'; } else { foreach ($cacheServers as $server) { if (!preg_match('/^[a-z0-9.-]+(?::\d{1,5})?$/i', $server)) { $errors[] = "CACHE_SERVERS contains invalid entry '{$server}'"; continue; } $parts = explode(':', $server, 2); if (count($parts) !== 2) { continue; } $port = self::parseInt($parts[1]); if ($port === null || $port < 1 || $port > 65535) { $errors[] = "CACHE_SERVERS entry '{$server}' has invalid port"; } } } $storagePath = self::value($env, 'APP_STORAGE_PATH'); if ($storagePath !== '') { $absoluteStoragePath = self::absolutePath($storagePath); if (!is_dir($absoluteStoragePath)) { $errors[] = "APP_STORAGE_PATH does not exist: '{$storagePath}'"; } elseif (!is_writable($absoluteStoragePath)) { $errors[] = "APP_STORAGE_PATH is not writable: '{$storagePath}'"; } } $cryptoKey = self::value($env, 'APP_CRYPTO_KEY'); if ($cryptoKey !== '' && !self::isValidCryptoKey($cryptoKey)) { $errors[] = 'APP_CRYPTO_KEY must be 64-char hex or base64 encoded 32-byte key'; } $appUrl = self::value($env, 'APP_URL'); $appUrlIsValid = true; if ($appUrl !== '' && !self::isValidHttpUrl($appUrl)) { $errors[] = "APP_URL is invalid: '{$appUrl}'"; $appUrlIsValid = false; } if (in_array($appEnv, ['dev', 'prod'], true) && $appUrl === '') { if ($appEnv === 'prod') { $errors[] = 'APP_URL must be set in production'; } else { $errors[] = 'APP_URL must be set in development'; } } // Production-only hard requirements: HTTPS, real hostname, crypto key, debug off, strict tenant scope. if ($appEnv === 'prod') { if ($appUrl !== '' && $appUrlIsValid) { $appUrlScheme = strtolower((string) parse_url($appUrl, PHP_URL_SCHEME)); if ($appUrlScheme !== 'https') { $errors[] = 'APP_URL must use https in production'; } $appUrlHost = strtolower((string) parse_url($appUrl, PHP_URL_HOST)); if (self::isLocalOrIpHost($appUrlHost)) { $errors[] = 'APP_URL must not use localhost or an IP host in production'; } } if ($cryptoKey === '') { $errors[] = 'APP_CRYPTO_KEY must be set in production'; } if (self::parseBool($appDebug) !== false) { $errors[] = 'APP_DEBUG must be false in production'; } if (self::parseBool($tenantScopeStrict) !== true) { $errors[] = 'TENANT_SCOPE_STRICT must be true in production'; } } return $errors; } /** * @param array $env */ private static function hasEnvKey(array $env, string $key): bool { return array_key_exists($key, $env) && $env[$key] !== false && $env[$key] !== null; } /** * @param array $env */ private static function value(array $env, string $key): string { if (!self::hasEnvKey($env, $key)) { return ''; } return trim((string) $env[$key]); } private static function parseBool(string $value): ?bool { $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); return $parsed; } private static function parseInt(string $value): ?int { if ($value === '') { return null; } $parsed = filter_var($value, FILTER_VALIDATE_INT); return $parsed !== false ? (int) $parsed : null; } private static function parseFloat(string $value): ?float { if ($value === '') { return null; } $parsed = filter_var($value, FILTER_VALIDATE_FLOAT); return $parsed !== false ? (float) $parsed : null; } /** * @return list */ private static function parseCsv(string $value): array { if ($value === '') { return []; } return array_values(array_filter(array_map('trim', explode(',', $value)))); } // Accepts either 64-char hex (32 bytes) or base64-encoded 32 bytes — both produce a 256-bit key. private static function isValidCryptoKey(string $value): bool { if (preg_match('/^[a-f0-9]{64}$/i', $value) === 1) { return true; } $decoded = base64_decode($value, true); return is_string($decoded) && strlen($decoded) === 32; } private static function isValidHttpUrl(string $value): bool { if (filter_var($value, FILTER_VALIDATE_URL) === false) { return false; } $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME)); return in_array($scheme, ['http', 'https'], true); } private static function isLocalOrIpHost(string $host): bool { $host = strtolower(trim($host)); if ($host === '') { return true; } if ($host === 'localhost' || str_ends_with($host, '.localhost')) { return true; } $host = trim($host, '[]'); return filter_var($host, FILTER_VALIDATE_IP) !== false; } private static function absolutePath(string $path): string { if (self::isAbsolutePath($path)) { return $path; } $projectRoot = dirname(__DIR__, 3); return $projectRoot . '/' . ltrim($path, '/'); } private static function isAbsolutePath(string $path): bool { if ($path === '') { return false; } if ($path[0] === '/' || $path[0] === '\\') { return true; } return preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1; } }