One Site, Many Packages: Developing Drupal Recipes and Modules Next to the Site That Uses Them

Hand using a stylus on a laptop with digital AI graphics overlay.

In the last post about GitLab's Composer Registry I went through how to publish custom Drupal modules and recipes as proper Composer packages. That setup works. It's been running for months. But it left me with a problem I didn't see coming until I was deep into a real migration project.

Once every recipe, module and theme is its own tagged package, editing them becomes expensive.

Say I need to add one line to a recipe. With a registry-based setup, the honest workflow is: edit the file, commit, tag, push, wait for the pipeline to publish, then composer update on the site to pull the new version. For a one-line change. And if the change was wrong — which, let's be realistic, it often is on the first attempt — I do the whole thing again with a new tag. I burned through half a dozen patch versions of one recipe in an afternoon that way, and every one of them is permanently in the registry, documenting my confusion for posterity.

So I needed a way to work on a package in place, as if it were part of the site, and then put it back on the registry when I'm done. That's what this post is about: the directory layout that makes it possible, and the three ddev commands that drive it.

The layout

Everything lives side by side in one parent folder. The site is a normal DDEV project; the packages are ordinary git repositories sitting next to it.

~/websites/hi/
├── new-hi-multilingual/          ← the site (the DDEV project)
│   ├── .ddev/
│   │   ├── commands/host/
│   │   │   ├── localize
│   │   │   ├── release
│   │   │   └── localized
│   │   └── docker-compose.siblings.yaml
│   ├── composer.json
│   ├── recipes/                  ← Composer installs recipes here
│   └── web/
│       ├── modules/custom/
│       └── themes/custom/
├── hi_recipes/                   ← one git repo per recipe   (hi-recipes/*)
│   ├── site_install/
│   ├── page/
│   ├── news/
│   └── … about a hundred more
├── hi_themes/                    ← one git repo per theme    (hi-themes/*)
│   └── hi_main_theme/
├── hi_modules/                   ← one git repo per module   (hi-modules/*)
│   ├── hi_manual_migration/
│   ├── hi_layouts/
│   └── …
└── documentation/

The site itself is deliberately boring. Its composer.json points at the registry:

"repositories": {
    "drupal": {
        "type": "composer",
        "url": "https://packages.drupal.org/8"
    },
    "hi-repository": {
        "type": "composer",
        "url": "https://web-gitlab.example.com/api/v4/group/2/-/packages/composer/packages.json"
    }
}

And then it requires exactly one of my packages:

"require": {
    "hi-recipes/site_install": "^1"
}

That's it. One line. site_install is a recipe whose whole job is to require and apply all the others, so the entire site — a hundred-odd recipes, the theme, every custom module — arrives transitively from that single constraint. When I clone this project on another machine and run ddev composer install, I get the whole stack from the registry with no local dependencies whatsoever.

That property is the one I care most about, and it's the reason I didn't just use Composer path repositories permanently. Path repos would mean the site only builds if you happen to have all the sibling folders checked out, in the right place, on the right branch. That's fine for me on my laptop and useless for anyone else, including CI. Registry by default, local only when I'm actually editing something. That's the whole design.

ddev localize

Here's the command that swaps one package from the registry to the folder next door:

ddev localize news
ddev localize hi-modules/hi_manual_migration

It figures out which sibling tree the package lives in, adds a path repository to the site's composer.json with symlinking turned on, pins the require to that local copy, and runs composer update for just that package. Afterwards, recipes/news is a symlink into hi_recipes/news, and I can edit the recipe with my editor and re-apply it without touching git at all.

#!/usr/bin/env bash

## Description: Symlink a local hi-recipes/hi-themes/hi-modules package for live editing
## Usage: localize <name|vendor/name>
## Example: "ddev localize news"  or  "ddev localize hi-modules/hi_manual_migration"

set -euo pipefail

name="${1:-}"
approot="${DDEV_APPROOT:-$PWD}"
parent="$(cd "$approot/.." && pwd)"

if [ -z "$name" ]; then
  echo "Usage: ddev localize <name|vendor/name>"
  echo "  e.g. ddev localize news"
  echo "  e.g. ddev localize hi-modules/hi_manual_migration   (when a short name is ambiguous)"
  exit 1
fi

# Resolve which sibling tree holds the package -> sets SRCBASE and SHORT.
# Accepts a bare short name (searched across all trees; errors if ambiguous)
# or a fully-qualified vendor/name (hi-recipes|hi-themes|hi-modules).
SRCBASE=""; SHORT=""
if [ "${name#*/}" != "$name" ]; then
  vendor="${name%%/*}"; SHORT="${name##*/}"
  case "$vendor" in
    hi-recipes) SRCBASE=hi_recipes ;;
    hi-themes)  SRCBASE=hi_themes ;;
    hi-modules) SRCBASE=hi_modules ;;
    *) echo "ERROR: unknown vendor '$vendor' (expected hi-recipes / hi-themes / hi-modules)"; exit 1 ;;
  esac
  [ -d "$parent/$SRCBASE/$SHORT/.git" ] || { echo "ERROR: no git repo at $parent/$SRCBASE/$SHORT"; exit 1; }
else
  SHORT="$name"; found=""
  for base in hi_recipes hi_themes hi_modules; do
    [ -d "$parent/$base/$name/.git" ] && found="$found $base"
  done
  set -- $found
  if [ "$#" -eq 0 ]; then
    echo "ERROR: no git repo named '$name' under $parent/{hi_recipes,hi_themes,hi_modules}"; exit 1
  elif [ "$#" -gt 1 ]; then
    echo "ERROR: '$name' is ambiguous — exists in:$found"
    echo "Re-run with the vendor prefix, e.g.: ddev localize ${1/hi_/hi-}/$name"
    exit 1
  fi
  SRCBASE="$1"
fi

srcdir="$parent/$SRCBASE/$SHORT"
url="../$SRCBASE/$SHORT"
pkg="$(python3 -c "import json; print(json.load(open('$srcdir/composer.json'))['name'])")"
branch="$(git -C "$srcdir" rev-parse --abbrev-ref HEAD)"
version="$(python3 -c "import json; print(json.load(open('$srcdir/composer.json')).get('version') or '')")"

echo "Localizing $pkg"
if [ -n "$version" ]; then
  # Package declares a version -> the path repo serves that concrete version,
  # which already satisfies the ^1 constraints. No dev alias needed.
  constraint="$version"
  echo "  source : $SRCBASE/$SHORT  (declares version $version; served directly by the path repo)"
else
  # No version field -> the path repo serves dev-<branch>; alias it to the
  # latest git tag so it satisfies the ^1 constraints across the graph.
  tag="$(git -C "$srcdir" describe --tags --abbrev=0 2>/dev/null || true)"
  if [ -z "$tag" ]; then
    echo "ERROR: $srcdir has neither a composer 'version' nor a git tag; cannot satisfy its version constraint."
    exit 1
  fi
  constraint="dev-$branch as $tag"
  echo "  source : $SRCBASE/$SHORT  (branch dev-$branch, aliased to $tag)"
fi

python3 - "$approot/composer.json" "$pkg" "$url" "$constraint" <<'PY'
import json, sys, collections
f, pkg, url, constraint = sys.argv[1:5]
data = json.load(open(f, encoding="utf-8"), object_pairs_hook=collections.OrderedDict)
repos = data.get("repositories", collections.OrderedDict())
key = "localdev-" + pkg
merged = collections.OrderedDict()
merged[key] = collections.OrderedDict([
    ("type", "path"), ("url", url),
    ("options", collections.OrderedDict([("symlink", True)])),
])
for k, v in repos.items():
    if k != key:
        merged[k] = v
data["repositories"] = merged
data.setdefault("require", collections.OrderedDict())[pkg] = constraint
json.dump(data, open(f, "w", encoding="utf-8"), indent=4, ensure_ascii=False)
open(f, "a", encoding="utf-8").write("\n")
PY

ddev composer update "$pkg"
echo
echo "✔ $pkg is symlinked from $SRCBASE/$SHORT."
echo "  Edit it in $srcdir and commit/tag/push there when done, then: ddev release $pkg"

A few things in there earned their place the hard way.

The path repo is inserted first, not appended. Composer resolves repositories in order, and the local copy has to win over the registry. Appending it looks like it works right up until the day it silently doesn't.

The version handling is the fiddly bit, and it's worth understanding. A Composer path repository versions the package by branch — you get dev-main. But everything in my graph is constrained to ^1, and dev-main doesn't satisfy ^1. So the script has two modes. If the package's composer.json declares a version field, the path repo serves that concrete version and it just satisfies the constraint. If it doesn't — which is what I now prefer, because a stale version field beating your git tags is a genuinely annoying afternoon — the script reads the latest git tag and aliases the branch to it: dev-main as 1.0.12. Composer then treats the symlinked folder as if it were that release.

Short names, when they're unambiguous. I have a hi_manual_migration that is both a recipe and a module. Typing ddev localize hi_manual_migration there would be a coin flip, so the script refuses and tells me to add the vendor prefix. I'd rather be nagged than localize the wrong package and spend twenty minutes wondering why my edits do nothing.

The part that isn't in the script

Composer creates relative symlinks. recipes/news becomes ../../hi_recipes/news, which points clean outside the project root. On the host that's fine. Inside the DDEV web container it's a dangling link, because the container has no idea anything exists above /var/www/html.

That's what this file is for:

# Bind-mount the sibling recipe/theme git checkouts into the web container so
# that the relative symlinks Composer creates from the `path` repositories
# resolve identically on the host and inside the container.
#
#   recipes/<name>            -> ../../hi_recipes/<name>     => /var/www/hi_recipes/<name>
#   web/themes/custom/<name>  -> ../../../../hi_themes/<name>  => /var/www/hi_themes/<name>
#   web/modules/custom/<name> -> ../../../../hi_modules/<name> => /var/www/hi_modules/<name>
services:
  web:
    volumes:
      - "${DDEV_APPROOT}/../hi_recipes:/var/www/hi_recipes"
      - "${DDEV_APPROOT}/../hi_themes:/var/www/hi_themes"
      - "${DDEV_APPROOT}/../hi_modules:/var/www/hi_modules"

DDEV mounts the project at /var/www/html, so ../hi_recipes from there lands at /var/www/hi_recipes — exactly where the bind mount puts it. The relative path resolves to the same place on both sides, and drush inside the container reads the same files my editor is writing.

Using ${DDEV_APPROOT} rather than an absolute path matters if anyone else ever uses this: it works for whoever keeps the sibling folders next to the project, wherever their home directory happens to be. And when nothing is localized, these mounts just sit there doing nothing.

ddev release

The other direction. This removes the path repository and the pinned require, then updates the package so it comes from the registry again.

#!/usr/bin/env bash

## Description: Return a localized package to its released registry version (undo `ddev localize`)
## Usage: release <name|vendor/name>
## Example: "ddev release news"  or  "ddev release hi-modules/hi_manual_migration"

set -euo pipefail

name="${1:-}"
approot="${DDEV_APPROOT:-$PWD}"
parent="$(cd "$approot/.." && pwd)"

if [ -z "$name" ]; then
  echo "Usage: ddev release <name|vendor/name>"
  echo "  e.g. ddev release news"
  echo "  e.g. ddev release hi-modules/hi_manual_migration"
  exit 1
fi

# Resolve which sibling tree holds the package (same rules as `localize`).
SRCBASE=""; SHORT=""
if [ "${name#*/}" != "$name" ]; then
  vendor="${name%%/*}"; SHORT="${name##*/}"
  case "$vendor" in
    hi-recipes) SRCBASE=hi_recipes ;;
    hi-themes)  SRCBASE=hi_themes ;;
    hi-modules) SRCBASE=hi_modules ;;
    *) echo "ERROR: unknown vendor '$vendor' (expected hi-recipes / hi-themes / hi-modules)"; exit 1 ;;
  esac
  [ -d "$parent/$SRCBASE/$SHORT/.git" ] || { echo "ERROR: no git repo at $parent/$SRCBASE/$SHORT"; exit 1; }
else
  SHORT="$name"; found=""
  for base in hi_recipes hi_themes hi_modules; do
    [ -d "$parent/$base/$name/.git" ] && found="$found $base"
  done
  set -- $found
  if [ "$#" -eq 0 ]; then
    echo "ERROR: no git repo named '$name' under $parent/{hi_recipes,hi_themes,hi_modules}"; exit 1
  elif [ "$#" -gt 1 ]; then
    echo "ERROR: '$name' is ambiguous — exists in:$found"
    echo "Re-run with the vendor prefix, e.g.: ddev release ${1/hi_/hi-}/$name"
    exit 1
  fi
  SRCBASE="$1"
fi

pkg="$(python3 -c "import json; print(json.load(open('$parent/$SRCBASE/$SHORT/composer.json'))['name'])")"

removed="$(python3 - "$approot/composer.json" "$pkg" <<'PY'
import json, sys, collections
f, pkg = sys.argv[1:3]
data = json.load(open(f, encoding="utf-8"), object_pairs_hook=collections.OrderedDict)
changed = False
repos = data.get("repositories", collections.OrderedDict())
key = "localdev-" + pkg
if key in repos:
    del repos[key]; changed = True
req = data.get("require", collections.OrderedDict())
if pkg in req:
    del req[pkg]; changed = True
if changed:
    json.dump(data, open(f, "w", encoding="utf-8"), indent=4, ensure_ascii=False)
    open(f, "a", encoding="utf-8").write("\n")
print("yes" if changed else "no")
PY
)"

if [ "$removed" != "yes" ]; then
  echo "$pkg was not localized (nothing to release)."
  exit 0
fi

echo "Releasing $pkg back to its registry version..."
ddev composer update "$pkg"
echo
echo "✔ $pkg now resolves from the GitLab registry again (real, non-symlinked copy)."

Note that it deletes the require line entirely rather than putting ^1 back. Almost none of these packages are direct requires of the site — they arrive through site_install — so removing the line restores the graph to its natural shape. Only site_install itself is a genuine direct dependency.

And there's a small guard at the end that has saved me from myself more than once: if the package wasn't localized, the command says so and exits 0 instead of quietly running a composer update I didn't intend.

ddev localized

The boring one, and the one I use most:

#!/usr/bin/env bash

## Description: List packages currently localized for live editing
## Usage: localized
## Example: "ddev localized"

set -euo pipefail
approot="${DDEV_APPROOT:-$PWD}"

python3 - "$approot/composer.json" <<'PY'
import json, sys, collections
data = json.load(open(sys.argv[1], encoding="utf-8"), object_pairs_hook=collections.OrderedDict)
repos = data.get("repositories", {})
req = data.get("require", {})
rows = []
for k, v in repos.items():
    if k.startswith("localdev-") and isinstance(v, dict) and v.get("type") == "path":
        pkg = k[len("localdev-"):]
        rows.append((pkg, v.get("url", "?"), req.get(pkg, "?")))
if not rows:
    print("No packages are localized — everything resolves from the registry.")
else:
    print("Localized packages (symlinked for live editing):\n")
    w = max(len(p) for p, _, _ in rows)
    for pkg, url, constraint in rows:
        print(f"  {pkg:<{w}}  <- {url}   ({constraint})")
PY

This exists because of a specific mistake. I spent a while convinced a bug fix hadn't worked, re-reading code that looked correct, growing steadily more suspicious of Drupal. The fix was fine. I'd already released the package, so the site was running the registry copy while I was editing the symlink target that was no longer symlinked to anything. ddev localized answers "which reality am I in?" in one second, and that turned out to be worth writing a command for.

Note the localdev- prefix on the repository keys. That's what makes all of this discoverable — the commands never have to guess which repositories are mine versus part of the project's normal setup.

The loop

In practice a day looks like this:

ddev localize page                 # symlink it
# … edit hi_recipes/page/recipe.yml, apply it, break it, fix it …
ddev drush recipe /var/www/html/recipes/page -y

cd ../hi_recipes/page
git commit -am "Fix the thing"
git tag 1.0.12
git push origin main && git push origin 1.0.12

cd -
ddev release page                  # back to the registry
git diff composer.lock             # should show exactly one version bump
git commit composer.lock -m "Pin page 1.0.12"

That last bit isn't ceremony. The committed composer.lock is what makes the site reproducible, and it's the difference between "the fix is on my machine" and "the fix is in the project".

Three things that will bite you

ddev release pulls from the registry, so wait for the pipeline. If you release before the tag's publish job finishes, Composer happily gives you the previous version and reports success. The failure is completely silent — the lock diff just comes back empty. I've done this three times now, so the habit I'm building is: after every release, look at the lock diff and confirm the version bump you expected is actually in it. If it isn't, ddev composer clear-cache and ddev composer update <vendor/name>.

Applied config doesn't revert when you swap the files back. Swapping a recipe from local to registry changes the recipe files. It does nothing to config you already applied to the site. So "the site behaves correctly" is not evidence that the package released properly. Check the lock, not the behaviour.

Prefer no version field in your packages. Let git tags be the single source of truth. A version field in composer.json overrides the tag, which means the day you forget to bump it, the registry serves 1.0.3 under the name 1.0.7 and you get to spend an afternoon learning that. I dropped the field from everything except a couple of packages that need it.

Was it worth it?

Three shell scripts, one YAML file, and about a day of fiddling. Against that: I now edit a recipe the same way I'd edit a file in a normal Drupal project, and the site still builds from nothing but a registry constraint for anyone who clones it. The expensive bit of the registry approach — the release cycle standing between me and a one-line change — is gone, and I didn't have to give up the thing that made the registry worth doing in the first place.

I'll be honest that shell scripting is not where my talents lie, and a good chunk of this was written with Claude sitting next to me, mostly me describing what I wanted and then arguing about the edge cases. The edge cases are the interesting part anyway. The dev-main as 1.0.12 aliasing, the container mount, the "which reality am I in" command — none of those were in the first version. They're all scar tissue.

If you're running a private Composer registry for your own Drupal packages and you've been feeling that same friction, steal these. They're about a hundred lines total, and the only thing you'll need to change is the three vendor names.

More insights

Get ready to transform your operations

Sign up for a free trial and discover a world of possibilities to elevate your outreach and engagement strategies