DEV Community

Cover image for One interface, many runtimes: a build-preset registry in Laravel
Nasrul Hazim
Nasrul Hazim

Posted on

One interface, many runtimes: a build-preset registry in Laravel

TL;DR

  • When one system has to build many kinds of app (Laravel, Django, Go, Node, static…), a growing match statement becomes the bottleneck.
  • The fix: a preset contract (each runtime knows how to build itself) + a registry (resolves the right one).
  • The same shape solves provider parity — Swarm and Kubernetes behind one driver interface, so the caller never branches on infra.

I was working on a system that builds someone's app into a deployable image — and "build it" means something different for Laravel vs Django vs a static site. Here's the pattern that kept that difference from leaking everywhere.

The smell: one match to rule them all

The naive version is a switch that grows forever:

$image = match ($runtime) {
    'laravel' => $this->buildLaravel($ctx),
    'django'  => $this->buildDjango($ctx),
    'go'      => $this->buildGo($ctx),
    // ...and it keeps growing
};
Enter fullscreen mode Exit fullscreen mode

Every new runtime edits this method. Every runtime's quirks pile into one class. Testing one means booting all of them.

Step 1: a contract per capability

Give "a thing that can build a runtime" a contract. Small and honest — what it must do, not how.

interface BuildPreset
{
    public function supports(string $runtime): bool;

    public function build(BuildContext $context): BuildResult;
}
Enter fullscreen mode Exit fullscreen mode

Now each runtime is its own class, and its quirks stay local:

final class LaravelPreset implements BuildPreset
{
    public function supports(string $runtime): bool
    {
        return $runtime === 'laravel';
    }

    public function build(BuildContext $context): BuildResult
    {
        // create .env, generate APP_KEY at build time,
        // install deps, warm caches — Laravel-specific setup
        return new BuildResult(/* ... */);
    }
}
Enter fullscreen mode Exit fullscreen mode

That .env + APP_KEY detail is a real one: a Laravel app that boots without a key fails in confusing ways, so the preset owns that step instead of hoping something upstream did it.

Step 2: a registry to resolve the right one

The registry holds the presets and picks the match. The caller asks for a runtime and gets a builder — it never knows the concrete class.

final class BuildPresetRegistry
{
    /** @var array<int, BuildPreset> */
    private array $presets = [];

    public function register(BuildPreset $preset): void
    {
        $this->presets[] = $preset;
    }

    public function resolve(string $runtime): BuildPreset
    {
        foreach ($this->presets as $preset) {
            if ($preset->supports($runtime)) {
                return $preset;
            }
        }

        throw new UnsupportedRuntimeException($runtime);
    }
}
Enter fullscreen mode Exit fullscreen mode

Wire the presets in a service provider, and adding a runtime is now: write one class, register it. Nothing else moves.

$this->app->singleton(BuildPresetRegistry::class, function () {
    $registry = new BuildPresetRegistry();

    foreach ([LaravelPreset::class, DjangoPreset::class, GoPreset::class] as $preset) {
        $registry->register(app($preset));
    }

    return $registry;
});
Enter fullscreen mode Exit fullscreen mode

Same shape, different problem: provider parity

The exact pattern solved infra too. Deploying to Docker Swarm and to Kubernetes are different mechanics — but the operations are the same: provision a database, stand up a reverse proxy, run a workload. So a driver contract fronts both:

        ProviderDriver (interface)
        /                        \
 SwarmProviderDriver        K8sProviderDriver
   - provisionDatabase()      - provisionDatabase()
   - runWorkload()            - runWorkload()
   - reverseProxy()           - reverseProxy()
Enter fullscreen mode Exit fullscreen mode

A resolver picks the driver from config, and the pipeline code calls provisionDatabase() without ever knowing which backend answers. Swap infra by swapping the driver, not the callers.

Why it's worth the ceremony

Naive match Contract + registry
One class grows forever One small class per runtime
New runtime edits shared code New runtime = add + register
Hard to test in isolation Each preset unit-tested alone
Quirks bleed together Quirks stay local

Testing gets pleasant, too — you fake one preset and never touch the rest:

it('resolves the laravel preset', function () {
    $registry = new BuildPresetRegistry();
    $registry->register(new LaravelPreset());

    expect($registry->resolve('laravel'))->toBeInstanceOf(LaravelPreset::class);
});

it('throws on an unknown runtime', function () {
    expect(fn () => (new BuildPresetRegistry())->resolve('cobol'))
        ->toThrow(UnsupportedRuntimeException::class);
});
Enter fullscreen mode Exit fullscreen mode

Takeaway

Any time you catch yourself writing a match that will clearly keep growing — build targets, payment gateways, infra providers, export formats — reach for a contract plus a registry. It's the driver pattern Laravel itself uses for cache, queue, and filesystem, and it's just as useful in your own domain.

Top comments (0)