From c8c99cf7f9c68127a6bb6c89e85774dc9e2ef296 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 8 Apr 2026 12:18:29 +0200 Subject: [PATCH 1/5] perf: iterate only once on all installed packages --- .../src/AutoloadDiscoveryLocations.php | 83 +++++++++---------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/packages/discovery/src/AutoloadDiscoveryLocations.php b/packages/discovery/src/AutoloadDiscoveryLocations.php index bba4f0096..d0725f7cb 100644 --- a/packages/discovery/src/AutoloadDiscoveryLocations.php +++ b/packages/discovery/src/AutoloadDiscoveryLocations.php @@ -5,8 +5,7 @@ namespace Tempest\Discovery; use Tempest\Support\Filesystem; - -use function Tempest\Support\Path\normalize; +use Tempest\Support\Path; final readonly class AutoloadDiscoveryLocations { @@ -27,42 +26,47 @@ public function __construct( /** @return \Tempest\Discovery\DiscoveryLocation[] */ public function __invoke(): array { + $composerPath = Path\normalize($this->rootPath, 'vendor/composer'); + $installed = $this->loadJsonFile(Path\normalize($composerPath, 'installed.json')); + $packages = $installed['packages'] ?? []; + return [ - ...$this->discoverCorePackages(), - ...$this->discoverVendorPackages(), + ...$this->discoverInstalledPackages($composerPath, $packages), ...$this->discoverAppNamespaces(), ]; } - /** - * @return DiscoveryLocation[] - */ - private function discoverCorePackages(): array + /** @return array{core: DiscoveryLocation[], vendor: DiscoveryLocation[], optIn: DiscoveryLocation[]} */ + private function discoverInstalledPackages(string $composerPath, array $packages): array { - $composerPath = normalize($this->rootPath, 'vendor/composer'); - $installed = $this->loadJsonFile(normalize($composerPath, 'installed.json')); - $packages = $installed['packages'] ?? []; - - $discoveredLocations = []; + $core = []; + $vendor = []; + $optIn = []; foreach ($packages as $package) { - $packageName = $package['name'] ?? ''; - $isTempest = str_starts_with($packageName, 'tempest'); - - if (! $isTempest) { + if (! isset($package['autoload']['psr-4'])) { continue; } - $packagePath = normalize($composerPath, $package['install-path'] ?? ''); + $packagePath = Path\normalize($composerPath, $package['install-path'] ?? ''); + + if (str_starts_with($package['name'] ?? '', needle: 'tempest/')) { + $core = [...$core, ...$this->discoverPackageLocations($packagePath, $package['autoload']['psr-4'])]; + continue; + } - foreach ($package['autoload']['psr-4'] as $namespace => $namespacePath) { - $namespacePath = normalize($packagePath, $namespacePath); + if (array_find($package['require'] ?? [], static fn ($_, string $package) => str_starts_with($package, needle: 'tempest/'))) { + $vendor = [...$vendor, ...$this->discoverPackageLocations($packagePath, $package['autoload']['psr-4'])]; + continue; + } - $discoveredLocations[] = new DiscoveryLocation($namespace, $namespacePath); + if ($package['extra']['tempest']['can-discover'] ?? false) { + $optIn = [...$optIn, ...$this->discoverPackageLocations($packagePath, $package['autoload']['psr-4'])]; + continue; } } - return $discoveredLocations; + return [...$core, ...$vendor, ...$optIn]; } /** @@ -73,7 +77,7 @@ private function discoverAppNamespaces(): array $discoveredLocations = []; foreach ($this->composer->namespaces as $namespace) { - $path = normalize($this->rootPath, $namespace->path); + $path = Path\normalize($this->rootPath, $namespace->path); $discoveredLocations[] = new DiscoveryLocation($namespace->namespace, $path); } @@ -81,35 +85,26 @@ private function discoverAppNamespaces(): array return $discoveredLocations; } - /** - * @return DiscoveryLocation[] - */ - private function discoverVendorPackages(): array + /** @return DiscoveryLocation[] */ + private function discoverPackageLocations(string $packagePath, array $psr4Namespaces): array { - $composerPath = normalize($this->rootPath, 'vendor/composer'); - $installed = $this->loadJsonFile(normalize($composerPath, 'installed.json')); - $packages = $installed['packages'] ?? []; - $discoveredLocations = []; - foreach ($packages as $package) { - $packageName = $package['name'] ?? ''; - $isTempest = str_starts_with($packageName, 'tempest'); - - if ($isTempest) { - continue; - } + foreach ($psr4Namespaces as $namespace => $namespacePath) { + if (is_array($namespacePath)) { + foreach ($namespacePath as $path) { + if (! is_string($path)) { + continue; + } - $packagePath = normalize($composerPath, $package['install-path'] ?? ''); - $requiresTempest = isset($package['require']['tempest/discovery']) || isset($package['require']['tempest/framework']) || isset($package['require']['tempest/core']); - $hasPsr4Namespaces = isset($package['autoload']['psr-4']); + $discoveredLocations[] = new DiscoveryLocation($namespace, Path\normalize($packagePath, $path)); + } - if (! ($requiresTempest && $hasPsr4Namespaces)) { continue; } - foreach ($package['autoload']['psr-4'] as $namespace => $namespacePath) { - $path = normalize($packagePath, $namespacePath); + if (is_string($namespacePath)) { + $path = Path\normalize($packagePath, $namespacePath); $discoveredLocations[] = new DiscoveryLocation($namespace, $path); } From 9911efc9edac24374ca9fb17ec9f29bd6fd56db8 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 8 Apr 2026 12:20:45 +0200 Subject: [PATCH 2/5] feat: support ignoring discovery locations --- packages/discovery/src/AutoloadDiscoveryLocations.php | 10 ++++------ packages/discovery/src/BootDiscovery.php | 4 ++++ packages/discovery/src/DiscoveryLocation.php | 6 ++++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/discovery/src/AutoloadDiscoveryLocations.php b/packages/discovery/src/AutoloadDiscoveryLocations.php index d0725f7cb..6bef90b38 100644 --- a/packages/discovery/src/AutoloadDiscoveryLocations.php +++ b/packages/discovery/src/AutoloadDiscoveryLocations.php @@ -61,7 +61,7 @@ private function discoverInstalledPackages(string $composerPath, array $packages } if ($package['extra']['tempest']['can-discover'] ?? false) { - $optIn = [...$optIn, ...$this->discoverPackageLocations($packagePath, $package['autoload']['psr-4'])]; + $optIn = [...$optIn, ...$this->discoverPackageLocations($packagePath, $package['autoload']['psr-4'], $package['extra']['tempest']['ignore'] ?? [])]; continue; } } @@ -86,7 +86,7 @@ private function discoverAppNamespaces(): array } /** @return DiscoveryLocation[] */ - private function discoverPackageLocations(string $packagePath, array $psr4Namespaces): array + private function discoverPackageLocations(string $packagePath, array $psr4Namespaces, array $ignore = []): array { $discoveredLocations = []; @@ -97,16 +97,14 @@ private function discoverPackageLocations(string $packagePath, array $psr4Namesp continue; } - $discoveredLocations[] = new DiscoveryLocation($namespace, Path\normalize($packagePath, $path)); + $discoveredLocations[] = new DiscoveryLocation($namespace, Path\normalize($packagePath, $path), $ignore); } continue; } if (is_string($namespacePath)) { - $path = Path\normalize($packagePath, $namespacePath); - - $discoveredLocations[] = new DiscoveryLocation($namespace, $path); + $discoveredLocations[] = new DiscoveryLocation($namespace, Path\normalize($packagePath, $namespacePath), $ignore); } } diff --git a/packages/discovery/src/BootDiscovery.php b/packages/discovery/src/BootDiscovery.php index 25f514dee..70991d530 100644 --- a/packages/discovery/src/BootDiscovery.php +++ b/packages/discovery/src/BootDiscovery.php @@ -150,6 +150,10 @@ private function scan(DiscoveryLocation $location, array $discoveries, string $p return; } + if ($location->isIgnored($input)) { + return; + } + if (is_file($input)) { $this->discoverPath($input, $location, $discoveries); return; diff --git a/packages/discovery/src/DiscoveryLocation.php b/packages/discovery/src/DiscoveryLocation.php index 84b33b494..e27e00aab 100644 --- a/packages/discovery/src/DiscoveryLocation.php +++ b/packages/discovery/src/DiscoveryLocation.php @@ -18,6 +18,7 @@ final class DiscoveryLocation public function __construct( public readonly string $namespace, string $path, + private(set) array $ignore = [], ) { $this->path = Filesystem\normalize_path(rtrim($path, '\\/')); } @@ -37,6 +38,11 @@ public function isVendor(): bool return str_contains($this->path, '/vendor/') || str_contains($this->path, '\\vendor\\') || $this->isTempest(); } + public function isIgnored(string $path): bool + { + return array_any($this->ignore, fn (string $ignore) => str_starts_with($path, $ignore)); + } + public function toClassName(string $path): string { // Try to create a PSR-compliant class name from the path From fa9f9abf0783ba4b3d8dcf633c3efc4bd9b66f3e Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 8 Apr 2026 12:22:49 +0200 Subject: [PATCH 3/5] style: apply fixes from qa --- packages/discovery/src/AutoloadDiscoveryLocations.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/discovery/src/AutoloadDiscoveryLocations.php b/packages/discovery/src/AutoloadDiscoveryLocations.php index 6bef90b38..a6dbfde1e 100644 --- a/packages/discovery/src/AutoloadDiscoveryLocations.php +++ b/packages/discovery/src/AutoloadDiscoveryLocations.php @@ -36,7 +36,7 @@ public function __invoke(): array ]; } - /** @return array{core: DiscoveryLocation[], vendor: DiscoveryLocation[], optIn: DiscoveryLocation[]} */ + /** @return DiscoveryLocation[] */ private function discoverInstalledPackages(string $composerPath, array $packages): array { $core = []; From 4b6cc26cc324c9f30791d8e3c70ae0a4f8ea6aaf Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 8 Apr 2026 12:38:25 +0200 Subject: [PATCH 4/5] fix: map ignored paths to their full paths --- packages/discovery/src/AutoloadDiscoveryLocations.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/discovery/src/AutoloadDiscoveryLocations.php b/packages/discovery/src/AutoloadDiscoveryLocations.php index a6dbfde1e..fe0f46817 100644 --- a/packages/discovery/src/AutoloadDiscoveryLocations.php +++ b/packages/discovery/src/AutoloadDiscoveryLocations.php @@ -89,6 +89,7 @@ private function discoverAppNamespaces(): array private function discoverPackageLocations(string $packagePath, array $psr4Namespaces, array $ignore = []): array { $discoveredLocations = []; + $ignore = array_map(static fn (string $path) => Filesystem\normalize_path(Path\normalize($packagePath, $path)), $ignore); foreach ($psr4Namespaces as $namespace => $namespacePath) { if (is_array($namespacePath)) { From 5bb6b3b88ed7fa5c9de971d0110167e132e68ab4 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 9 Apr 2026 19:57:47 +0200 Subject: [PATCH 5/5] docs: document opt-in discovery --- docs/3-packages/02-console.md | 2 +- docs/5-extra-topics/01-package-development.md | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/3-packages/02-console.md b/docs/3-packages/02-console.md index bb67fb153..fc7eddea3 100644 --- a/docs/3-packages/02-console.md +++ b/docs/3-packages/02-console.md @@ -35,7 +35,7 @@ You may read more about building commands in the [dedicated documentation](../1- Tempest will automatically discover all console commands from multiple sources: 1. **Core Tempest packages** — Built-in commands from Tempest itself -2. **Vendor packages** — Third-party packages that require `tempest/framework` or `tempest/core` +2. **Vendor packages** — Packages that require any `tempest/*` package,or opt in via `extra.tempest.can-discover` 3. **App namespaces** — All namespaces configured as PSR-4 autoload paths in your `composer.json` ```json diff --git a/docs/5-extra-topics/01-package-development.md b/docs/5-extra-topics/01-package-development.md index 97d4eaa7f..7236c10e3 100644 --- a/docs/5-extra-topics/01-package-development.md +++ b/docs/5-extra-topics/01-package-development.md @@ -5,10 +5,26 @@ description: "Tempest comes with a handful of tools to help third-party package ## Overview -Creating a package for Tempest is as simple as adding `tempest/core` as a dependency. When this happens, [discovery](../1-essentials/05-discovery.md) will find the package thanks to composer metadata and register discoverable classes. +Creating a package for Tempest consists of creating a typical PHP package, except it should depend on the relevant Tempest dependency. When you install a dependency that depends on any `tempest/*` package, [discovery](../1-essentials/05-discovery.md) will find it through Composer metadata and register discoverable classes. Unlike Symfony or Laravel, Tempest doesn't have a dedicated "service provider" concept. Instead, you're encouraged to rely on [discovery](../1-essentials/05-discovery.md) and [initializers](../1-essentials/05-container#dependency-initializers). +## Optional Tempest support + +If your package is a normal package but has optional support for Tempest, you can opt-in for discovery by providing metadata in `composer.json`: + +```json composer.json +{ + "extra": { + "tempest": { + "can-discover": true + } + } +} +``` + +The `extra.tempest.can-discover` property marks your package as discoverable even without a Tempest dependency. + ## Preventing discovery You may create classes which would normally be discovered by Tempest. You may prevent this behavior by marking them with the {`Tempest\Discovery\SkipDiscovery`} attribute. @@ -25,6 +41,20 @@ final readonly class UserMigration implements Migration } ``` +Alternatively, you may use composer metadata to completely exclude any path from discovery. This is mostly useful when the package has optional dependencies, since discovery use Reflection and will throw errors when encountering unknown classes or interfaces. + +```json composer.json +{ + "extra": { + "tempest": { + "ignore": [ + "src/OptionalDependency.php" + ] + } + } +} +``` + ## Installers An installer is a command that publishes files to the user's project. For instance, this can be used to export migration files that shouldn't be discovered unless the user have published them.