Discover

Canvas includes Discover, a Composer-aware service discovery system for automatic provider registration. Packages advertise themselves via composer.json metadata, and Discover loads, validates, and lazily instantiates them at runtime — no manual wiring required.

explanation

Core Concepts

  • Provider — A class implementing ProviderInterface that registers a service or capability. Providers carry metadata and optional config file paths.
  • ProviderDefinition — A value object describing a provider: its class name, family, metadata, and config files. Definitions are created by scanners before providers are instantiated.
  • Scanner — A strategy object that discovers providers. Two built-in types exist: ComposerScanner (reads composer.json / installed.php) and DirectoryScanner (traverses directories by reflection). Both implement ScannerInterface.
  • MetadataCollector — Implements MetadataScannerInterface. Reads arbitrary key/value data from the extra.discover section of installed packages — for example, controller or middleware paths — without creating provider instances.
  • Family — A logical group name (e.g. 'cache', 'canvas') used to partition providers and metadata. Filters at query time are applied against this label.

Setting Up Discover

Create a Discover instance, register scanners, then call discover():

use Quellabs\Discover\Discover;
use Quellabs\Discover\Scanner\ComposerScanner;

$discover = new Discover();
$discover->addScanner(new ComposerScanner('canvas'));
$discover->discover();

Multiple scanners can be registered. Each runs independently; results are merged into a single deduplicated pool of provider definitions.

The ComposerScanner

Reads the extra.discover section of every installed package's composer.json. Each package that wants to register a provider declares it under its family key:

{
    "extra": {
        "discover": {
            "canvas": {
                "provider": "Acme\\Cache\\RedisCacheProvider",
                "config": "config/cache.php"
            }
        }
    }
}

The scanner resolves provider class names, loads defaults via getDefaults() and metadata via getMetadata(), and produces ProviderDefinition objects. A ServiceDiscoveryPlugin Composer plugin generates a cached config/discovery-mapping.php file after every composer install / composer update, which ComposerScanner reads at runtime for fast O(1) lookup.

The DirectoryScanner

Traverses one or more directories and discovers any class that implements ProviderInterface:

use Quellabs\Discover\Scanner\DirectoryScanner;

$discover->addScanner(new DirectoryScanner(
    directories:   [__DIR__ . '/src/Providers'],
    pattern:       '/Provider$/',   // optional regex filter on class name
    defaultFamily: 'canvas',
));
$discover->discover();

The pattern parameter is a regular expression applied to fully qualified class names before reflection runs — cheap pre-filtering before the more expensive class_exists() and interface check. Pass null to include every class implementing ProviderInterface.

Strict mode: Both scanners accept a $strictMode constructor flag. In normal mode, unreadable directories, invalid class names, and reflection failures are logged as warnings and skipped. In strict mode the same conditions throw a RuntimeException.

Retrieving Providers

Providers are instantiated lazily — only when first accessed. Retrieve a single provider by class name:

$provider = $discover->get(RedisCacheProvider::class);
// Returns null if not found

Iterate all discovered providers one at a time using the generator-based accessor:

foreach ($discover->getProviders() as $provider) {
    ...
}

Check for existence or fetch the raw definition without triggering instantiation:

$discover->exists(RedisCacheProvider::class);          // bool
$discover->getDefinition(RedisCacheProvider::class);   // ProviderDefinition|null

Retrieve all definitions as a flat array without instantiating any providers. This is useful when you need to inspect or filter the discovered class names before deciding which providers to instantiate — for example, to check interface compliance or read annotations at scan time:

foreach ($discover->getDefinitions() as $key => $definition) {
    // $definition->className is available without triggering instantiation
    if (is_a($definition->className, MyInterface::class, true)) {
        $instance = $discover->get($definition->className);
    }
}

getDefinitions() returns the same ProviderDefinition objects that scanners produce, keyed by their unique definition key. No provider constructors are called until get(), getProviders(), or a query result is accessed.

Querying Providers

Use findProviders() to build a fluent query that filters definitions before instantiating anything:

$providers = $discover->findProviders()
    ->withFamily('cache')
    ->withCapability('distributed')
    ->withMinPriority(50)
    ->get();

Available filter methods on ProviderQuery:

  • withFamily(string $family) — Match the provider's family label. Only one family filter is active at a time; calling it again replaces the previous value.
  • withCapability(string $capability) — Require capabilities in metadata to contain the given string.
  • withMinPriority(int $priority) — Require priority in metadata to be ≥ the given value.
  • where(callable $filter) — Add a custom filter receiving array $metadata and returning bool.

For large result sets, use lazy() instead of get() to receive a generator and avoid instantiating all matching providers at once:

foreach ($discover->findProviders()->withFamily('cache')->lazy() as $provider) {
    ...
}

Writing a Provider

Extend AbstractProvider to implement your provider logic. Providers must be autoloadable and use a zero-argument constructor so they can be instantiated by Discover. Declare the provider in the package's composer.json so ComposerScanner can discover it automatically (see The ComposerScanner for the declaration format).

namespace Acme\Cache;

use Quellabs\Discover\Provider\AbstractProvider;

class RedisCacheProvider extends AbstractProvider {

    public static function getMetadata(): array {
        return [
            'capabilities' => ['distributed', 'persistent'],
            'priority'     => 100,
        ];
    }

    public function getConfig(): array {
        return $this->config;
    }

    public function setConfig(array $config): void {
        $this->config = $config;
    }
}

Providers receive configuration automatically during instantiation. Discover loads all configuration files declared in composer.json and merges them into a single configuration array. Each file is resolved relative to the project's root directory. If a sibling .local.php file exists alongside a configuration file (for example cache.local.php next to cache.php), it is merged afterward and overrides the base values, allowing environment-specific configuration to remain outside version control.

Reading Metadata

Use MetadataCollector alongside a MetadataScannerInterface scanner to gather non-provider data that packages advertise — such as controller or middleware directories — without instantiating providers:

use Quellabs\Discover\Scanner\MetadataCollector;

$discover->addScanner(new MetadataCollector('canvas'));
$discover->discover();

// All data for the 'canvas' family, grouped by package name
$byPackage = $discover->getFamilyMetadata('canvas');
// ['vendor/pkg' => ['controllers' => 'src/Controllers', 'middleware' => [...]]]

// Flat deduplicated list of values for a single key across all packages
$controllerPaths = $discover->getFamilyValues('canvas', 'controllers');
// ['/abs/path/src/Controllers', ...]

// Everything across every family and package
$all = $discover->getAllMetadata();

Values are returned as-is: scalars stay scalar, arrays stay arrays. The collector performs no path resolution — consuming code is responsible for interpreting values.

The ServiceDiscoveryPlugin

A plugin is included that generates a PHP mapping file after every composer install or composer update. The file is written to config/discovery-mapping.php relative to the project root. At runtime, ComposerInstalledLoader loads this file instead of traversing installed.json, reducing discovery to a single include operation rather than parsing JSON. To use a different file location, configure mapping-file in your project's composer.json. Both relative and absolute paths are supported:

{
    "extra": {
        "discover": {
            "mapping-file": "bootstrap/discovery-mapping.php"
        }
    }
}
Development packages are excluded: The discovery loader reads only the packages section from Composer metadata and ignores packages-dev. Providers declared in development dependencies are therefore not included in the generated discovery map.

Re-running Discovery

Calling discover() again resets all previously discovered providers and metadata before running the scanners fresh. Cached provider instances are also cleared:

$discover->discover(); // runs all registered scanners from scratch

To discard all state without re-scanning, call clearProviders():

$discover->clearProviders(); // empties definitions, instances, and metadata

ProviderValidator

Both scanners delegate class validation to ProviderValidator before creating any ProviderDefinition. The validator performs four checks in order:

  • The class name matches the valid PHP identifier pattern (guards against arbitrary class loading).
  • The class exists and can be autoloaded.
  • The class implements ProviderInterface.
  • The class is instantiable (not abstract, not an interface, constructor is public).

Any failure returns false and the class is silently skipped (or throws in strict mode). Validation is intentionally separate from instantiation — it runs at scan time, not at provider retrieval time.