Adding a New Framework
Govard ships support for a growing list of frameworks — Magento 2, Mage-OS, Magento 1, OpenMage, Laravel, Symfony, Drupal, WordPress, Next.js, Emdash, Shopware, CakePHP, PrestaShop, Django, and more over time. This page documents how that support is structured internally, and what to touch to add a new one.
The Registry: internal/frameworks
Each framework has one small package under internal/frameworks/<name>/ that produces a types.FrameworkDefinition — a single struct that's the framework's identity card inside Govard:
// internal/frameworks/types/definition.go
type FrameworkDefinition struct {
Name string // canonical key, e.g. "magento2"
Aliases []string // e.g. "magento" -> "magento2"
DisplayName string // human-facing label, e.g. "Magento 2"
Config engine.FrameworkConfig // PHP/Node version, nginx template, DB defaults...
Manifest engine.FrameworkManifestConfig // sync excludes, sensitive tables, feature flags
Detect engine.DetectionSpec // composer/package.json/auth.json/file-path signatures
Bootstrap BootstrapFactory // func(bootstrap.Options) bootstrap.FrameworkBootstrap
BaseURLManager func() tunnel.BaseURLManager // nil if the framework needs no tunnel base-URL rewriting
SupportsBootstrap bool // allow `govard bootstrap` (remote/clone workflow)
SupportsFreshInstall bool // allow `govard bootstrap --fresh`
}internal/frameworks/all.go's init() calls Register(<pkg>.Definition()) for each registered framework package, in a specific order (more on why, below), populating a package-level registry that the rest of Govard reads through three small, focused files:
| File | Purpose |
|---|---|
internal/frameworks/registry.go | Get(name), All(), Normalize(name) — the registry itself, alias resolution |
internal/frameworks/run.go | RunBootstrap(name, opts) — dispatches to def.Bootstrap instead of a switch |
internal/frameworks/base_url.go | NewBaseURLManager(name) — dispatches to def.BaseURLManager, falls back to tunnel.NoopManager |
Everything that reads framework data by name — govard bootstrap's allowlists, govard tunnel's base-URL rewriting, the bootstrap dispatcher — goes through one of those three files instead of a hardcoded switch framework { case "magento2": ... }. Adding a framework to the registry means it automatically participates in all three, no switch to edit.
What's not on the registry yet
Two things are still framework-name switches, on purpose, not by omission:
internal/cmd/bootstrap_fresh_install.go/bootstrap_remote.go— the actual fresh-install/clone orchestration (which shell commands run, in what order, with whichbootstrap.Optionsfields populated). Six frameworks (Symfony, Laravel, Drupal, WordPress, Shopware, CakePHP) share one genericCreateProject → Install → govard config autosequence via a small lookup table (genericFreshInstallFrameworksinbootstrap_fresh_install.go) — but OpenMage, Next.js, Emdash, and the Magento family each do materially different things (git clone vs.composer create-projectvs. HTTP download; admin-user creation;.envgeneration; sample data), not just "call a different constructor." That can't be data-driven without either forcing frameworks into an ill-fitting common shape or restructuring howinternal/cmdandinternal/frameworksdepend on each other. See the "Fresh-install orchestration" section below for what a new framework needs here.engine.GetFrameworkConfig/engine.GetFrameworkManifestConfig(internal/engine/framework_config.go,internal/engine/framework_manifest.json) — the actual PHP/Node/DB version defaults and sync/manifest data. The registry'sConfig/Manifestfields are a read-through of these —internal/engineremains the authoritative data source;internal/frameworkscomposes it into one struct per framework alongside detection and dispatch.
Adding a new framework: a checklist
Say you're adding a fictional framework called whimsy. Every step below has a real, working example already in the codebase — the file references point at the closest existing analog to copy from.
1. Runtime defaults — internal/engine/framework_config.go
Add a FrameworkConfig entry (PHP/Node version, nginx template, DB engine/version, includes list). Copy the closest existing framework's shape — e.g. "cakephp" for a vanilla PHP+MariaDB stack, "nextjs" for a Node-only one with no DB.
2. Manifest data — internal/engine/framework_manifest.json
Add an entry under frameworks.whimsy: sync excludes (local_media/remote_media paths, web-root candidates), sensitive/ignored DB tables, and the features block:
"whimsy": {
"paths": { "local_media": "public/uploads", "remote_media": "public/uploads", "web_root_candidates": [] },
"features": {
"requires_running_env_for_fresh_install": false,
"supports_post_clone": true
}
}requires_running_env_for_fresh_install controls whether govard bootstrap --fresh starts containers before or after running CreateProject — see the gotcha about this below before setting it true.
3. Compose blueprint — internal/blueprints/files/whimsy/
A services.yml (Docker Compose fragment) rendered as a Go template — copy the closest analog (internal/blueprints/files/nextjs/services.yml for a Node runtime, internal/blueprints/files/cakephp/ for PHP). Not every framework needs its own directory: Mage-OS reuses Magento 2's compose/nginx/Varnish blueprint outright (see varnishTemplateFramework in internal/engine/render.go) since it's a drop-in fork with the same runtime shape.
4. Bootstrap implementation — internal/engine/bootstrap/whimsy.go
Implement the FrameworkBootstrap interface (internal/engine/bootstrap/base.go):
type FrameworkBootstrap interface {
Name() string
SupportsFreshInstall() bool
SupportsClone() bool
FreshCommands() []string // human-readable summary, not necessarily what actually runs
CreateProject(projectDir string) error
Install(projectDir string) error
Configure(projectDir string) error
PostClone(projectDir string) error
}Copy internal/engine/bootstrap/cakephp.go for a PHP framework using the shared runStagedCreateProject helper (internal/engine/bootstrap/staged_project.go), or internal/engine/bootstrap/emdash.go for a framework whose CreateProject doesn't need any container at all (plain HTTP download).
If your framework needs to run a CLI tool (npx, composer, etc.) to scaffold the project, it must do so inside a container — never assume host tooling is present. See the "Container execution" gotcha below; it's the single most important lesson from this system's history.
5. Wire it into the registry — internal/frameworks/whimsy/whimsy.go
package whimsy
import (
"govard/internal/engine"
"govard/internal/engine/bootstrap"
"govard/internal/frameworks/types"
)
func Definition() types.FrameworkDefinition {
config, _ := engine.GetFrameworkConfig("whimsy")
manifest, _ := engine.GetFrameworkManifestConfig("whimsy")
return types.FrameworkDefinition{
Name: "whimsy",
DisplayName: "Whimsy",
Config: config,
Manifest: manifest,
Detect: engine.DetectionSpec{
ComposerPackages: []string{"whimsy/framework"}, // or PackageJSONDeps, AuthJSONHosts, FilePaths
},
Bootstrap: func(opts bootstrap.Options) bootstrap.FrameworkBootstrap {
return bootstrap.NewWhimsyBootstrap(opts)
},
SupportsFreshInstall: true,
SupportsBootstrap: true, // only if it supports the remote/clone workflow too
}
}Only set BaseURLManager if the framework needs specialized base-URL rewriting for govard tunnel (most don't — the default tunnel.NoopManager is a no-op, which is correct for anything that doesn't store its own base URL in the database or a config file).
6. Register it — internal/frameworks/all.go
Add the import and one Register(whimsy.Definition()) call inside init(). Position matters: detection walks the registered frameworks in registration order and returns the first match, so a framework whose detection signature could also match another framework's must be registered in the right relative position. The existing comment in all.go documents the one known case (Emdash before Next.js, preserving a legacy detector's tie-break).
7. Fresh-install / clone orchestration — internal/cmd
This is the part that stays a switch (see "What's not on the registry yet," above):
- If
whimsyfits the genericCreateProject → Install → govard config autoshape, add one line togenericFreshInstallFrameworksinbootstrap_fresh_install.go(amap[string]struct{ needsDB, needsDomain bool }) instead of writing a whole function. - If it needs bespoke steps, add a
case "whimsy":torunBootstrapFrameworkFreshInstall's switch calling a newrunBootstrapWhimsyFreshInstallfunction — copyrunBootstrapOpenMageFreshInstallorrunBootstrapNextJSFreshInstallas a starting shape. - If it supports the remote/clone workflow (
SupportsBootstrap: true, not just fresh-install), it's picked up automatically bybootstrap_remote.go's post-clone dispatch (bootstrapPostCloneDefinition) — no switch to edit there unless it's part of the Magento family (which is handled by a separate, earlier branch keyed onengine.IsMagento2Family).
8. Docs
Add a row to the support/runtime-defaults tables and a short section in docs/reference/frameworks.md (and the Vietnamese mirror, docs/vi/reference/frameworks.md).
9. Tests
tests/framework_detection_test.go— aTestWhimsyDiscoverymatching whateverDetectsignature you used.tests/framework_definitions_test.goor a newwhimsy-specific test file — assertDefinition()'sConfig/Manifest/Bootstrapare populated as expected.tests/framework_snapshot_test.go— this is a golden-snapshot regression net covering every registered framework automatically (blueprint rendering,FreshCommands(), resolved config/profile, manifest/DB-credential defaults) viaallFrameworkNames; registeringwhimsyinall.gomakes it start running, but its golden fixtures undertests/testdata/framework_snapshots/whimsy/won't exist yet. Generate them once you're confident the rendered output is correct:bashUPDATE_GOLDEN=1 go test ./tests/... -run TestFrameworkSnapshotAlways review the generated fixture diff before committing it —
UPDATE_GOLDEN=1writes whatever the code currently produces, correct or not.
10. Validate for real
Unit tests check that rendering and dispatch produce the expected output — they don't catch a container that can't actually reach the internet, an image that's missing a binary, or a race between a container starting and it actually being ready to serve traffic. Before considering the framework done, actually run it:
mkdir -p /tmp/whimsy-test && cd /tmp/whimsy-test
govard bootstrap --framework whimsy --fresh --yes
curl -sk -o /dev/null -w '%{http_code}\n' https://whimsy-test.test/ # expect 200, not a docker/proxy error
govard env downThis isn't a formality — every real bug found while building this registry (a Mage-OS auto-configuration step silently using Magento 2's DB credentials; Next.js's CreateProject depending on the host's npm install; a proxy-registration race against a container that wasn't ready yet) was invisible to go test ./... and only showed up by actually running the command and checking the result.
Gotchas learned the hard way
Container execution, not host execution
Every FrameworkBootstrap.CreateProject/Install that shells out to a CLI tool (composer, npx, npm) must run that tool inside a container, never via a bare exec.Command on the host. PHP frameworks do this via bootstrap.Options.Runner (a func(command string) error closure that internal/cmd wires to runPHPContainerShellCommand, exec'ing into the already-running PHP container). Next.js originally ran npx create-next-app directly on the host — this meant its fresh-install depended on whatever npm/node happened to be installed (and configured) on the developer's machine, completely outside Govard's control. On one real machine, a stray global ~/.npmrc setting broke every Next.js fresh-install silently (the container came up, govard bootstrap reported success, but the app was never actually installed — next: not found at runtime).
The fix (internal/cmd/bootstrap.go's nodeCreateProjectRunner) runs the scaffolding command in a throwaway docker run --rm -v <projectDir>:/app node:<version> ... container instead — independent of both the host environment and of whether any compose-managed service is running yet.
Don't assume a compose service is up yet
A tempting alternative to the throwaway-container approach above is to exec into the framework's own long-lived "web" service container instead (matching the PHP pattern). This works only if that container is already running by the time CreateProject executes, which for most frameworks it isn't — govard bootstrap --fresh runs fresh-install before env up for anything whose manifest doesn't set requires_running_env_for_fresh_install: true. Flipping that flag to force env-up first introduces a subtler problem: the container starts running its normal long-lived command (e.g. npm run dev) against what is still an empty project directory, so it exits immediately — and the bootstrap pipeline's domain/proxy-registration step runs during that same window, registering a route to a container that isn't actually serving anything yet. The registration silently succeeds, but the reverse proxy never gets a working backend, and the very first https://<project>.test/ request 502s until a manual env down && env up.
If a framework genuinely needs its long-lived service container up before CreateProject can run inside it, that container's startup command needs to tolerate an empty/partial project directory (loop waiting for a marker file, or similar) and the domain-registration timing needs to happen after the app is actually serving — not just after the container process started. Emdash sidesteps this entirely: its CreateProject needs no container (a plain HTTP tarball download), and its compose command already has an "install if node_modules missing" guard for defense, but never a "wait for files to exist" one, because by the time its container starts, the files are already there.
Family membership isn't automatic
Mage-OS is a drop-in fork of Magento 2 and reuses most of its runtime behavior, but "reuses most of" is not "reuses all of" — DB credential defaults, the search-engine version gate, and the exec user are each their own decision point, and each one needs an explicit check. engine.IsMagento2Family(framework) / engine.Magento2FamilyDisplayName(framework) (internal/engine/framework_family.go) exist as the single place that decision lives, applied at every call site that used to check framework == "magento2" literally. When adding a framework that's a close variant of an existing one, grep for every == "<existing-framework>" string comparison in internal/cmd and internal/engine and decide, case by case, whether the new framework belongs on each check — don't assume "looks similar" implies "behaves identically everywhere." A real bug shipped exactly this way: Mage-OS's setup:config:set auto-configuration step used Magento 2's hardcoded "magento"/"magento"/"magento" DB credentials instead of Mage-OS's own "mageos"/"mageos"/"mageos", because that one call site was missed when Mage-OS was added — caught only by actually running a fresh Mage-OS bootstrap end-to-end.