Git Workflow Strategies for Multi-Developer Craft CMS Projects
Solo Craft CMS development is easy. You make a change in the control panel, the YAML files update, you commit, you push, you deploy. Done.
Add a second developer and everything gets more interesting. Add a third and you'd better have a plan. The moment two people are editing the same content model at the same time, Project Config turns into the most conflict-prone part of your repo. I've helped a lot of teams work through this, and the workflow below is the one I keep landing on.
Why Craft Makes This Harder Than Normal
On most projects, git conflicts happen in code. You edit a file, I edit a file, git figures out whether our changes overlap. Craft adds a second surface where conflicts can happen: the config/project/ YAML files that describe your content model. Those files are generated by the control panel, keyed by UUIDs, and split across dozens of files. They're machine-authored, structurally sensitive, and connected to a running database. That's a rough combination for git.
Two developers adding fields to the same section at the same time will produce conflicts in the same YAML file, in blocks of code that look nearly identical, keyed by UUIDs neither of them recognize. Resolving that conflict by hand is how broken content models end up in production. So the workflow has to do two things: minimize how often those conflicts happen, and make them easy to resolve when they do.
The Workflow, In One Paragraph
Short-lived feature branches off main. Rebase (don't merge) main into your branch at least once a day. Run php craft project-config/apply after every pull. Squash-merge PRs back to main. Coordinate content-model changes in advance over Slack, not over git. Use a staging branch only if you need one — most teams don't.
That's it. The rest of this post is about why each of those pieces matters and how to handle the edge cases.
Branching Model: Short-Lived Branches Off Main
Forget Git Flow. Forget long-running develop branches. For Craft projects, I use a simple trunk-based workflow with a clear naming convention so everyone can tell at a glance what kind of work a branch contains:
mainis always deployable and always reflects what's on production (or what's about to ship)- Every change happens on a branch off
main, named by type - Branches live hours or days, not weeks
- PRs are reviewed, merged via squash, and the branch is deleted
Branch Naming Convention
The prefix tells you what kind of work the branch contains. It shows up in git branch output, in GitHub's branch list, and in PR titles — so a consistent convention pays for itself quickly.
feature/xxx— new functionality. A new section, a new template, a new integration. Anything that adds capability to the site.improvement/xxx— refinements to existing functionality. Refactors, performance tuning, tweaks to existing fields, copy updates, UX polish. The work isn't new, it's better.bugfix/xxx— fixes for defects found during development or staging, before code has reached production. Caught during QA, not on the live site.hotfix/xxx— urgent fixes for problems found in production. These branch frommain, get reviewed fast, and merge straight back tomainfor immediate deploy. They skip any in-flight release branch.
feature/1234-author-bio-field, hotfix/2045-broken-checkout. This makes it trivial to connect a branch to its ticket when you're scanning git log or reviewing PRs weeks later.
The critical rule is short-lived branches. A branch that's been open for two weeks while the rest of the team is also touching the content model is a ticking Project Config conflict. If you can't finish a piece of work in a few days, break it into smaller pieces.
Release Branches: Bundling Work for a Coordinated Ship
The trunk-based model above works great when features ship one at a time. But sometimes you need to bundle multiple branches into a single coordinated release — a site redesign, a quarterly feature drop, a launch that has to land with marketing, a group of changes that have to be tested together before going live. For those cases, add a release branch into the mix.
A release branch is a temporary integration branch for a batch of related features. It lives longer than a feature branch but still has a defined end date. Think of it as a staging area where you collect, integrate, and test work before sending it to main.
release/2026-q2-redesign
release/v2.5
release/fall-launch
How It Flows
- Cut
release/xxxfrommainat the start of the release cycle - Developers branch off
release/xxx(notmain) for work that belongs to this release:feature/xxx,improvement/xxx,bugfix/xxx - PRs target the release branch and get squash-merged into it
- The release branch gets deployed to staging continuously so QA can test the integrated work
- When the release is approved, the entire release branch is merged (not squashed) into
main, which triggers a production deploy - The release branch is deleted
# Start a release
git checkout main
git pull
git checkout -b release/2026-q2-redesign
git push -u origin release/2026-q2-redesign
# Developers branch off the release branch for their work
git checkout release/2026-q2-redesign
git pull
git checkout -b feature/new-hero-section
# When done, PR targets release/2026-q2-redesign (not main)
# When release is approved, merge to main
git checkout main
git pull
git merge --no-ff release/2026-q2-redesign
git push origin main
# Delete the release branch
git branch -d release/2026-q2-redesign
git push origin --delete release/2026-q2-redesign
main with --no-ff (no fast-forward), not squash. You want to preserve the individual squash commits from each feature inside the release so git log on main still tells the story of what shipped. A single squashed "Q2 Redesign" commit throws away months of useful history.
Hotfixes During an Active Release
Hotfixes always branch from main and merge back to main, even when a release branch is in flight. This is the whole reason hotfix/xxx is its own category — production can't wait for a release to ship.
After the hotfix is merged to main, immediately merge main back into the active release branch so the fix flows forward and doesn't get lost or clobbered when the release eventually ships:
# Hotfix is merged to main and deployed. Now catch up the release branch.
git checkout release/2026-q2-redesign
git pull
git merge main
git push origin release/2026-q2-redesign
# Everyone working off the release branch should then rebase their feature branches
git checkout feature/new-hero-section
git fetch origin
git rebase origin/release/2026-q2-redesign
php craft project-config/apply
When Not to Use a Release Branch
Release branches add overhead. Don't reach for one unless you actually need the coordination. For most week-to-week Craft work — small features, tweaks, fixes — merge directly to main and deploy continuously. Release branches are for the moments when several pieces of work have to land together and testing them as a batch matters more than shipping them individually.
main.
Rebase, Don't Merge
This is where Craft teams most often go wrong. The default workflow for many developers is to create a branch, do work, and when main moves ahead, git merge main into their branch to catch up. That works for code. It makes a mess of Project Config.
Merge commits create a history where your branch contains both sides of every conflict, forever. When you eventually merge back to main, any lingering project-config conflicts can resurface or get resolved wrong. Rebase sidesteps this by replaying your commits cleanly on top of the latest main.
# From your feature branch
git fetch origin
git rebase origin/main
# If there are conflicts in config/project/*.yaml
# resolve them (more on this below), then:
git add config/project/
git rebase --continue
# After the rebase completes, reapply project config to your local DB
php craft project-config/apply
Rebase daily. The longer you wait, the worse the conflicts get, and the more likely you are to merge a broken YAML file. A 10-minute rebase every morning is cheap insurance.
Squash-Merge PRs Back to Main
When your PR is approved, merge it with a squash merge. This collapses all your branch commits into one clean commit on main. Reasons:
- Your Project Config commits during development were probably messy — five commits back-and-forth adding and removing a field. Squashing makes
main's history tell a clean story. - If you ever need to revert a change, reverting one squashed commit is straightforward. Reverting a series of interleaved commits is a nightmare.
git logonmainreads like a changelog, not a stream of consciousness.
Set this as the default merge strategy in GitHub: Settings → General → Pull Requests → Allow squash merging, and disable the other two options if you want to enforce it.
The Golden Rule: Apply Project Config After Every Pull
This is the single most important habit to drill into your team. Every time you pull changes from another branch — whether it's a rebase, a fast-forward pull on main, or a checkout of someone else's branch for review — run:
php craft project-config/apply
This syncs your local database with whatever's in the YAML files. If you skip it, your local database drifts from what the YAML says, and the next time you touch the control panel you'll generate YAML that looks like a merge of both states. That's how phantom fields appear in Project Config that nobody remembers adding.
alias pca="php craft project-config/apply". Even better, add a git post-checkout hook that runs it automatically when you switch branches. Ten seconds of automation saves hours of debugging.
Post-checkout hook
#!/bin/bash
# Auto-apply Project Config after checkout or branch switch
# Only run if project-config YAML actually changed
prev_head=$1
new_head=$2
branch_switch=$3
if [ "$branch_switch" = "1" ]; then
if git diff --name-only $prev_head $new_head | grep -q "^config/project/"; then
echo "Project Config changed — applying..."
php craft project-config/apply
fi
fi
Make it executable (chmod +x .git/hooks/post-checkout) and commit a copy to the repo under scripts/hooks/ so the whole team can install it.
Coordinating Content-Model Changes
Rebasing and applying Project Config handles mechanical conflicts. But the real problem is semantic: two developers adding different fields to the same section at the same time, without knowing about each other. The YAML will conflict in a resolvable way, but the intent behind the changes is still two competing plans for the same piece of the content model.
The fix isn't technical. It's communication.
- Announce structural work before you start it. A one-line Slack message: "Hey, I'm adding an author bio field to the Blog section this afternoon." That gives anyone else planning a similar change a chance to coordinate or wait.
- Keep content-model PRs small and focused. One PR per feature, not a week's worth of unrelated schema changes piled together.
- Don't batch structural changes with content/template work. A PR that adds a field and the template that uses it is fine. A PR that adds ten unrelated fields across eight sections because they're all "for the redesign" is asking for trouble.
- Merge structural PRs quickly. Once a Project Config PR is approved, merge it within an hour. Don't let it sit open while the team keeps working.
On larger teams I've worked with, we've designated a single developer as the "schema owner" for big initiatives. Everyone else coordinates through them. Sounds heavy, but on a team of five people redesigning the content model, it prevents a lot of pain.
Resolving Project Config Conflicts
Eventually you'll hit one. Two developers added fields to the same section, you rebase, and git stops and tells you there's a conflict in config/project/sections/5a1c...yaml. Here's how to handle it without breaking things.
First, don't panic and don't try to manually merge the conflict markers. Project Config YAML is keyed by UUIDs and the structure is too easy to get wrong. Instead, follow this process:
# 1. See which files are conflicted
git status
# 2. For each conflicted YAML file, pick one side as the base.
# Usually "theirs" (main) is the right choice because it's the
# authoritative version. You'll reapply your local changes after.
git checkout --theirs config/project/
# 3. Mark them resolved and continue the rebase
git add config/project/
git rebase --continue
# 4. Apply project config so your local DB matches main
php craft project-config/apply
# 5. Now redo your structural changes in the control panel.
# This sounds painful but it's usually 30 seconds of clicking.
# The new YAML will be a clean addition on top of main.
# 6. Commit the new YAML
git add config/project/
git commit -m "Re-add author bio field after rebase"
This "take theirs, redo yours" approach is counterintuitive but it's the safest way. You end up with a clean history where the other developer's change is already on main and your change is stacked cleanly on top. No manual YAML editing, no half-resolved conflict markers, no risk of a malformed config.
When the YAML really is broken
If you end up in a state where php craft project-config/apply throws errors no matter what you do, the escape hatch is project-config/rebuild. This rebuilds the YAML files from whatever's currently in the database. Run it on the developer's machine that has the "correct" state, commit the result, and have everyone else pull it.
# On the machine with the correct DB state
php craft project-config/rebuild
# Commit the regenerated YAML
git add config/project/
git commit -m "Rebuild project config from database"
git push
# Everyone else pulls and applies
git pull origin main
php craft project-config/apply
Use this sparingly. It's a blunt instrument and it produces a large, noisy commit. But it'll get you out of a corner.
Database Strategy: Everyone Gets Their Own
A question I get a lot: should developers share a dev database, or should each developer run their own?
Each developer runs their own. Always. A shared dev database seems convenient — "we all see the same content!" — but it defeats the entire point of Project Config. If you're all connected to the same database, you can't safely test schema changes locally, because your changes are immediately visible to everyone else's running Craft instances, which then disagree with their local YAML files.
Instead, share a snapshot periodically. I keep a recent production database dump in an S3 bucket (scrubbed of any PII). Developers pull it down when they want fresh content to work with:
# Pull latest snapshot from S3
aws s3 cp s3://project-db-snapshots/latest.sql.gz ./storage/db-snapshots/
# Restore to local database
gunzip -c storage/db-snapshots/latest.sql.gz | ddev import-db
# Apply any project config changes that aren't in the snapshot
php craft project-config/apply
For teams on DDEV, this is even simpler because DDEV has built-in snapshot commands. The key thing is that each developer has an isolated database they can freely break and reset.
Production Safeguards
All of this only works if production can't diverge from what's in git. Set these environment variables on your production server:
CRAFT_ALLOW_ADMIN_CHANGES=false
CRAFT_DISALLOW_ROBOTS=false
CRAFT_ALLOW_ADMIN_CHANGES=false disables the parts of the control panel that would modify Project Config — adding fields, creating sections, editing entry types. Content editors can still edit entries, but structural changes have to come through git. This is the guardrail that makes the whole workflow trustworthy.
Without this setting, someone can log into production, add a field, and then your next deploy will overwrite their change with whatever's in the YAML. That's not a theoretical problem — I've seen it happen, and the client was not happy about losing the field they'd just spent an hour configuring.
PR Review Checklist for Craft Projects
When reviewing a Craft PR, I check a few things beyond the normal code review:
- Are there Project Config changes? If yes, does the PR description explain what structural change is being made and why?
- Did the developer rebase recently? A branch that's behind
mainby dozens of commits is a risk. Ask them to rebase before merge. - Are there any data migrations required? Project Config handles structure, not data. If a field is being renamed or its type is changing, there probably needs to be a content migration PHP script.
- Are front-end templates updated to match the new structure? It's easy to add a field in the control panel and forget to actually use it in a template.
- Do I understand the YAML diff? If the diff is hundreds of lines and I can't tell what's happening, the PR is too big. Ask for it to be split.
That last one is important. Huge unexplained YAML diffs are a smell. Either the developer did a project-config/rebuild when they shouldn't have, or they squashed a lot of unrelated changes into one PR, or their local state was out of sync. Any of those is worth asking about before merging.
Common Pitfalls
Committing the database dump
Don't. Databases don't belong in git. Content lives in the database, and content is the editors' domain — it shouldn't flow through developer PRs.
Forgetting to commit config/project/
If you add a field locally and don't commit the resulting YAML changes, nobody else will get them. I've seen teams lose hours debugging why "the field shows up for Dev A but not Dev B." Always check git status after making structural changes in the control panel.
Editing YAML files directly
Tempting but dangerous. The YAML format is designed to be written by Craft, not by humans. Making small tweaks (renaming a handle, changing a label) is usually safe. Making structural changes by hand is not. When in doubt, make the change in the control panel and commit the generated YAML.
Long-running feature branches
A branch that's been open for a month will have more Project Config conflicts than clean code changes. If a feature is that big, break it into milestones and merge them incrementally behind a feature flag or in an inactive section.
Force-pushing to main
Obvious, but I'll say it anyway: protect your main branch in GitHub. Require PRs. Require at least one approval. Block force pushes. Craft teams can't afford to have main's history rewritten — it breaks everyone's local state in ways that are hard to recover from.
Recommended Setup
Put all of the above together and you get something like this:
- Branch model: Short-lived branches off
mainusingfeature/xxx,improvement/xxx,bugfix/xxx, andhotfix/xxxprefixes - Release branches:
release/xxxfor coordinated multi-feature launches; skip for normal week-to-week work - Update strategy: Rebase daily (
git fetch && git rebase origin/main, ororigin/release/xxxif you're on a release) - Merge strategy: Squash-merge feature PRs; merge release branches to
mainwith--no-ffto preserve individual commits - After every pull:
php craft project-config/apply, ideally via a post-checkout hook - Database: Each developer has their own local DB, snapshots shared via S3
- Production:
CRAFT_ALLOW_ADMIN_CHANGES=falseto prevent schema drift - Branch protection: Require PRs, require review, block force pushes
- Coordination: Announce structural changes in Slack before starting them
None of this is revolutionary. It's the standard modern git workflow with two Craft-specific additions: run project-config/apply obsessively, and coordinate content-model changes like a grown-up team. Do those two things and the Project Config conflict problem mostly disappears.
The most important thing I can tell you is that the workflow is only half of it. The other half is culture. Teams that ship cleanly in Craft are teams where the developers talk to each other about what they're working on. No amount of tooling fixes a team that's building in silos and finding out about each other's changes at merge time. Get the habits right first, and the git mechanics follow.
Working on a Craft project with a team and running into these problems? I help agencies untangle this stuff.