TL;DR
- When one system has to build many kinds of app (Laravel, Django, Go, Node, static…), a growing
matchstatement 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
};
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;
}
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(/* ... */);
}
}
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);
}
}
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;
});
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()
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);
});
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)