A team's git workflow is often where development discipline either holds together or falls apart. In teams without a clear branching strategy, developers work directly on the main branch, or create branches arbitrarily without any convention, leading to a tangled history of merge conflicts, incomplete features mixed into releases, and no clear path for hotfixes. A well-defined branching model gives the team a shared language for where work goes and when it ships.

This article covers a practical branching strategy suitable for small to medium web development teams, how to structure feature work, and how to handle the inevitable moments when things go wrong. Whether you are setting up a workflow for a new project or tidying up an existing repository, these principles apply broadly to PHP projects, static sites, JavaScript applications, and most other stacks you might encounter in business web development.

The Core Idea: Feature Branches

The fundamental rule is that the main branch is always shippable. Nothing goes into main unless it is tested, reviewed, and ready to deploy. All work happens in feature branches that branch off main and merge back into main when complete. This means the main branch always represents the current state of what is live or what will go live next.

main: A--B--C--D--F--G (releases)
 \ /
feature/x ---E-- (feature branch)

The practical benefit is that at any given moment, you can look at main and know exactly what is in production. If a bug is reported on production, you can branch from main and be certain you are working on the code that matches production, not some half-finished feature branch. This alone eliminates a large class of "works on my machine" problems that arise when developers base fixes on stale code.

Teams that adopt this model find that their deployment confidence increases. When you trust that main is always deployable, deployments stop being nerve-wracking events and become routine. This is especially valuable for businesses where website downtime or broken releases have real consequences.

Structuring Feature Branches

Feature branches should be short-lived: a few hours to a few days, rarely longer. The longer a branch lives, the more painful the eventual merge becomes. A branch that is a week old may have significant divergence from main, and merging it will require resolving conflicts that span many files and touch many parts of the codebase.

If a feature takes more than a few days to complete, break it into smaller pieces that can be merged incrementally. A search feature might be split into: database schema and migration, API endpoint for search, search results display, and finally the search UI. Each of these is a mergeable unit that delivers partial value and keeps the repository history clean.

Branch naming should be consistent and descriptive. The format type/short-description works well:

feature/user-authentication
feature/booking-calendar
bugfix/payment-validation
hotfix/login-timeout
chore/update-dependencies

This naming convention makes it immediately clear what the branch contains and what type of change it represents, without having to open the branch and read the commit history. It also makes branch listings in tools like GitHub or GitLab much easier to scan when you are looking for a specific piece of work.

Some teams add additional context to branch names, such as a ticket reference from their project management tool:

feature/PROJ-42-user-authentication
bugfix/PROJ-78-payment-validation

This connects the branch directly to the relevant task or user story, which helps during code review when you want to understand the broader context of a change.

The Development Branch

For teams with multiple developers working on overlapping features, introducing a development branch (or integration branch) between feature branches and main reduces the pressure on main. All feature branches merge into dev first. The dev branch is regularly merged into main, typically when a release is being prepared.

main: A--B--C--D-----------F
 \ /
dev: E--G--H--I--
 / \
feature/x J--K \
 feature/y L--M

This model is sometimes called "Gitflow" and is well-suited to teams with longer release cycles or teams that maintain multiple versions of software simultaneously. For teams doing continuous deployment (multiple small releases per week), the simpler feature-branch-directly-to-main model is more appropriate and reduces the cognitive overhead of managing an additional branch layer.

The choice between these models should align with your deployment frequency and release process. A small business website that gets updates every few weeks may find that a development branch creates unnecessary ceremony. A team shipping client projects on fixed release schedules may find that the development branch provides a useful staging point for gathering completed features before a release.

Merging and Code Review

All merges to main should go through a pull request. A pull request creates a structured opportunity for code review, provides a forum for discussing the change, and creates an auditable record of what was merged and when. The pull request should include a description of what changed and why, and the CI pipeline should be passing before the PR is reviewed.

The review does not need to be extensive for small changes, but it should always happen. Even a five-minute review by a second developer catches simple mistakes, identifies opportunities to simplify the code, and spreads knowledge of the codebase across the team. A team where only one person knows how a particular subsystem works is a team with a significant bus-factor risk.

Good pull requests are small and focused. A PR that changes three files and adds one new feature is much easier to review than one that touches twenty files and implements half a dozen changes. If you find yourself writing "and also" in a PR description, that is a signal that the work should be split into separate branches.

Handling Hotfixes

A production bug that requires immediate correction needs a different path. Branch from the current production tag (or from main if main is production), make the minimum change needed to fix the bug, test it, and merge directly to main with expedited review. Do not let a hotfix sit in a feature branch while the production system is broken.

main: A--B--C--D--F--G--H
 \ /
hotfix: --E-- (branched from D, minimal fix)

The hotfix branch should be based on the production tag, not on the tip of main, to avoid including incomplete feature work in the hotfix. After merging the hotfix to main, also merge it back to any active development branches to avoid the fix diverging. This step is easy to forget but important: if developers continue working on the development branch without the hotfix, they will eventually re-introduce the bug when their work is merged to main.

Not every production bug requires a hotfix. A non-critical visual glitch might wait for the next scheduled release. A payment processing error or a security vulnerability requires immediate action. Part of establishing a branching strategy is agreeing on what constitutes a hotfix-worthy issue in your context.

Dealing with Merge Conflicts

Merge conflicts are a normal part of collaborative development and are not a sign of failure. They happen when two branches modify the same lines of the same file. The solution is always to read both changes carefully and determine what the correct combined state should be, not to blindly accept one side or the other.

The best way to reduce merge conflict pain is to merge main into your feature branch regularly, rather than waiting until the feature is complete. A daily merge of main into your feature branch keeps the branch current and means conflicts are small and easy to resolve as you go, rather than large and daunting at the end.

git checkout feature/my-feature
git merge origin/main # pull latest main into your branch

If you follow this practice, most merge conflicts will be straightforward to resolve because they involve recently changed code that you understand well. Waiting until the end of a two-week development cycle to merge main means you are resolving conflicts between code you wrote two weeks ago and code that has evolved significantly since then.

Git Hygiene

Commit messages should describe what changed and why. "Fixed bug" is not a useful commit message. "Fixed booking validation to reject past dates" is. A good commit message makes it possible to understand the history of the codebase without having to read every diff. Teams that treat commit messages as an afterthought end up with a repository history that is impossible to navigate six months later.

Keep commits atomic: each commit should represent one logical change. If you are refactoring a function and discovering a bug in the process, commit the refactoring and the bugfix separately. This makes it easier to understand the history and to revert specific changes if needed. Atomic commits also make code review more manageable, because each pull request contains one clear change rather than a mixed bag of unrelated modifications.

Do not commit generated files, vendor directories, or IDE configuration to version control. Use a .gitignore file. Committing compiled assets, vendor code, or IDE-specific files creates noise in the repository and can cause merge conflicts that should not exist. For PHP projects, that means ignoring the vendor directory, compiled asset folders, and environment files that contain credentials.

# Example .gitignore for a PHP project
/vendor/
/node_modules/
/.env
/storage/*.key
*.log
.DS_Store

Taking time to set up .gitignore correctly at the start of a project pays dividends throughout the project lifetime. Retrofitting it to a repository that already has vendor files committed is painful but not impossible.

When Branches Go Stale

A branch that has not been touched in more than a week is a warning sign. Either the feature it represents is blocked by something outside the developer's control, or the work has been abandoned and the branch should be closed. Leaving stale branches open creates noise in the repository and can cause confusion about what is actually in progress.

If a feature is blocked, move it to a "blocked" column in your task tracker and merge main into the branch regularly so it stays current. When the block is resolved, the branch should merge cleanly because it has been kept up to date. If a branch has been idle for more than two weeks with no clear restart date, close it and create a new branch when the work resumes. Starting fresh from the current main is almost always easier than trying to resurrect an old branch that has diverged significantly.

Regular repository housekeeping, such as deleting merged and abandoned branches, keeps the branch list manageable. Many teams automate this: GitHub and GitLab both offer branch deletion as part of the merge process, and tools like GitHub's repository settings can automatically delete branches after a PR is merged.

Branch Protection and CI

Configure branch protection rules on main so that no one can push directly to it. All changes must come through a pull request. This is not about trust within the team - it is about creating a consistent, auditable process that prevents accidental direct pushes that bypass review and testing.

Your CI pipeline should run on every pull request: automated tests, linting, and a build step that confirms the code compiles or passes a syntax check. A pull request with a failing CI pipeline should not be merged, regardless of how minor the failure seems. The goal of CI is to catch problems before they reach main, and that only works if the team treats failing builds as a blocking issue rather than something to fix after the merge.

If you are setting up CI for a PHP project, getting started with CI/CD for PHP projects covers the practical steps for configuring automated testing with GitHub Actions. Automating your build and test process means fewer manual checks and faster feedback loops, which keeps developers productive and releases reliable.

Deployment Context

The branching strategy you choose should align with how code actually reaches production. In a continuous deployment setup where changes go live multiple times per day, a simple feature-branch-to-main model with short-lived branches is the most practical. In a team that deploys weekly or monthly releases, a development branch as an integration layer makes more sense, because it provides a natural holding point for features that are complete but not yet ready to ship.

Regardless of which model you use, the discipline is the same: main is always releasable, branches are short, and merges go through review. For teams that are configuring their deployment pipeline, the choices you make about branching and merging interact with how you move code from repository to server. A team that deploys via a simple git push to a server will have different needs from one that runs containerised deployments behind a load balancer managed through a CI pipeline.

For those exploring more structured deployment approaches, setting up a GitOps deployment pipeline describes a workflow where the repository state drives what is deployed, which pairs naturally with a clean branching model. Teams using automated scripts to push deployments may find the guide to writing deployment scripts useful for reducing manual steps in their release process.

Trunk-Based Development as an Alternative

While the feature-branch model described above is widely used, trunk-based development takes a different approach. In trunk-based development, developers work in very short-lived branches (measured in hours, not days) that merge directly into main, or they commit straight to main with feature flags controlling incomplete work.

This model reduces merge complexity because branches are always short and close to the current main state. It requires discipline around feature flags and a strong CI pipeline, but it can be more efficient for teams that deploy multiple times per day. The tradeoff is that incomplete features live in main behind toggles, which can increase complexity in the codebase if feature flags are not managed carefully.

Small to medium web development teams often find that a modified feature-branch model strikes the right balance between structure and simplicity. You do not need to adopt every practice from trunk-based development to benefit from shorter branches and better CI hygiene.