Git Workflow for PHP Projects: Commits, Branches, Reviews, and Secrets
Apply a disciplined Git workflow to a PHP project: make atomic, well-described commits, use feature branches and pull requests for code review, tag releases with semantic versioning, and correctly configure .gitignore to keep vendor directories and secrets out of version control — including what to do if a secret is accidentally committed.
Git is the version control system underlying essentially every professional PHP project, and how a team uses it — commit discipline, branching strategy, review process, and what deliberately never gets committed — has a bigger day-to-day impact on a codebase's health than almost any single language feature. A PHP-specific wrinkle sits at the center of this: the vendor/ directory that Composer generates can be enormous, is fully reproducible from composer.json/composer.lock, and must never be committed; and PHP projects are exactly the kind of codebase where a .env file sits at the project root, one careless git add . away from being committed and leaking every credential the application has. This lesson covers the Git fundamentals every PHP developer needs — repositories, atomic commits, branching, pull/merge requests, release tags — with particular attention to .gitignore discipline and secret hygiene, because a leaked secret is one of the few Git mistakes that can't be fixed by simply editing history.
Repositories and the Git Mental Model
A Git repository is a versioned history of a project's files, stored as a sequence of snapshots (commits) rather than a single current state. Locally, a repository has three relevant areas: the working directory (the actual files on disk you're editing), the staging area/index (a holding area where git add places changes that will go into the next commit), and the commit history itself (the sequence of committed snapshots, forming a directed graph). Understanding that git add and git commit are two separate steps — stage, then commit — is fundamental: staging lets you build up exactly the set of changes you want in one commit, even if your working directory currently has other, unrelated changes mixed in.
A remote (most commonly hosted on GitHub, GitLab, or Bitbucket) is a copy of the repository's history that team members synchronize with via git push (send local commits to the remote) and git pull (fetch and merge remote commits into your local branch). For a PHP project, the remote almost always also hosts CI configuration (a .github/workflows/*.yml file, or a .gitlab-ci.yml) that runs automatically against every push and pull request, tying version control directly into the quality-gate process covered in the next lesson.
A composer.json and its lockfile composer.lock are always committed — they are the source of truth for which packages and exact versions the project depends on — while the vendor/ directory that composer install generates from them is never committed, because it is large, fully reproducible, and would otherwise bloat the repository with thousands of third-party files that have nothing to do with the project's own history.
• Working directory → staging area (git add) → commit history (git commit) — three distinct stages. • Remote (GitHub/GitLab/Bitbucket) synchronized via git push / git pull / git fetch. • Always commit composer.json and composer.lock; never commit the generated vendor/ directory. • CI configuration lives in the repo itself (.github/workflows/, .gitlab-ci.yml) and runs automatically on push/PR.
Atomic Commits and Writing Good Commit Messages
An atomic commit contains exactly one logical change — a single bug fix, a single small feature, a single refactor — and nothing unrelated bundled in alongside it. The test for atomicity is simple: if you had to revert this commit, would reverting it cleanly undo exactly one thing, with no side effects on unrelated functionality? A commit that fixes a validation bug and also reformats an unrelated file and also bumps a dependency version fails that test three times over — reverting it would undo all three changes at once, and reviewing it forces a reviewer to mentally untangle three unrelated diffs into one.
Atomic commits matter enormously for a PHP codebase's long-term health because they make git bisect (binary-searching commit history to find which exact commit introduced a regression) actually useful, make code review tractable (a reviewer can approve a small, focused diff far faster and more confidently than a sprawling one), and make git blame/history genuinely informative when someone later asks "why does this line exist?" three years from now.
A good commit message follows a well-established convention: a short summary line (ideally under ~50-72 characters) written in the imperative mood ("Fix null pointer in checkout total" rather than "Fixed" or "Fixes"), optionally followed by a blank line and a longer body explaining *why* the change was made — the motivation and context, not a restatement of the diff itself, since the diff already shows *what* changed. Many teams additionally adopt a structured prefix convention like Conventional Commits (feat:, fix:, refactor:, test:, chore:), which enables automated changelog generation and, combined with semantic-release tooling, can even drive automatic version bumping.
• Atomic commit = one logical change; reverting it should cleanly undo exactly that, nothing else. • Atomic commits make git bisect, code review, and git blame all meaningfully more useful. • Commit message: short imperative summary line + optional blank line + body explaining WHY, not WHAT. • Conventional Commits prefixes (feat:, fix:, refactor:, chore:) enable automated changelogs and version bumps.
Branching: Feature Branches, Trunk-Based Development, and Git-Flow
A branch is a movable pointer to a commit, letting development proceed on independent lines without disturbing the main line of history. The dominant convention today is the feature branch workflow: create a new branch off the main branch for each piece of work (git checkout -b feature/add-refund-endpoint), commit to it, and merge it back once the work is reviewed and complete — keeping the main branch always in a deployable, working state.
Two broad strategies describe how far that convention is taken. Trunk-based development keeps feature branches extremely short-lived (often merged within a day or two, sometimes hidden behind feature flags rather than kept unmerged for a long time), continuously integrating into a single main/trunk branch that is always releasable — this scales well with strong CI and is what most modern, fast-moving teams (including large PHP shops) use. Git-flow, by contrast, is an older, heavier convention with explicit long-lived develop and main branches plus dedicated release/* and hotfix/* branches, and a formal merge from develop into main only at release time — it offers more ceremony and explicit release staging, which some teams with slower, more formal release cadences still prefer, but it has fallen out of favor for teams doing continuous deployment because long-lived branches tend to accumulate painful merge conflicts.
Whichever strategy a team picks, a couple of practices are close to universal: the main branch is protected (no direct pushes; changes only land via reviewed pull/merge requests), and branch names typically carry a short prefix indicating their purpose (feature/, fix/, hotfix/, chore/) to make the branch list self-describing.
• A branch is a movable pointer to a commit — feature branches isolate work-in-progress from the main line. • Trunk-based development: short-lived branches, frequent merges into a single always-releasable main/trunk. • Git-flow: long-lived develop/main plus release/* and hotfix/* branches — heavier, less common for continuous deployment now. • Protect the main branch: no direct pushes; all changes land via reviewed pull/merge requests.
Pull Requests, Merge Requests, and Code Review
A pull request (GitHub's term) or merge request (GitLab's term) is a proposal to merge one branch into another, opened once a feature branch's work is ready for review. It bundles the branch's full diff, its commit history, and a description of what and why, and becomes the place where CI results, reviewer comments, and approvals accumulate before the merge actually happens — making it the single point of accountability for what's about to land in the main branch.
Effective code review on a PHP project looks for more than just "does this work": reviewers check for correctness (does the diff actually do what it claims, including edge cases), style/consistency with the project's conventions (often partly automated via php-cs-fixer/PHPStan running in CI so humans don't have to nitpick formatting), security concerns specific to PHP web apps (SQL injection via unescaped queries, unvalidated user input reaching a dangerous sink, secrets accidentally included in the diff), and test coverage (does the PR include tests for the new behavior, and do existing tests still pass).
A well-functioning review culture treats the PR description as documentation in its own right — a clear description of the problem being solved and the approach taken saves reviewers significant time and becomes valuable context later when someone is trying to understand why a change was made. Most teams also require at least one approval and a passing CI run before a merge button is even enabled, enforced through branch protection rules rather than social convention alone.
• A PR/MR proposes merging a branch, bundling diff + commit history + description + CI results + reviewer comments. • Review checks: correctness, style/consistency (often automated), PHP-specific security concerns, test coverage. • A clear PR description (problem + approach) saves reviewer time and documents intent for later readers. • Branch protection rules typically require passing CI + at least one approval before merge is allowed.
Tags and Releases with Semantic Versioning
A Git tag is a fixed, named pointer to a specific commit — unlike a branch, a tag doesn't move as new commits are added, which makes it ideal for marking exactly which commit corresponds to a given release. Tags are created with git tag v1.4.0 (a lightweight tag) or, more commonly for releases, git tag -a v1.4.0 -m "Release 1.4.0" (an annotated tag, which stores the tagger, date, and message as a real Git object), and pushed to the remote explicitly with git push origin v1.4.0 since tags aren't included in an ordinary git push by default.
PHP's own ecosystem leans heavily on Semantic Versioning (SemVer) for release tags, in the form vMAJOR.MINOR.PATCH — this is exactly the versioning scheme Composer itself expects when a package specifies version constraints in composer.json (^1.4 means "compatible with 1.4.0, allow minor/patch upgrades, but not a 2.0 breaking change"). MAJOR increments for breaking/incompatible API changes, MINOR increments for backward-compatible new functionality, and PATCH increments for backward-compatible bug fixes — a discipline that matters enormously for any PHP library published to Packagist, since downstream consumers rely on that contract to safely automate their own dependency upgrades.
For an application (as opposed to a published library), tags still provide real value even without downstream Composer consumers: they give a precise, permanent marker for "what code was actually deployed to production on this date", which is invaluable for incident investigation ("which release introduced this regression?") and for generating changelogs between releases.
• A tag is a fixed pointer to one commit (doesn't move), unlike a branch — ideal for marking releases. • git tag -a v1.4.0 -m "..." creates an annotated tag; git push origin v1.4.0 pushes it (tags aren't pushed by default). • SemVer (MAJOR.MINOR.PATCH): MAJOR = breaking change, MINOR = backward-compatible feature, PATCH = backward-compatible fix. • Composer version constraints (e.g. ^1.4) directly rely on packages following SemVer correctly.
.gitignore for PHP Projects, and Recovering from a Leaked Secret
.gitignore tells Git which files and directories to never track, and every PHP project needs one from its very first commit. The two non-negotiable entries for any Composer-based PHP project are /vendor/ (fully reproducible from composer.lock, and often huge) and .env (contains real secrets for whatever environment that copy represents). Beyond those two, a typical PHP .gitignore also excludes IDE-specific directories (.idea/, .vscode/ — though some teams do commit a shared .vscode/settings.json), OS cruft (.DS_Store, Thumbs.db), build/cache artifacts (/node_modules/ if a frontend build tool is involved, /storage/logs/ or similar framework cache/log directories), and PHPUnit's own coverage output directories.
It's essential to add .gitignore entries *before* a sensitive file is ever committed — .gitignore only prevents Git from tracking files it doesn't already know about; adding an entry for .env after .env has already been committed does nothing to remove it from history, it merely stops *future* changes to that file from being tracked (running git rm --cached .env is additionally required to actually untrack an already-committed file, on top of adding the .gitignore rule).
If a real secret (an API key, a database password, a private key) is ever accidentally committed — even if it's caught and "removed" in the very next commit — the correct response is never just deleting the file and committing again. The secret still exists in every prior commit in history, is trivially recoverable by anyone who clones the repo or already has a clone, and if the repository is or ever becomes public, automated scanners actively crawl GitHub specifically looking for exactly this pattern within minutes of a push. The only correct fix is to treat the secret as compromised and rotate it immediately at the source — generate a new database password, revoke and reissue the API key, rotate the credential wherever it's issued — because rotation is the only action that actually invalidates the leaked value; only after rotating should the team optionally also scrub history (via git filter-repo or GitHub's secret-scanning remediation tools) to clean up the repository, understanding that history rewriting requires every collaborator to re-clone or carefully rebase, and does not undo any exposure that already happened before the rotation.
• Non-negotiable PHP .gitignore entries: /vendor/ and .env — add them before the first commit. • .gitignore only stops tracking NEW files; an already-committed file needs git rm --cached <file> too, and remains in history regardless. • A leaked secret: rotate it immediately at the source — this is the only step that actually invalidates the exposure. • History scrubbing (git filter-repo, GitHub secret scanning remediation) is a valuable cleanup AFTER rotation, never a substitute for it. • Public repos are actively scanned by bots for leaked credentials within minutes of a push.
Core Git Commands for a PHP Project
git add is given explicit filenames here, not ., to keep the staged change atomic and intentional. The branch is pushed with -u to set up tracking, so future git push/git pull on it need no extra arguments.
git checkout -b feature/refund-endpoint
git add app/Services/RefundService.php tests/RefundServiceTest.php
git commit -m "Add refund service with idempotency check"
git push -u origin feature/refund-endpoint
# ... open a pull request, get it reviewed and merged ...
git tag -a v1.4.0 -m "Release 1.4.0: refunds, bugfixes"
git push origin v1.4.0Code Examples
This covers the two non-negotiable PHP entries (/vendor/ and .env), plus the other common categories of files that should never pollute a repository: generated caches, test coverage output, editor state, and OS-specific junk files. .env.example (with placeholder values, not real secrets) is deliberately NOT ignored — it should be committed so every developer knows which keys are required.
Adding a .gitignore rule alone does not remove a file Git already knows about; git rm --cached stops tracking it going forward, but every value it ever held remains fully recoverable from the repository's history, which is why rotating the exposed credentials is the mandatory next step, not optional cleanup.
Common mistake: Believing that deleting .env and committing that deletion 'removes the secret' — it only removes the file from the latest snapshot; every prior commit still contains the full plaintext value.
# Dependencies (fully reproducible from composer.lock — never commit)
/vendor/
# Environment / secrets — never commit real values
.env
.env.*.local
.env.local
# Framework cache, logs, compiled views (Laravel-style example)
/storage/*.key
/storage/logs/*.log
/bootstrap/cache/*.php
# Testing artifacts
/.phpunit.cache/
/coverage/
.phpunit.result.cache
# IDE / editor
.idea/
.vscode/
*.swp
# OS cruft
.DS_Store
Thumbs.db
# Frontend build output, if a JS build step is present
/node_modules/
/public/build/# 1. Add .env to .gitignore FIRST
echo ".env" >> .gitignore
git add .gitignore
git commit -m "Add .env to .gitignore"
# 2. Stop tracking the already-committed file (this does NOT delete it locally)
git rm --cached .env
git commit -m "Stop tracking .env"
# 3. Rotate every credential .env contained — it is still recoverable from git history
# (e.g. reset DB_PASSWORD, regenerate API keys) before doing anything elseFeature Branch to Tagged Release
Work happens on a short-lived feature branch with atomic commits; it merges into main via a reviewed PR, and a release is marked with an annotated SemVer tag.
main ●────────────●───────────────●──────● (tag: v1.4.0)
\ /
●──●──●──●──● feature/refund-endpoint
(atomic commits, reviewed via PR, then merged)Important Rules
Never commit /vendor/ or .env — add both to .gitignore before your very first commit.
Make every commit atomic: one logical change, with a clear imperative-mood summary line.
Never push directly to a protected main branch — always go through a reviewed pull/merge request.
Tag releases with annotated, SemVer-formatted tags (vMAJOR.MINOR.PATCH), pushed explicitly to the remote.
If a secret is ever committed, rotate it immediately — removing the file from a later commit does not undo the exposure.
Automated Secret Scanning
GitHub's secret scanning actively monitors public (and, with GitHub Advanced Security, private) repositories for patterns matching known API key formats — including many PHP-ecosystem services like Stripe and AWS — and can automatically notify the provider to revoke a leaked key within minutes of a push, which is exactly why rotating a leaked credential immediately, rather than assuming a quick fix went unnoticed, is the only safe response.
git add. git commit -m "fixes" git add app/Services/PricingService.php git commit -m "Fix rounding error in discount calculation"
git add . risks staging unrelated changes (or even a stray .env if .gitignore is misconfigured) into one vague commit; staging specific files with a clear, descriptive imperative message keeps history atomic and useful.
//.env accidentally committed; fix applied by deleting the file and committing again git rm.env git commit -m "remove.env" git rm --cached.env && git commit -m "Stop tracking.env" # THEN: rotate every credential.env contained at its source (DB, API providers, etc.)
Deleting a committed .env in a later commit leaves every value fully intact and recoverable in the repository's history; the only action that actually neutralizes the exposure is rotating the leaked credentials themselves.
Why should the vendor/ directory never be committed to a PHP project's Git repository? A. Composer does not work if vendor/ is tracked by Git B. It is fully reproducible from composer.json/composer.lock and would needlessly bloat the repository with third-party files C. GitHub blocks repositories containing a vendor/ directory D. vendor/ contains compiled binaries that Git cannot store
B. It is fully reproducible from composer.json/composer.lock and would needlessly bloat the repository with third-party files — vendor/ can always be regenerated with composer install from composer.json and composer.lock, so committing it adds enormous, unnecessary bulk and churn to the repository's history for no benefit.
What is the correct response after discovering a real secret was accidentally committed and pushed to a shared repository? A. Delete the file and commit the deletion; the secret is then safe B. Add the file to .gitignore; that alone removes it from history C. Rotate the leaked credential immediately at its source, since that is the only action that actually invalidates the exposure D. Ignore it, since only the team has access to the repository
C. Rotate the leaked credential immediately at its source, since that is the only action that actually invalidates the exposure — A committed secret remains fully recoverable from git history regardless of later deletions or .gitignore changes; only rotating the credential at its source actually neutralizes the risk.
In Semantic Versioning (vMAJOR.MINOR.PATCH), when should the MAJOR version be incremented? A. On every commit merged to main B. When a backward-compatible bug fix is released C. When a backward-incompatible, breaking API change is introduced D. Only when the PATCH number reaches 100
C. When a backward-incompatible, breaking API change is introduced — SemVer reserves the MAJOR version specifically for breaking changes; MINOR is for backward-compatible new features and PATCH is for backward-compatible fixes.
What distinguishes an atomic commit from a non-atomic one? A. An atomic commit must touch exactly one file B. An atomic commit contains exactly one logical change, so reverting it cleanly undoes just that change C. Atomic commits cannot include a commit message body D. Atomic commits are only used in trunk-based development, never git-flow
B. An atomic commit contains exactly one logical change, so reverting it cleanly undoes just that change — Atomicity is about logical scope, not file count — a commit can touch several files and still be atomic if they all belong to one coherent change, and the key test is whether reverting it cleanly undoes exactly that one thing.
You discover that a teammate committed a .env file containing a real database password three commits ago, and it has already been pushed to a shared GitHub repository. List, in order, the correct steps to remediate this.
1. Immediately rotate the database password at the source (change it on the database server / hosting provider). 2. Update the real .env files in every environment (dev, staging, prod) with the new password. 3. Add .env to .gitignore if it wasn't already there. 4. Run "git rm --cached .env" and commit, to stop tracking it going forward. 5. Optionally, scrub the password from git history entirely (e.g. with git filter-repo), understanding this requires every collaborator to re-clone or carefully rebase, and does not undo exposure that already happened before step 1.
Think about what actually invalidates the exposure versus what merely cleans up the repository afterward.