Internals

Discovery

Learn how Tempest automatically locates controller actions, event handlers, console commands, and other components of your application.

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.

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.

For instance, web routes are discovered based on route attributes:

app/HomeController.php
final readonly class HomeController
{
    #[Get(uri: '/home')]
    public function __invoke(): View
    {
        return view('home.view.php');
    }
}

Note that Tempest is able to cache discovery information to avoid any performance cost. Enabling this cache in production is highly recommended.

Built-in discovery classes

Most of Tempest's features are built on top of discovery. The following describes which discovery class is associated to which feature.

Implementing your own discovery

Discovering code in classes

Tempest will discover classes that implement Tempest\Discovery\Discovery. You may create one, and implement the discover() and apply methods.

The discover() method accepts a DiscoveryLocation and a ClassReflector parameter. You may use the latter to loop through a class' attributes, methods, parameters or anything else.

If you find what you are interested in, you may register it using $this->discoveryItems->add(). As an example, the following is a simplified version of the event bus discovery:

EventBusDiscovery.php
use Tempest\Discovery\Discovery;
use Tempest\Discovery\IsDiscovery;

final readonly class EventBusDiscovery implements Discovery
{
    // This provides the default implementation for `Discovery`'s internals
    use IsDiscovery;

    public function __construct(
        // Discovery classes are autowired,
        // so you can inject all dependencies you need
        private EventBusConfig $eventBusConfig,
    ) {
    }

    public function discover(DiscoveryLocation $location, ClassReflector $class): void
    {
        foreach ($class->getPublicMethods() as $method) {
            $eventHandler = $method->getAttribute(EventHandler::class);

            // Extra checks to determine whether
            // we can actually use the current method as an event handler

            // …

            // Finally, we add all discovery-related data into `$this->discoveryItems`:
            $this->discoveryItems->add($location, [$eventName, $eventHandler, $method]);
        }
    }

    // Next, the `apply` method is called whenever discovery is ready to be
    // applied into the framework. In this case, we want to loop over all
    // registered discovery items, and add them to the event bus config.
    public function apply(): void
    {
        foreach ($this->discoveryItems as [$eventName, $eventHandler, $method]) {
            $this->eventBusConfig->addClassMethodHandler(
                event: $eventName,
                handler: $eventHandler,
                reflectionMethod: $method,
            );
        }
    }
}

Discovering files

In some situations, you may want to not just discover classes, but also files. For instance, view files, front-end entrypoints or SQL migrations are not PHP classes, but still need to be discovered.

In this case, you may implement the additional Tempest\Discovery\DiscoversPath interface. It will allow a discovery class to discover all paths that aren't classes as well. As an example, below is a simplified version of the Vite discovery:

use Tempest\Discovery\Discovery;
use Tempest\Discovery\DiscoversPath;
use Tempest\Discovery\IsDiscovery;

final class ViteDiscovery implements Discovery, DiscoversPath
{
    use IsDiscovery;

    public function __construct(
        private readonly ViteConfig $viteConfig,
    ) {}

    // We are not discovering any class, so we return immediately.
    public function discover(DiscoveryLocation $location, ClassReflector $class): void
    {
        return;
    }

    // This method is called for every file in registered discovery locations.
    // We can use the `$path` to determine whether we are interested in it.
    public function discoverPath(DiscoveryLocation $location, string $path): void
    {
        // We are insterested in `.ts`, `.css` and `.js` files only.
        if (! ends_with($path, ['.ts', '.css', '.js'])) {
            return;
        }

        // These files need to be specifically marked as `.entrypoint`.
        if (! str($path)->beforeLast('.')->endsWith('.entrypoint')) {
            return;
        }

        $this->discoveryItems->add($location, [$path]);
    }

    // When discovery is cached, `discover` and `discoverPath` are not called.
    // Instead, `discoveryItems` is already fed with serialized data, which
    // we can use. In this case, we add the paths to the Vite config.
    public function apply(): void
    {
        foreach ($this->discoveryItems as [$path]) {
            $this->viteConfig->addEntrypoint($path);
        }
    }
}

Discovery in production

While discovery is a really powerful feature, it also comes with some performance considerations. In production environments, you need to make sure that the discovery workflow is cached. This is done by using the DISCOVERY_CACHE environment variable:

.env
DISCOVERY_CACHE=true

The most important step is to generate that cache. This is done by running the discovery:generate, which should be part of your deployment pipeline. Make sure to run it before any other Tempest command.

./tempest discovery:generate
 ℹ  Clearing existing discovery cache…
 ✓  Discovery cached has been cleared
 ℹ  Generating new discovery cache… (cache strategy used: all)
 ✓  Cached 1119 items

Discovery for local development

By default, the discovery cache is disabled in a development environment. Depending on your local setup, it is likely that you will not run into noticeable slowdowns. However, for larger projects, you might benefit from enabling a partial discovery cache:

.env
DISCOVERY_CACHE=partial

This caching strategy will only cache discovery for vendor files. For this reason, it is recommended to run discovery:generate after every composer update:

{
  "scripts": {
    "post-package-update": [
      "php tempest discovery:generate"
    ]
  }
}

Note that, if you've created your project using tempest/app, you'll have the post-package-update script already included. You may read the internal documentation about discovery to learn more.

Discovering files — Tempest