diff --git a/src/Discovery/BundleDiscovery.php b/src/Discovery/BundleDiscovery.php index 1cf8c9c..5c77e5d 100644 --- a/src/Discovery/BundleDiscovery.php +++ b/src/Discovery/BundleDiscovery.php @@ -42,12 +42,16 @@ final class BundleDiscovery /** @var array */ private array $metadataHashes = []; - /** @param list|array $packagePrefixes Optional package-name prefixes used to narrow discovery. */ + /** + * @param list|array $packagePrefixes Optional package-name prefixes used to narrow discovery. + * @param string|null $cacheDir Resolved kernel cache directory; resolved again from the project when omitted. + */ public function __construct( private readonly ActivePackageResolver $resolver, array $packagePrefixes = [], private readonly ?string $projectDir = null, private readonly ?string $environment = null, + private readonly ?string $cacheDir = null, ) { $this->packagePrefixes = $this->normalizePackagePrefixes($packagePrefixes); @@ -548,7 +552,7 @@ private function packageNames(): array return $this->packageNames; } - $cache = new KernelPackageManifestCache($this->projectDir, $this->environment, $this->packagePrefixes); + $cache = new KernelPackageManifestCache($this->projectDir, $this->environment, $this->packagePrefixes, $this->cacheDir); $cachedPackages = $cache->read(); if ($cachedPackages !== null) { diff --git a/src/Discovery/KernelPackageManifestCache.php b/src/Discovery/KernelPackageManifestCache.php index 6a36acc..635cd30 100644 --- a/src/Discovery/KernelPackageManifestCache.php +++ b/src/Discovery/KernelPackageManifestCache.php @@ -23,11 +23,13 @@ public function metadata(): array /** * @param list $packagePrefixes + * @param string|null $cacheDir Resolved kernel cache directory; resolved from the project when omitted. */ public function __construct( private readonly ?string $projectDir, private readonly ?string $environment, private readonly array $packagePrefixes, + private readonly ?string $cacheDir = null, ) { } @@ -151,6 +153,10 @@ private function cacheFile(): ?string return null; } + if ($this->cacheDir !== null && $this->cacheDir !== '') { + return rtrim($this->cacheDir, '/') . '/discovery-packages.json'; + } + $environment = $this->environment; if ($environment === null || $environment === '') { diff --git a/src/Kernel/AbstractKernel.php b/src/Kernel/AbstractKernel.php index 45c52c9..7ab9481 100644 --- a/src/Kernel/AbstractKernel.php +++ b/src/Kernel/AbstractKernel.php @@ -31,6 +31,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; +// phpcs:ignore SymPress.Classes.PropertyLimit.TooManyProperties -- The resolver is kept for one boot so the cache path is resolved once per request. abstract class AbstractKernel implements KernelInterface { protected readonly SiteConfig $config; @@ -44,6 +45,7 @@ abstract class AbstractKernel implements KernelInterface protected array $bundles = []; private ?CoreServiceRegistrar $coreServiceRegistrar = null; + private ?KernelConfigurationResolver $configuration = null; public function __construct( protected readonly string $projectDir, @@ -76,6 +78,7 @@ public function __clone() $this->bundleRegistry = null; $this->bundles = []; $this->coreServiceRegistrar = null; + $this->configuration = null; } /** @return array{project_dir: string, environment: string, debug: bool} */ @@ -265,6 +268,7 @@ public function discoverBundles(): BundleRegistry $this->configuration()->packagePrefixes(), $this->projectDir, $this->environment, + $this->getCacheDir(), ))->discover(); $this->bundles = $this->bundleMap($this->bundleRegistry); @@ -373,7 +377,7 @@ private function containerCacheManager(): ContainerCacheManager private function configuration(): KernelConfigurationResolver { - return new KernelConfigurationResolver( + return $this->configuration ??= new KernelConfigurationResolver( $this->projectDir, $this->environment, $this->config, @@ -433,6 +437,7 @@ public function shutdown(): void $this->container = null; $this->bundleRegistry = null; $this->bundles = []; + $this->configuration = null; } /** @return array */ diff --git a/src/Kernel/KernelConfigurationResolver.php b/src/Kernel/KernelConfigurationResolver.php index fd67fd1..acf2a38 100644 --- a/src/Kernel/KernelConfigurationResolver.php +++ b/src/Kernel/KernelConfigurationResolver.php @@ -7,18 +7,23 @@ use SymPress\Kernel\Bundle\BundleRegistry; use SymPress\Kernel\SiteConfig; -final readonly class KernelConfigurationResolver +final class KernelConfigurationResolver { + /** @var list|null */ + private ?array $packagePrefixes = null; + private ?string $cacheDir = null; + public function __construct( - private string $projectDir, - private string $environment, - private SiteConfig $config, + private readonly string $projectDir, + private readonly string $environment, + private readonly SiteConfig $config, ) { } public function cacheDir(): string { - return CachePath::resolve($this->projectDir, $this->environment, $this->serverString('APP_CACHE_DIR')); + // Resolving the path walks realpath() and permission checks; a resolver lives for one boot. + return $this->cacheDir ??= CachePath::resolve($this->projectDir, $this->environment, $this->serverString('APP_CACHE_DIR')); } public function buildDir(): string @@ -65,13 +70,20 @@ public function logDir(): ?string /** @return list */ public function packagePrefixes(): array { + if ($this->packagePrefixes !== null) { + return $this->packagePrefixes; + } + $configured = $this->config->get('KERNEL_PACKAGE_PREFIXES', null); if ($configured === null) { + // The root composer.json is parsed once per resolver, not once per request phase. $configured = $this->composerKernelPackagePrefixes(); } - return $this->normalizePackagePrefixes($configured); + $this->packagePrefixes = $this->normalizePackagePrefixes($configured); + + return $this->packagePrefixes; } /** @return list */ diff --git a/tests/Discovery/KernelPackageManifestCacheTest.php b/tests/Discovery/KernelPackageManifestCacheTest.php index 8d9cf57..51013c0 100644 --- a/tests/Discovery/KernelPackageManifestCacheTest.php +++ b/tests/Discovery/KernelPackageManifestCacheTest.php @@ -42,6 +42,20 @@ public function testManifestRoundTripsUntilComposerMetadataChanges(): void self::assertNull($cache->read()); } + public function testExplicitCacheDirectorySkipsResolvingTheProjectCachePath(): void + { + $projectDir = $this->tmpPath('discovery-cache-explicit'); + file_put_contents("{$projectDir}/composer.json", '{}'); + $cacheDir = "{$projectDir}/resolved/kernel"; + + $cache = new KernelPackageManifestCache($projectDir, 'production', ['sympress/'], $cacheDir); + $cache->write(['sympress/kernel']); + + self::assertFileExists("{$cacheDir}/discovery-packages.json"); + self::assertFileDoesNotExist("{$projectDir}/var/cache/production/kernel/discovery-packages.json"); + self::assertSame(['sympress/kernel'], $cache->read()); + } + public function testUnchangedManifestReadersDoNotInvalidateOpcache(): void { $project = $this->tmpPath('discovery-reader-cost'); diff --git a/tests/Kernel/RuntimeCacheCompatibilityTest.php b/tests/Kernel/RuntimeCacheCompatibilityTest.php index 8086406..a83d78b 100644 --- a/tests/Kernel/RuntimeCacheCompatibilityTest.php +++ b/tests/Kernel/RuntimeCacheCompatibilityTest.php @@ -414,12 +414,13 @@ public function testCacheDirectoryReadsRealEnvironmentWithServerAndEnvPrecedence $previous = getenv('APP_CACHE_DIR'); putenv('APP_CACHE_DIR=' . $project . '/external'); try { - $resolver = new KernelConfigurationResolver($project, 'test', new TestSiteConfig('test')); - self::assertSame($project . '/external/test/kernel', $resolver->cacheDir()); + // A resolver fixes its cache path for one boot; each precedence step uses a fresh one. + $resolver = static fn (): KernelConfigurationResolver => new KernelConfigurationResolver($project, 'test', new TestSiteConfig('test')); + self::assertSame($project . '/external/test/kernel', $resolver()->cacheDir()); $_ENV['APP_CACHE_DIR'] = $project . '/env'; - self::assertSame($project . '/env/test/kernel', $resolver->cacheDir()); + self::assertSame($project . '/env/test/kernel', $resolver()->cacheDir()); $_SERVER['APP_CACHE_DIR'] = $project . '/server'; - self::assertSame($project . '/server/test/kernel', $resolver->cacheDir()); + self::assertSame($project . '/server/test/kernel', $resolver()->cacheDir()); } finally { unset($_SERVER['APP_CACHE_DIR'], $_ENV['APP_CACHE_DIR']); putenv($previous === false ? 'APP_CACHE_DIR' : 'APP_CACHE_DIR=' . $previous);