← All articles

Workflow

How to Version Control WordPress Snippets with GitHub: The Complete Guide

Repository layout, branch strategy, pull-request review, CI linting, conflict handling, and rollbacks — a complete workflow for keeping custom WordPress snippets in Git.

Updated 9 min read Mark Ashton

  • github
  • workflow
  • version control
  • agencies
Abstract cover artwork for How to Version Control WordPress Snippets with GitHub: The Complete Guide

Themes and plugins went into Git years ago. Snippets — the hooks, filters, WooCommerce rules, and tracking code that do most day-to-day customization — usually did not. They live as rows in a database table or files on one server, edited in a browser textarea, with no history beyond "it worked yesterday."

This guide is the full workflow for fixing that: how to lay out the repository, which branches map to which sites, how review and CI fit in, what to do when a conflict appears, and how to roll back in under a minute. The examples use SnipVault's GitHub sync, but the structure works with any tool that can read and write snippet files.

What goes wrong without version control

Every team that manages snippets in the admin eventually hits the same four problems:

  • No history. A checkout snippet changes and nobody can say who changed it, when, or what it looked like before.
  • Drift. Staging and production hold two slightly different copies of the same snippet, and nobody knows which is correct.
  • No review. A contractor edits PHP on a live store at 11pm and the first reviewer is a customer.
  • Lost code. A migration, a restore, or a plugin swap drops snippets that only ever existed in one database.

Git fixes all four, but only if the repository is the source of truth. If people keep editing production and "syncing later," you have a backup, not version control.

Decide what is the source of truth

Pick one sentence and write it in the repo README:

Snippets are changed in Git or on staging. Production only ever pulls.

That rule drives everything below. Production can still have an editor — you will need it during an incident — but any change made there is a hotfix that must be pushed back the same day.

Lay out the repository

There are two sensible layouts.

One repository per client is the simplest. Access control maps to GitHub permissions and offboarding a client is deleting a repo.

One repository for the whole agency works when many clients share code. Use a base path per site so each install only sees its own tree:

wp-snippets/
├── README.md
├── .github/
│   ├── CODEOWNERS
│   └── workflows/lint.yml
├── _common/                 # shared snippets, reviewed by leads
│   ├── disable-xmlrpc.php
│   └── security-headers.php
└── clients/
    ├── acme/                # base path for acme.com + staging.acme.com
    │   ├── woo-hide-company-field.php
    │   └── checkout-vat-notice.php
    └── globex/
        └── gf-lead-routing.php

In SnipVault each site chooses its own repository, branch, and base path, so clients/acme is the whole world for Acme's installs. Each snippet syncs as a file with a .snipvault.json metadata sidecar that carries the snippet's settings alongside its code. Commit the sidecars; they are what lets a pull recreate the snippet correctly on another site.

Name files after the job, not the client or the ticket: woo-hide-company-field, not acme-fix-2 or snippet-14.

Map branches to environments

The branch model that holds up for snippet work is deliberately boring:

  • main is production. Production sites pull from it. Nobody pushes to it directly.
  • staging is what the staging site runs. Staging can push and pull.
  • Feature branches are where changes are written and reviewed.
feature/woo-vat-notice ──PR──▶ staging ──PR──▶ main
                                   │               │
                           staging.acme.com    acme.com
                            (push + pull)     (pull only)

Two pull requests sounds heavy for a ten-line snippet. In practice the first PR is the review and the second is a one-click promotion after QA. It is the same flow you already use for themes and plugins.

Connect a site to GitHub

In SnipVault, the setup per site is:

  1. Connect GitHub from SnipVault settings. It is a single OAuth authorization; you do not create an OAuth app per site.
  2. Choose the repository, branch, and base path — for example wp-snippets, staging, clients/acme.
  3. Run an initial push from the site that currently has the most complete library. Check the commit on GitHub before connecting any other site.
  4. Connect the remaining sites to the same repository and path on their own branch, and pull.
  5. Set a sync cadence for scheduled sync, or leave it manual on production if you want every production change to be a deliberate pull.

Sync can run for the whole library or per snippet. Per-snippet push and pull is useful during an incident when you want to move exactly one fix without touching anything else.

The daily workflow

Changing a snippet in your editor

Most developers prefer their own editor over a browser textarea:

git switch staging && git pull
git switch -c feature/woo-vat-notice

# edit clients/acme/checkout-vat-notice.php
git commit -am "Show VAT notice for EU B2B checkouts"
git push -u origin feature/woo-vat-notice
# open a PR into staging

After the PR merges, pull on the staging site, test the pages the snippet touches, then open the staging → main PR and pull on production.

Changing a snippet in the WordPress admin

For small changes, or when you need WordPress context to write the code, edit on staging and push. The commit lands on staging, and the promotion PR into main becomes the review. Do not skip that PR because the change came from the admin — that is exactly how untested edits reach production.

Writing snippets that are safe to sync

A snippet in Git should be safe to load on any site that pulls it. That means guarding it:

<?php
/**
 * Show a VAT notice on checkout for EU business customers.
 * Owner: @acme-dev · Requires: WooCommerce 9+
 */

defined( 'ABSPATH' ) || exit;

if ( ! function_exists( 'acme_vat_notice' ) ) {
	function acme_vat_notice(): void {
		if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
			return;
		}

		echo '<p class="acme-vat-notice">' . esc_html__( 'VAT is reverse-charged for EU businesses.', 'acme' ) . '</p>';
	}

	add_action( 'woocommerce_before_checkout_form', 'acme_vat_notice' );
}

The header comment says why and who, the function_exists guards stop a double include from being fatal, and the WooCommerce check means a site without WooCommerce skips it instead of crashing.

Add CI: lint every snippet on every PR

The cheapest insurance in this whole guide is a syntax check. A parse error caught in a PR never white-screens anyone. Test against every PHP version you run in production:

# .github/workflows/lint.yml
name: Lint snippets
on: [pull_request]

jobs:
  php-lint:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ["8.0", "8.3", "8.4"]
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
      - name: php -l
        run: |
          status=0
          while IFS= read -r -d '' file; do
            php -l "$file" > /dev/null || status=1
          done < <(git ls-files -z '*.php')
          exit $status

The matrix matters. PHP 8.0 is SnipVault's minimum, and code that parses on 8.4 can still use syntax an older host rejects. Once linting is routine, add WordPress Coding Standards via PHPCS for escaping and sanitization warnings.

Enforce review with CODEOWNERS

Shared code needs an owner. A CODEOWNERS file plus branch protection on main and staging means nothing merges without the right reviewer:

# .github/CODEOWNERS
/_common/          @agency/leads
/clients/acme/     @agency/acme-team
/clients/globex/   @agency/globex-team

Turn on "Require a pull request before merging" and "Require status checks to pass" for both protected branches. Now a snippet cannot reach production without a reviewer and a green lint.

Conflicts: why they happen and how to resolve them

A conflict means the site and the repository both changed the same snippet since the last sync. The usual causes:

  • Someone hotfixed production while a PR for the same snippet was open
  • A scheduled pull ran while a developer was mid-edit in the admin
  • Two sites were accidentally pointed at the same branch and path, and both push

SnipVault detects this instead of silently overwriting, and asks you to choose a strategy: keep the local version, take the remote version, or resolve by hand. A simple rule for choosing:

  • Keep local when the site holds a hotfix that is not in Git yet — then push it immediately so Git catches up.
  • Take remote when Git holds the reviewed version and the site copy is an unreviewed edit.
  • Resolve by hand when both changes matter. Do the merge in Git, where you have a real diff, commit it, then pull.

If conflicts are frequent, the setup is wrong, not the tool. Usually production is pushing when it should only pull, or two environments share a branch.

Rollbacks in under a minute

When a snippet change breaks something, do not edit the broken snippet back by hand under pressure:

git log --oneline -- clients/acme/checkout-vat-notice.php
git revert <sha>
git push

Then pull that one snippet on the affected site. The revert becomes part of history, so the post-incident review can see exactly what shipped and what was undone. For planned releases, tag main first (git tag acme-2026-09-24) so "go back to this morning" is a single checkout.

If the site is down hard, disable the snippet first and investigate second. SnipVault's admin screen never executes snippets, so it stays reachable even when a snippet has fatalled the front end. The full incident path is in recovering from a fatal PHP error in a snippet.

Security considerations

Putting executable PHP in a repository changes your threat model. Handle it deliberately:

  • Keep the repository private and limit write access to people who are allowed to ship PHP to production.
  • Never commit secrets. API keys go in wp-config.php constants or environment variables, and snippets read them from there. Treat a leaked key in Git history as leaked — rotate it.
  • Imports are not deploys. In SnipVault, sidecar metadata pulled from GitHub cannot enable a Snippet Function endpoint or plant its secret; an admin has to switch it on per site.
  • Verify what runs. SnipVault signs snippet files with HMAC-SHA256 to prevent unauthorized modification, so a file edited directly on the server is detected rather than silently trusted.
  • Scan before merging. Run the Security Audit Center on changed snippets — it flags unescaped output, unprepared SQL, and other risky patterns that lint cannot see.

Moving an existing library into Git

  1. Pick the most complete site and treat its library as the starting point.
  2. Import snippets from your previous plugin if needed, and deactivate the old plugin so the same hook never runs twice.
  3. Clean up before the first push. Rename vague snippets, delete dead ones, and split anything that mixes jobs. Your first commit becomes everyone's reference.
  4. Push, review the tree on GitHub, and add the README rule, CODEOWNERS, and CI before anyone else connects.
  5. Connect other sites one at a time and pull. Compare each site's old library against the repo so a site-specific snippet is not lost — move genuinely per-site code into that client's path.

If you are coming from a file-based plugin such as FluentSnippets, the code is already files, which makes the review in step 3 much easier. The FluentSnippets review covers where that plugin stops.

Common mistakes

  • Production pushes to main. Now every admin edit bypasses review. Production should pull.
  • One branch for every environment. Staging tests become production deploys the moment someone syncs.
  • Syncing secrets in snippet source "because the repo is private."
  • Scheduled pulls on production with no CI. A merged parse error becomes a scheduled outage.
  • Deleting a snippet in Git and assuming sites followed. Check each site after a retirement — multi-site rollouts covers retiring code across a fleet.

Checklist

  • The README states the source-of-truth rule
  • main = production (pull only), staging = staging, feature branches for changes
  • One base path per client or site
  • .snipvault.json sidecars committed
  • PHP lint across your production PHP versions on every PR
  • CODEOWNERS and branch protection on both long-lived branches
  • No secrets in snippet source or history
  • A tested rollback: revert, push, pull

Once this is in place, a snippet stops being "that thing someone pasted into the admin" and becomes what it always was: production code with an owner, a review, and a history.

Next

Ready to upgrade your snippet workflow?

GitHub sync, the Snippet Engineer, and security auditing — in one WordPress plugin.