Laravel inspired package discovery for HTTP Fn

Usually, there's some wiring to do when you want to use packages with frameworks. Laravel manages to reduce the friction, in most cases, to a minimum with their package discovery feature.

When someone installs your package, you will typically want your service provider to be included in this list. Instead of requiring users to manually add your service provider to the list, you may define the provider in the extra section of your package's composer.json file.

Once your package has been configured for discovery, Laravel will automatically register its service providers and facades when it is installed, creating a convenient installation experience for your package's users.

This means that typically you don't have to do anything else but run composer require my-fav-package, and you are good to go.

I wanted the same DX for HTTP Fn.


How does it work

Because we don't have to think about it, it might appear magical compared to other frameworks where we have to take some additional steps. As long as a package developer follows the convention, it's no-config for us, the consumers.

The "package discovery" happens after a package is installed or removed with Composer. When something happens (events) in Composer, we can run some arbitrary code (command). Laravel is taking advantage of this.

Practically: after the installation or removal of a package, Laravel (1) loops over all the packages, (2) filters them down to the ones with the discoverable key, and (3) saves the list of "discovered packages". When Laravel is booted, (4) the stored list of discovered packages is merged with the explicitly defined packages, and then Laravel does what it does.

Here's how the "package discovering" looks like for HTTP Fn:

<?php

declare(strict_types=1);

namespace HttpFn\App\Composer;

use Composer\Script\Event;

class GenerateAutoDiscoveredFnPackageProviderJsonFile
{
    public const JSON_FILE_PATH = 'tmp/fn-package-providers.json';
    public const EXTRA_NAMESPACE_KEY = 'http-fn';
    public const EXTRA_FN_PROVIDER_KEY = 'fnProvider';

    public static function run(Event $event): void
    {
        $providers = [];
        $localPackages = $event->getComposer()->getRepositoryManager()->getLocalRepository()->getPackages();

        if (empty($localPackages)) {
            return;
        }

        foreach ($localPackages as $package) {
            $extra = $package->getExtra();
            $provider = $extra[self::EXTRA_NAMESPACE_KEY][self::EXTRA_FN_PROVIDER_KEY] ?? false;

            if (!$provider) {
                continue;
            }

            $providers[] = $provider;
        }

        if (empty($providers)) {
            return;
        }

        file_put_contents(
            self::JSON_FILE_PATH,
            json_encode(array_unique($providers))
        );
    }
}

In the composer.json this is registered as a command for the post-autoload-dump event:

{
    "scripts": {
        "post-autoload-dump": "HttpFn\\App\\Composer\\GenerateAutoDiscoveredFnPackageProviderClassNameJsonFile::run"
    }
}

And here is how the "package registration" looks like:

function withAutoDiscoveredFnPackageProviders($fnPackageProviders = []): array
{
    $jsonFile = '../' . GenerateAutoDiscoveredFnPackageProviderJsonFile::JSON_FILE_PATH;
    $jsonData = file_exists($jsonFile) ? file_get_contents($jsonFile) : false;

    if ($jsonData !== false) {
        $autoDiscoveredFnPackageProviders = json_decode($jsonData, true) ?? [];

        $fnPackageProviders = [
            ...$fnPackageProviders,
            ...$autoDiscoveredFnPackageProviders,
        ];
    }

    return $fnPackageProviders;
}

$fnPackageProviders = withAutoDiscoveredFnPackageProviders();

After the combined list of package providers, it's a matter of looping over them, checking if they are the right type, etc., and calling their boot method.


In Laravel, all this is a bit more complex, for good reasons. Check the PR introducing this feature if you are curious.