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.
Core Concepts
- Provider — A class implementing
ProviderInterfacethat registers a service or capability. Providers carry metadata, default configuration, and optional config file paths. - ProviderDefinition — A value object describing a provider: its class name, family, metadata, defaults, 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(readscomposer.json/installed.php) andDirectoryScanner(traverses directories by reflection). Both implementScannerInterface. - MetadataCollector — Implements
MetadataScannerInterface. Reads arbitrary key/value data from theextra.discoversection 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
$strictModeconstructor 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 aRuntimeException.
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) {
$provider->boot();
}
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)— Requirecapabilitiesin metadata to contain the given string.withMinPriority(int $priority)— Requirepriorityin metadata to be ≥ the given value.where(callable $filter)— Add a custom filter receivingarray $metadataand returningbool.
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) {
$provider->boot();
}
Writing a Provider
Extend AbstractProvider and implement whatever boot logic the provider requires. The class must be autoloadable and have a zero-argument constructor. Declare it in the package's composer.json so ComposerScanner picks it up (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 static function getDefaults(): array {
return ['host' => '127.0.0.1', 'port' => 6379, 'ttl' => 3600];
}
public function boot(): void {
$host = $this->getConfigValue('host');
$port = $this->getConfigValue('port');
// ...
}
}
Providers receive merged configuration automatically during instantiation. Discover merges the values returned by getDefaults() with values loaded from any declared config files, with loaded values taking precedence. Config files are declared in the provider's ProviderDefinition (typically set by the scanner from composer.json). Each listed file is loaded from the project root. If a sibling .local.php file exists alongside a config file (e.g. cache.local.php next to cache.php), it is automatically merged on top — useful for per-environment overrides that are not committed to 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
The plugin is included in quellabs/discover and requires no configuration. After every composer install or composer update, it writes a PHP file mapping package names to their extra blocks at config/discovery-mapping.php relative to the project root. At runtime, ComposerInstalledLoader reads this file in preference to walking installed.json, making discovery a single include rather than a JSON parse.
To write the mapping file to a different location, set mapping-file in your project's composer.json. Both relative and absolute paths are accepted:
{
"extra": {
"discover": {
"mapping-file": "bootstrap/discovery-mapping.php"
}
}
}
Dev packages are excluded: The plugin reads only the
packageskey fromcomposer.lock, notpackages-dev. Providers declared in development dependencies are never included in the generated 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.