diff --git a/docs/1-essentials/05-discovery.md b/docs/1-essentials/05-discovery.md
index 7e4c5f689..8518f97fd 100644
--- a/docs/1-essentials/05-discovery.md
+++ b/docs/1-essentials/05-discovery.md
@@ -5,11 +5,11 @@ description: "Tempest automatically locates controller actions, event handlers,
## Overview
-Tempest introduces a unique approach to bootstrapping an application. Instead of requiring manual registration of project code and packages, Tempest automatically scans the codebase and detects the components that should be loaded. This process is called **discovery**.
+Tempest introduces a unique approach to bootstrapping applications. Instead of requiring manual registration of project code and packages, Tempest automatically scans the codebase and detects the components that should be loaded. This process is called **discovery**.
-Discovery is powered by composer metadata. Every package that depends on Tempest, along with your application's own code, are included in the discovery process. Tempest applies various rules to determine the purpose of different pieces of code. It can analyze file names, attributes, interfaces, return types, and more.
+Discovery is powered by composer metadata. Every package that depends on Tempest, along with your application's own code, are included in the discovery process.
-For instance, web routes are discovered based on route attributes:
+Tempest applies [various rules](#built-in-discovery-classes) to determine the purpose of different pieces of codeāit can analyze file names, attributes, interfaces, return types, and more. For instance, web routes are discovered when methods are annotated with route attributes:
```php app/HomeController.php
final readonly class HomeController
@@ -22,33 +22,59 @@ final readonly class HomeController
}
```
-Note that Tempest is able to cache discovery information to avoid any performance cost in production. You can read more about caching in the [development](#discovery-for-local-development) and [production](#discovery-in-production) sections.
-
-:::info
+:::tip
Read the [getting started with discovery](/blog/discovery-explained) guide if you want to know more about the philosophy of discovery and how it works.
:::
-## Built-in discovery classes
+## Discovery in production
-Most of Tempest's features are built on top of discovery. The following is a non-exhaustive list that describes which discovery class is associated to which feature.
+Discovery comes with performance considerations. In production, it is always cached to avoid scanning files on every request.
-- {b`Tempest\Core\DiscoveryDiscovery`} discovers other discovery classes. This class is run manually by the framework when booted.
-- {b`Tempest\CommandBus\CommandBusDiscovery`} discovers methods with the {b`#[Tempest\CommandBus\CommandHandler]`} attribute and registers them into the [command bus](../2-features/10-command-bus.md).
-- {b`Tempest\Console\Discovery\ConsoleCommandDiscovery`} discovers methods with the {b`#[Tempest\Console\ConsoleCommand]`} attribute and registers them as [console commands](../1-essentials/04-console-commands.md).
-- {b`Tempest\Console\Discovery\ScheduleDiscovery`} discovers methods with the {b`#[Tempest\Console\Schedule]`} attribute and registers them as [scheduled tasks](../2-features/11-scheduling.md).
-- {b`Tempest\Container\InitializerDiscovery`} discovers classes that implement {b`\Tempest\Container\Initializer`} or {b`\Tempest\Container\DynamicInitializer`} and registers them as [dependency initializers](./05-container.md#dependency-initializers).
-- {b`Tempest\Database\MigrationDiscovery`} discovers classes that implement {b`Tempest\Database\MigratesUp`} or {b`Tempest\Database\MigratesDown`} and registers them as [migrations](./03-database.md#migrations).
-- {b`Tempest\EventBusDiscovery\EventBusDiscovery`} discovers methods with the {b`#[Tempest\EventBus\EventHandler]`} attribute and registers them in the [event bus](../2-features/08-events.md).
-- {b`Tempest\Router\RouteDiscovery`} discovers route attributes on methods and registers them as [controller actions](./01-routing.md) in the router.
-- {b`Tempest\Mapper\MapperDiscovery`} discovers classes that implement {b`Tempest\Mapper\Mapper`} and registers them for [mapping](../2-features/01-mapper.md#mapper-discovery).
-- {b`Tempest\Mapper\CasterDiscovery`} discovers classes that implement {b`Tempest\Mapper\DynamicCaster`} and registers them as [casters](../2-features/01-mapper.md#casters-and-serializers).
-- {b`Tempest\Mapper\SerializerDiscovery`} discovers classes that implement {b`Tempest\Mapper\DynamicSerializer`} and registers them as [serializers](../2-features/01-mapper.md#casters-and-serializers).
-- {b`Tempest\View\ViewComponentDiscovery`} discovers `x-*.view.php` files and registers them as [view components](../1-essentials/02-views.md#view-components).
-- {b`Tempest\Vite\ViteDiscovery`} discovers `*.entrypoint.{ts,js,css}` files and register them as [entrypoints](../2-features/02-asset-bundling.md#entrypoints).
-- {b`Tempest\Auth\AccessControl\PolicyDiscovery`} discovers methods annotated with the {b`#[Tempest\Auth\AccessControl\Policy]`} attribute and registers them as [access control policies](../2-features/04-authentication.md#access-control).
+To ensure that the discovery cache is up-to-date, add the `discovery:generate` command before any other Tempest command in your deployment pipeline.
+
+```console ">_ ./tempest discovery:generate --no-interaction"
+Clearing discovery cache ..................................... 2025-12-30 15:51:46
+Clearing discovery cache ..................................... DONE
+Generating discovery cache using the `full` strategy ......... 2025-12-30 15:51:46
+Generating discovery cache using the `full` strategy ......... DONE
+```
+
+## Discovery for local development
+
+During development, discovery is only enabled for application code. This implies that the cache should be regenerated whenever a package is installed or updated.
+
+It is recommended to add the `discovery:generate` command to the `post-package-update` script in `composer.json`:
+
+```json composer.json
+{
+ "scripts": {
+ "post-package-update": [
+ "@php tempest discovery:generate"
+ ]
+ }
+}
+```
+
+### Disabling discovery cache
+
+In some situations, you may want to enable discovery even for vendor code. For instance, if you are working on a third-party package that is being developed alongside your application, you may want to have discovery enabled all the time.
+
+To achieve this, set the `DISCOVERY_CACHE` environment variable to `false`:
+
+```env .env
+{:hl-property:DISCOVERY_CACHE:}={:hl-keyword:false:}
+```
+
+### Troubleshooting
+
+The `discovery:clear` command clears the discovery cache, which will be rebuilt the next time the framework boots. `discovery:generate` can be used to manually regenerate the cache.
+
+If the discovery cache gets corrupted and even `discovery:clear` is not enough, the `.tempest/cache/discovery` may be manually deleted from your project.
## Implementing your own discovery
+While Tempest provides a variety of [built-in discovery classes](#built-in-discovery-classes), you may want to implement your own to extend the framework's capabilities in your application or in a package you are building.
+
### Discovering code in classes
Tempest discovers classes that implement {b`Tempest\Discovery\Discovery`}, which requires implementing the `discover()` and `apply()` methods. The {b`Tempest\Discovery\IsDiscovery`} trait provides the rest of the implementation.
@@ -161,51 +187,34 @@ final class ViteDiscovery implements Discovery, DiscoversPath
}
```
-## Discovery in production
-
-Discovery is a really powerful feature, but it comes with performance considerations. At its core, it loops through all files in your project, including vendors. For this reason, discovery information is automatically cached in production environments.
-
-Caching is done by running the `discovery:generate` command, which should be part of your deployment pipeline before any other Tempest command.
-
-```console ">_ ./tempest discovery:generate --no-interaction"
-Clearing discovery cache ..................................... 2025-12-30 15:51:46
-Clearing discovery cache ..................................... DONE
-Generating discovery cache using the `full` strategy ......... 2025-12-30 15:51:46
-Generating discovery cache using the `full` strategy ......... DONE
-```
-
-## Discovery for local development
-
-During development, discovery is enabled without a cache. Depending on the size of your project, you may benefit from enabling the partial cache strategy:
-
-```env .env
-{:hl-property:DISCOVERY_CACHE:}={:hl-keyword:partial:}
-```
-
-This strategy only caches discovery for vendor files. For this reason, it is recommended to run `discovery:generate` after every composer update:
-
-```json composer.json
-{
- "scripts": {
- "post-package-update": [
- "php tempest discovery:generate"
- ]
- }
-}
-```
-
-:::info
-If your project was created using {`tempest/app`}, the `post-package-update` script is already included.
-:::
-
## Excluding files and classes from discovery
-If needed, you can always exclude discovered files and classes by providing a discovery config file:
+Files and classes may be excluded from discovery by providing a {b`Tempest\Core\DiscoveryConfig`} [configuration](./06-configuration.md) file.
-```php app/discovery.config.php
+```php src/discovery.config.php
use Tempest\Core\DiscoveryConfig;
return new DiscoveryConfig()
->skipClasses(GlobalHiddenDiscovery::class)
->skipPaths(__DIR__ . '/../../Fixtures/GlobalHiddenPathDiscovery.php');
```
+
+## Built-in discovery classes
+
+Most of Tempest's features are built on top of discovery. The following is a non-exhaustive list that describes which discovery class is associated to which feature.
+
+- {b`Tempest\Core\DiscoveryDiscovery`} discovers other discovery classes. This class is run manually by the framework when booted.
+- {b`Tempest\CommandBus\CommandBusDiscovery`} discovers methods with the {b`#[Tempest\CommandBus\CommandHandler]`} attribute and registers them into the [command bus](../2-features/10-command-bus.md).
+- {b`Tempest\Console\Discovery\ConsoleCommandDiscovery`} discovers methods with the {b`#[Tempest\Console\ConsoleCommand]`} attribute and registers them as [console commands](../1-essentials/04-console-commands.md).
+- {b`Tempest\Console\Discovery\ScheduleDiscovery`} discovers methods with the {b`#[Tempest\Console\Schedule]`} attribute and registers them as [scheduled tasks](../2-features/11-scheduling.md).
+- {b`Tempest\Container\InitializerDiscovery`} discovers classes that implement {b`\Tempest\Container\Initializer`} or {b`\Tempest\Container\DynamicInitializer`} and registers them as [dependency initializers](./05-container.md#dependency-initializers).
+- {b`Tempest\Database\MigrationDiscovery`} discovers classes that implement {b`Tempest\Database\MigratesUp`} or {b`Tempest\Database\MigratesDown`} and registers them as [migrations](./03-database.md#migrations).
+- {b`Tempest\EventBusDiscovery\EventBusDiscovery`} discovers methods with the {b`#[Tempest\EventBus\EventHandler]`} attribute and registers them in the [event bus](../2-features/08-events.md).
+- {b`Tempest\Router\RouteDiscovery`} discovers route attributes on methods and registers them as [controller actions](./01-routing.md) in the router.
+- {b`Tempest\Mapper\MapperDiscovery`} discovers classes that implement {b`Tempest\Mapper\Mapper`} and registers them for [mapping](../2-features/01-mapper.md#mapper-discovery).
+- {b`Tempest\Mapper\CasterDiscovery`} discovers classes that implement {b`Tempest\Mapper\DynamicCaster`} and registers them as [casters](../2-features/01-mapper.md#casters-and-serializers).
+- {b`Tempest\Mapper\SerializerDiscovery`} discovers classes that implement {b`Tempest\Mapper\DynamicSerializer`} and registers them as [serializers](../2-features/01-mapper.md#casters-and-serializers).
+- {b`Tempest\View\ViewComponentDiscovery`} discovers `x-*.view.php` files and registers them as [view components](../1-essentials/02-views.md#view-components).
+- {b`Tempest\Vite\ViteDiscovery`} discovers `*.entrypoint.{ts,js,css}` files and register them as [entrypoints](../2-features/02-asset-bundling.md#entrypoints).
+- {b`Tempest\Auth\AccessControl\PolicyDiscovery`} discovers methods annotated with the {b`#[Tempest\Auth\AccessControl\Policy]`} attribute and registers them as [access control policies](../2-features/04-authentication.md#access-control).
+- {b`Tempest\Core\InsightsProviderDiscovery`} discovers classes that implement {b`Tempest\Core\InsightsProvider`} and registers them as insights providers, which power the `tempest about` command.
diff --git a/packages/core/src/Commands/DiscoveryGenerateCommand.php b/packages/core/src/Commands/DiscoveryGenerateCommand.php
index 00147bb94..6e14b26ce 100644
--- a/packages/core/src/Commands/DiscoveryGenerateCommand.php
+++ b/packages/core/src/Commands/DiscoveryGenerateCommand.php
@@ -17,8 +17,6 @@
use Tempest\Core\Kernel;
use Tempest\Core\Kernel\LoadDiscoveryClasses;
-use function Tempest\env;
-
if (class_exists(\Tempest\Console\ConsoleCommand::class)) {
final readonly class DiscoveryGenerateCommand
{
@@ -37,7 +35,7 @@ public function __construct(
)]
public function __invoke(): void
{
- $strategy = DiscoveryCacheStrategy::make(env('DISCOVERY_CACHE', default: $this->environment->requiresCaution()));
+ $strategy = DiscoveryCacheStrategy::resolveFromEnvironment();
if ($strategy === DiscoveryCacheStrategy::NONE) {
$this->info('Discovery cache disabled, nothing to generate.');
diff --git a/packages/core/src/DiscoveryCache.php b/packages/core/src/DiscoveryCache.php
index d42d17ea6..be6c20a6e 100644
--- a/packages/core/src/DiscoveryCache.php
+++ b/packages/core/src/DiscoveryCache.php
@@ -10,6 +10,7 @@
use Tempest\Discovery\Discovery;
use Tempest\Discovery\DiscoveryItems;
use Tempest\Discovery\DiscoveryLocation;
+use Tempest\Support\Filesystem;
use Throwable;
use function Tempest\internal_storage_path;
@@ -86,13 +87,10 @@ public function clear(): void
public function storeStrategy(DiscoveryCacheStrategy $strategy): void
{
- $dir = dirname(self::getCurrentDiscoverStrategyCachePath());
+ $path = self::getCurrentDiscoverStrategyCachePath();
- if (! is_dir($dir)) {
- mkdir($dir, recursive: true);
- }
-
- file_put_contents(self::getCurrentDiscoverStrategyCachePath(), $strategy->value);
+ Filesystem\create_directory_for_file($path);
+ Filesystem\write_file($path, $strategy->value);
}
public static function getCurrentDiscoverStrategyCachePath(): string
diff --git a/packages/core/src/DiscoveryCacheInitializer.php b/packages/core/src/DiscoveryCacheInitializer.php
index f0ebdb8a0..0370519a6 100644
--- a/packages/core/src/DiscoveryCacheInitializer.php
+++ b/packages/core/src/DiscoveryCacheInitializer.php
@@ -5,8 +5,7 @@
use Tempest\Container\Container;
use Tempest\Container\Initializer;
use Tempest\Container\Singleton;
-
-use function Tempest\env;
+use Tempest\Support\Filesystem;
final class DiscoveryCacheInitializer implements Initializer
{
@@ -24,13 +23,18 @@ private function resolveDiscoveryCacheStrategy(): DiscoveryCacheStrategy
return DiscoveryCacheStrategy::NONE;
}
- $current = DiscoveryCacheStrategy::make(env('DISCOVERY_CACHE', default: Environment::guessFromEnvironment()->requiresCaution()));
+ $current = DiscoveryCacheStrategy::resolveFromEnvironment();
if ($current === DiscoveryCacheStrategy::NONE) {
return $current;
}
- $original = DiscoveryCacheStrategy::make(@file_get_contents(DiscoveryCache::getCurrentDiscoverStrategyCachePath()));
+ $path = DiscoveryCache::getCurrentDiscoverStrategyCachePath();
+ $stored = Filesystem\exists($path)
+ ? Filesystem\read_file($path)
+ : null;
+
+ $original = DiscoveryCacheStrategy::resolveFromInput($stored);
if ($current !== $original) {
return DiscoveryCacheStrategy::INVALID;
diff --git a/packages/core/src/DiscoveryCacheStrategy.php b/packages/core/src/DiscoveryCacheStrategy.php
index b76c8fee5..a8ff3a1ec 100644
--- a/packages/core/src/DiscoveryCacheStrategy.php
+++ b/packages/core/src/DiscoveryCacheStrategy.php
@@ -4,14 +4,42 @@
namespace Tempest\Core;
+use function Tempest\env;
+
enum DiscoveryCacheStrategy: string
{
+ /**
+ * Discovery is completely cached and will not be re-run.
+ */
case FULL = 'full';
+
+ /**
+ * Vendors are cached, application discovery is re-run.
+ */
case PARTIAL = 'partial';
+
+ /**
+ * Discovery is not cached.
+ */
case NONE = 'none';
+
+ /**
+ * There is a mismatch between the stored strategy and the resolved strategy, discovery is considered as not cached.
+ */
case INVALID = 'invalid';
- public static function make(mixed $input): self
+ public static function resolveFromEnvironment(): self
+ {
+ $environment = Environment::guessFromEnvironment();
+
+ return static::resolveFromInput(env('DISCOVERY_CACHE', default: match (true) {
+ $environment->requiresCaution() => true,
+ $environment->isLocal() => 'partial',
+ default => false,
+ }));
+ }
+
+ public static function resolveFromInput(mixed $input): self
{
return match ($input) {
true, 'true', '1', 1, 'all', 'full' => self::FULL,
diff --git a/packages/core/src/FrameworkKernel.php b/packages/core/src/FrameworkKernel.php
index d1a4cf32e..46ac1b250 100644
--- a/packages/core/src/FrameworkKernel.php
+++ b/packages/core/src/FrameworkKernel.php
@@ -240,10 +240,8 @@ public function registerEmergencyExceptionHandler(): self
public function registerExceptionHandler(): self
{
- $environment = $this->container->get(Environment::class);
-
// During tests, PHPUnit registers its own error handling.
- if ($environment->isTesting()) {
+ if (Environment::guessFromEnvironment()->isTesting()) {
return $this;
}
diff --git a/tests/Integration/Core/DiscoveryCacheTest.php b/tests/Integration/Core/DiscoveryCacheTest.php
index 69fb4014f..864c1619e 100644
--- a/tests/Integration/Core/DiscoveryCacheTest.php
+++ b/tests/Integration/Core/DiscoveryCacheTest.php
@@ -2,8 +2,11 @@
namespace Tests\Tempest\Integration\Core;
+use PHPUnit\Framework\Attributes\PostCondition;
+use PHPUnit\Framework\Attributes\Test;
use Tempest\Core\CouldNotStoreDiscoveryCache;
use Tempest\Core\DiscoveryCache;
+use Tempest\Core\DiscoveryCacheStrategy;
use Tempest\Discovery\DiscoveryLocation;
use Tests\Tempest\Integration\Core\Fixtures\TestDiscovery;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
@@ -12,7 +15,15 @@
final class DiscoveryCacheTest extends FrameworkIntegrationTestCase
{
- public function test_exception_with_unserializable_discovery_items(): void
+ #[PostCondition]
+ protected function cleanup(): void
+ {
+ putenv('ENVIRONMENT=testing');
+ putenv('DISCOVERY_CACHE=true');
+ }
+
+ #[Test]
+ public function exception_with_unserializable_discovery_items(): void
{
$this->assertException(CouldNotStoreDiscoveryCache::class, function (): void {
$discoveryCache = $this->container->get(DiscoveryCache::class);
@@ -26,4 +37,49 @@ public function test_exception_with_unserializable_discovery_items(): void
]);
});
}
+
+ #[Test]
+ public function partial_locally(): void
+ {
+ putenv('ENVIRONMENT=local');
+ putenv('DISCOVERY_CACHE=null');
+
+ $this->assertSame(DiscoveryCacheStrategy::PARTIAL, DiscoveryCacheStrategy::resolveFromEnvironment());
+ }
+
+ #[Test]
+ public function overridable_locally(): void
+ {
+ putenv('ENVIRONMENT=local');
+ putenv('DISCOVERY_CACHE=false');
+
+ $this->assertSame(DiscoveryCacheStrategy::NONE, DiscoveryCacheStrategy::resolveFromEnvironment());
+ }
+
+ #[Test]
+ public function enabled_in_production(): void
+ {
+ putenv('ENVIRONMENT=production');
+ putenv('DISCOVERY_CACHE=null');
+
+ $this->assertSame(DiscoveryCacheStrategy::FULL, DiscoveryCacheStrategy::resolveFromEnvironment());
+ }
+
+ #[Test]
+ public function enabled_in_staging(): void
+ {
+ putenv('ENVIRONMENT=staging');
+ putenv('DISCOVERY_CACHE=null');
+
+ $this->assertSame(DiscoveryCacheStrategy::FULL, DiscoveryCacheStrategy::resolveFromEnvironment());
+ }
+
+ #[Test]
+ public function overridable_in_production(): void
+ {
+ putenv('ENVIRONMENT=production');
+ putenv('DISCOVERY_CACHE=partial');
+
+ $this->assertSame(DiscoveryCacheStrategy::PARTIAL, DiscoveryCacheStrategy::resolveFromEnvironment());
+ }
}