When vendoring via submodule is actually worth it
Git submodules promise to cleanly stitch multiple repositories together, but in practice forgotten updates, a confusing detached HEAD state, and diverging commit pins across the team cause frustration on a regular basis. This article explains how submodules technically work, compares them with git subtree, Composer based dependencies, and monorepos, and shows which tool actually fits typical Magento multi repo setups.
Table of Contents
- 1. How Git submodules technically work
- 2. .gitmodules and gitlink: the data structure behind submodules
- 3. Adding, cloning, and updating submodules
- 4. The detached HEAD problem with submodules
- 5. Diverging commit pins across the team and CI complexity
- 6. git subtree: merging history instead of referencing it
- 7. Composer dependencies: private Packagist, Satis, and VCS repositories
- 8. Monorepo: one repository for everything
- 9. Submodules, subtree, Composer, and monorepo compared
- 10. Summary
- 11. FAQ
1. How Git submodules technically work
A Git submodule is, at its core, nothing more than a fully independent repository with its own history, its own branches, and its own remote, mounted at a specific path inside the working tree of a parent repository. The parent repository stores neither the content nor the commits of the submodule itself. Instead, Git creates a special tree entry of mode 160000 in the parent repo's tree, known as a gitlink. This entry doesn't point to a blob or a tree, it points directly to a single, specific commit SHA inside the submodule repository.
This is complemented by the .gitmodules file in the parent repo's root directory, an ordinary, tracked text file in INI format that records the remote URL and optionally a branch for every submodule path. The separation of concerns matters: .gitmodules tells Git where a submodule comes from, while the gitlink entry in the tree tells Git which exact commit should currently be checked out there. This combination of an external repository plus a fixed commit pointer makes submodules the native Git tool for vendoring, but it's also the root of nearly every pitfall described below.
2. .gitmodules and gitlink: the data structure behind submodules
Running git ls-tree HEAD in a repository with submodules shows, alongside normal blob and tree entries, a line like 160000 commit a1b2c3d... vendor/module-x. Mode 160000 only exists for submodule entries and explicitly marks them as "commit", not as a file or directory. That's exactly why git diff shows only a single line with the old and new commit hash for a submodule change, never the actual file contents, because Git treats the entire submodule as an atomic, opaque pointer.
The .gitmodules file adds the origin information on top of that structure. It's tracked and committed like any normal file, but strictly separate from the actual version pin. A common misunderstanding: a git pull in the parent repository updates neither the gitlink nor the content of the submodule directory to the latest state of the submodule's remote automatically. It only fetches the parent repo's new history, including a possibly changed gitlink pointer, which only takes effect in the working tree after an explicit submodule update command.
# .gitmodules: tracked in the parent repo, maps path to remote URL
[submodule "vendor/module-x"]
path = vendor/module-x
url = git@github.com:example-vendor/module-x.git
branch = main
[submodule "deploy-config"]
path = config/deploy-config
url = git@gitlab.mironsoft.internal:ops/deploy-config.git
# Shallow clone keeps checkout fast for a config-only repo
shallow = true
3. Adding, cloning, and updating submodules
A new submodule is added with git submodule add <url> <path>. The command clones the external repository into the given path, writes the corresponding entry into .gitmodules, creates the gitlink tree entry, and stages both for the next commit in the parent repository. Up to this point, nothing has actually been shared except the reference. Other team members who clone the parent repo initially get only an empty folder at the submodule path, because a plain git clone does not initialize submodules by default.
This is by far the most common practical stumbling block: without git clone --recurse-submodules during the initial clone, or git submodule update --init --recursive afterwards, the submodule directory stays empty while build scripts or the Composer autoloader reference files that simply don't exist. The same problem resurfaces after every git pull once the gitlink pointer in the parent repo has changed: the new commit hash is known locally, but the submodule's working directory still points to the old state until it is updated again.
# Clone a parent repo including all submodules in one step
$ git clone --recurse-submodules git@github.com:mironsoft/magento-shop.git
# Forgot --recurse-submodules? Initialize and fetch afterwards
$ git submodule update --init --recursive
# Pull parent changes AND move submodules to their newly pinned commits
$ git pull --recurse-submodules
# Make git pull always recurse into submodules for this repo
$ git config submodule.recurse true
# Check status of all submodules including nested ones
$ git submodule status --recursive
a1b2c3d vendor/module-x (heads/main)
+e4f5a6b config/deploy-config (heads/main)
# leading "+" means the checked-out commit differs from the pinned SHA
4. The detached HEAD problem with submodules
After every git submodule update, the submodule by default is not on a branch but in a detached HEAD state, pointing directly at the pinned commit. This is technically correct and intentional, since Git has to check out exactly the referenced commit regardless of which branch currently points to it. For developers used to every checkout automatically landing on a branch, this feels like a bug though, especially when they accidentally start working directly inside the submodule directory.
The real risk appears when a commit is made while in the detached HEAD state: the new commits do exist in the submodule's local object store, but they're not attached to any branch. If a different branch is subsequently checked out inside the submodule, or git submodule update runs again in the parent repo, those commits become effectively unreachable for Git and risk being removed by the next garbage collection. The rule of thumb, therefore: before making any change inside a submodule, explicitly run git checkout -b feature/x or at least switch to a known branch, never keep working directly in a detached HEAD state.
5. Diverging commit pins across the team and CI complexity
Because every gitlink pins an exact commit, submodule states drift apart across a team quickly unless every developer consistently updates after every pull. One developer works against commit A, another still against commit B, a third has unknowingly checked out an even newer commit C locally without committing it in the parent repo. Tests then appear to pass or fail seemingly at random, even though the code in the parent repository looks identical to everyone, because the actual cause is invisible inside the submodule pointer and only surfaces as a vague "modified content" line in git status on the parent repo.
In CI pipelines, the problem compounds: a runner that freshly clones the parent repository needs an explicit submodule initialization step, or the build or tests fail with cryptic "file not found" errors that have nothing to do with the actual code. Private submodule repositories additionally require the CI runner to hold its own credentials or SSH deploy keys, separate from those of the parent repo, which noticeably complicates secret management and permission models in multi-stage pipelines.
# .gitlab-ci.yml: explicit submodule handling for a Magento CI pipeline
variables:
GIT_SUBMODULE_STRATEGY: recursive
# Separate deploy token, submodule repo is private and not covered
# by the parent repo's default CI credentials
GIT_SUBMODULE_FORCE_HTTPS: "true"
build:
stage: build
before_script:
- git submodule sync --recursive
- git submodule update --init --recursive
script:
- bin/composer install --no-dev --optimize-autoloader
- bin/magento setup:di:compile
6. git subtree: merging history instead of referencing it
git subtree solves the same underlying problem, integrating foreign code into a repository, with a fundamentally different approach: instead of storing a reference to an external repository, subtree copies and merges the entire history of the foreign project directly into a subdirectory of the parent repo. After a git subtree add, the code is simply there, as if it had been part of the repository from the start. There's no .gitmodules, no gitlink, no separate update command, no detached HEAD state, a plain git clone is entirely sufficient.
The price for that is a noticeably messier commit history: every subtree merge potentially adds hundreds of foreign commits into the parent repo's git log, which complicates git blame and bisect sessions. Updates happen via git subtree pull, which merges in the full current state of the foreign repo again, and local changes can be pushed back to the original repository with git subtree push, though only if its history stays compatible. For teams unwilling to tolerate an extra init command after cloning, subtree is often the more pragmatic choice over submodules.
# Add a third-party repo as a subtree, squashing its history into one commit
$ git subtree add --prefix=vendor/module-x \
git@github.com:example-vendor/module-x.git main --squash
# Pull upstream changes into the subtree later on
$ git subtree pull --prefix=vendor/module-x \
git@github.com:example-vendor/module-x.git main --squash
# Push local changes made inside vendor/module-x back upstream
$ git subtree push --prefix=vendor/module-x \
git@github.com:example-vendor/module-x.git contribution-branch
# No .gitmodules, no init step: a plain clone already has everything
$ git clone git@github.com:mironsoft/magento-shop.git
7. Composer dependencies: private Packagist, Satis, and VCS repositories
For PHP and Magento projects, the most natural alternative to submodules is often not a Git technique at all, but the package manager already in use anyway. Instead of pinning a raw commit hash via a gitlink, Composer references versioned releases through semantic versioning constraints like ^2.3. A private Magento module can be loaded directly from the Git remote via a vcs repository, published as its own package archive through a self-hosted Satis instance, or managed via private Packagist with full release management and team access rights.
The decisive advantage over submodules: composer install is already part of every Magento deployment and every CI pipeline, so there's no extra init step and no risk of a forgotten update. Versions are explicitly pinned in composer.lock, which guarantees reproducible builds just as reliably as a gitlink, but with readable version numbers instead of cryptic hashes, and with the familiar tools composer update, composer why, and composer outdated for dependency analysis. The downside: every change to the private module requires an actual release tag, which noticeably slows the iteration cycle during active parallel development.
{
"repositories": [
{
"type": "vcs",
"url": "git@github.com:mironsoft/module-seosuite.git"
},
{
"type": "composer",
"url": "https://satis.mironsoft.internal"
}
],
"require": {
"mironsoft/module-seosuite": "^2.3",
"mironsoft/module-core": "^1.8"
},
"config": {
"allow-plugins": {
"magento/*": true
}
}
}
8. Monorepo: one repository for everything
The most radical alternative gives up on multiple repositories entirely: in the monorepo approach, the store, shared modules, and deployment configuration all live inside exactly one Git repository, typically in separate directories. This eliminates submodules, gitlinks, and version pinning as a concept altogether, because there is simply just one single, shared history. A single commit can contain a change to a module and the matching adjustment in the store code at the same time, atomically and without coordinating across multiple repository boundaries. This exact advantage makes monorepos particularly attractive for tightly coupled, jointly developed code.
The price is structural: as the number of projects grows, so does repo size, clone times increase, and without additional tooling like sparse checkout or partial clone, developers often download more code than they actually need for their task. CI pipelines also have to learn to selectively build and test only the actually affected areas, otherwise pipeline runtime grows linearly with the total size of the repository, even when only a single line in a small module has changed.
9. Submodules, subtree, Composer, and monorepo compared
None of the four options is universally the "right" solution, each solves a different underlying problem to a different degree. The table below compares them across the most practically relevant criteria.
| Criterion | Submodules | Subtree | Composer | Monorepo |
|---|---|---|---|---|
| Setup complexity | Init step required after every clone | No init step, a plain clone is enough | composer install, already standard | No extra step |
| History clarity | Cleanly separated, own history | Foreign commits mixed in with your own | Cleanly separated via packages | One history, topics intermixed |
| Team learning curve | High, detached HEAD confuses newcomers | Medium, a few extra commands | Low, standard workflow for any PHP dev | Low, but requires tooling maturity |
| CI overhead | Extra init and auth configuration | No extra configuration needed | Already included in the standard build | Requires a selective build strategy |
| Good fit for | Pinned vendor forks, config repos | One-off vendoring situations | Versioned, reusable modules | Tightly coupled teams, one product |
For most Magento multi-repo setups, a simple rule of thumb applies: as soon as a piece of code is meant to be a standalone, reusable module with clear version boundaries, for example an SEO extension used across multiple client projects, Composer via a private VCS repository or Satis is almost always the better choice. Submodules remain useful for well-scoped special cases, such as a pinned fork of a third-party module that should deliberately not be updated through Composer, or a central, git-tracked deployment config repository that multiple projects should reference but never update automatically. Monorepos, in turn, pay off mainly when a store and its modules are developed exclusively by a single, tightly coordinated team anyway.
Mironsoft
Repo strategies, Composer migrations, and CI/CD pipelines for PHP and Magento teams
Ready to structure your multi-repo setup properly?
We analyze your current submodule or multi-repo setup, point out concrete alternatives, and guide the migration to Composer, subtree, or a monorepo, without interrupting your build and deployment processes.
Repo strategy audit
Analyzing and evaluating existing submodule and multi-repo setups
Composer migration
Setting up private Packagist, Satis, or VCS repositories for Magento modules
CI/CD for multi-repo
Reliably setting up pipelines for submodule, Composer, or monorepo workflows
10. Summary
Git submodules pin an external repository to an exact commit through a gitlink tree entry and the .gitmodules file. That's technically clean, but it comes with practical pitfalls: forgotten git submodule update --init --recursive calls, a confusing detached HEAD state after every update, diverging commit pins across the team, and extra configuration effort in every CI pipeline. git subtree avoids the init step and the detached HEAD state entirely, at the cost of a messier, intermixed commit history.
For most PHP and Magento projects, Composer via private Packagist, Satis, or VCS repositories is the more pragmatic choice, because version management and installation are already part of the standard workflow. A monorepo eliminates the problem structurally by making multiple repositories unnecessary altogether, but demands growing investment in tooling and selective CI strategies in return. Submodules remain the right choice for clearly scoped special cases like pinned vendor forks or shared deployment config repositories that are deliberately not meant to update automatically.
Git Submodules vs. Alternatives: The Essentials at a Glance
Submodules
Gitlink + .gitmodules pin an exact commit. The init step and detached HEAD are the biggest pitfalls.
Subtree
Merges foreign history directly into the repo. No init step, but a messier git log.
Composer
Versioned releases via VCS repository, Satis, or private Packagist instead of a raw commit pin.
Monorepo
One repository, atomic commits across module boundaries, at the cost of growing repo size and CI complexity.