Git and PHP Deployment: Tags, Hooks and CI/CD Basics

Git deployment for PHP projects solves every problem that FTP uploads create. No record of what changed or when. No way to roll back when something breaks. Getting code from your local machine to a production server is where most PHP deployment problems happen – and git deployment PHP workflows fix all of them with tags, hooks and automation.

Git solves every one of these problems. This guide covers deploying PHP applications with Git – pulling to a server, using tags for versioning, Git hooks for automation, and the basics of CI/CD pipelines.

For official Git deployment documentation see Git server deployment documentation.

The Simplest Git Deployment

The most basic Git deployment: your server has a clone of the repository and you pull when you want to deploy:

# On your server (via SSH)
ssh user@yourserver.com

# Navigate to the web root
cd /var/www/html/your-php-project

# Pull the latest code from main
git pull origin main

# Install/update dependencies
composer install --no-dev --optimize-autoloader

# Clear any application cache (Laravel example)
php artisan config:clear
php artisan cache:clear
php artisan view:clear

Simple and it works for solo projects. The problems appear when you’re deploying frequently, working in a team, or need to know exactly what version is on the server at any time. That’s where tags and automation come in.

Git Deployment PHP: Deploying Specific Versions With Tags

Tags give you named, reproducible points in history. Instead of deploying “whatever is on main” you deploy a specific tagged version:

# On your development machine - create and push a release tag
git tag -a v1.3.0 -m "Release v1.3.0 - Add product search and email notifications"
git push origin v1.3.0
# On the server - deploy the specific tagged version
ssh user@yourserver.com
cd /var/www/html/your-php-project

# Fetch all tags from remote
git fetch --tags

# Check what version is currently deployed
git describe --tags

# Deploy the new version
git checkout v1.3.0

# Install dependencies for this exact version
composer install --no-dev --optimize-autoloader

Output of git describe –tags:

v1.2.0

Now you know exactly what’s on the server. If v1.3.0 breaks production:

# Roll back to the previous version in seconds
git checkout v1.2.0
composer install --no-dev --optimize-autoloader
php artisan config:clear

Checking What Will Deploy Before Deploying

# On the server - what commits are in the new version 
# that aren't on the server yet?
git fetch origin

# Compare current HEAD to what you're about to pull
git log HEAD..origin/main --oneline

# What files will change?
git diff HEAD..origin/main --name-only

Output:

b5c6d7e Add product search filter with pagination
a1b2c3d Add SMTP email notification system
3f7a9b2 Update Composer dependencies

If composer.json is in the diff you know to run composer install. If migration files changed you know to run php artisan migrate. No more guessing what changed between deployments.

Git Hooks for Deployment Automation

Git hooks are scripts that run automatically at specific points in the Git workflow. The two most useful for deployment:

post-receive Hook (Server-Side)

This hook runs on the server after it receives a push. Used to automatically deploy when you push to a specific branch:

# On the server - set up a bare repository
mkdir /var/repos/your-php-project.git
cd /var/repos/your-php-project.git
git init --bare
# Create the post-receive hook
nano hooks/post-receive
#!/bin/bash
# hooks/post-receive

TARGET="/var/www/html/your-php-project"
GIT_DIR="/var/repos/your-php-project.git"
BRANCH="main"

while read oldrev newrev ref
do
    DEPLOYED_BRANCH=$(git rev-parse --symbolic --abbrev-ref $ref)

    if [ "$DEPLOYED_BRANCH" == "$BRANCH" ]
    then
        echo "Deploying branch: $DEPLOYED_BRANCH"

        git --work-tree=$TARGET --git-dir=$GIT_DIR checkout -f $BRANCH

        cd $TARGET

        echo "Installing Composer dependencies..."
        composer install --no-dev --optimize-autoloader --quiet

        echo "Clearing application cache..."
        php artisan config:clear
        php artisan cache:clear
        php artisan view:clear
        php artisan route:clear

        echo "Running migrations..."
        php artisan migrate --force

        echo "Deployment complete."
    fi
done
# Make the hook executable
chmod +x hooks/post-receive
# On your development machine - add the server as a remote
git remote add production user@yourserver.com:/var/repos/your-php-project.git

# Deploying is now just a push
git push production main

Output when you push:

Counting objects: 12, done.
Writing objects: 100% (12/12), done.
remote: Deploying branch: main
remote: Installing Composer dependencies...
remote: Clearing application cache...
remote: Running migrations...
remote: Deployment complete.
To user@yourserver.com:/var/repos/your-php-project.git
   a1b2c3d..b5c6d7e  main -> main

One git push deploys everything. No SSH-ing into the server manually, no forgetting to run migrations, no missing the cache clear.

pre-commit Hook (Local)

Runs before every commit on your local machine. Use it to catch problems before they reach the repository:

# In your project: .git/hooks/pre-commit
#!/bin/bash

echo "Running pre-commit checks..."

# Check PHP syntax on all changed PHP files
CHANGED_PHP=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')

if [ -n "$CHANGED_PHP" ]; then
    for file in $CHANGED_PHP; do
        php -l "$file" > /dev/null 2>&1
        if [ $? -ne 0 ]; then
            echo "PHP syntax error in: $file"
            echo "Commit aborted."
            exit 1
        fi
    done
    echo "PHP syntax check passed."
fi

# Prevent committing debug statements
if git diff --cached | grep -E '(var_dump|dd\(|die\(|print_r\()' > /dev/null; then
    echo "Found debug statements (var_dump/dd/die/print_r) in staged changes."
    echo "Remove them before committing."
    exit 1
fi

echo "All checks passed."
exit 0
chmod +x .git/hooks/pre-commit

Now if you try to commit PHP with syntax errors or debug statements:

git commit -m "Add payment integration"

# Output:
Running pre-commit checks...
PHP syntax error in: src/Payment/PaymentController.php
Commit aborted.

The commit is blocked until the error is fixed. Catches mistakes before they reach the repository or production.

Sharing Hooks With Your Team

The .git/hooks/ directory is not committed to the repository. Each developer must set up hooks manually, which means they often don’t. Store hooks in the repository and set up a script to install them:

# Create a hooks directory in your project root
mkdir .githooks

# Move hooks there
mv .git/hooks/pre-commit .githooks/pre-commit
# Create an install script
# install-hooks.sh
#!/bin/bash
echo "Installing Git hooks..."
cp .githooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "Hooks installed."
# Or configure Git to use the shared hooks directory
git config core.hooksPath .githooks

Add the hooks directory to the repository and document in your README that developers should run the install script after cloning.

CI/CD Basics: Automated Testing and Deployment

A CI/CD pipeline runs automated tests whenever you push code and optionally deploys automatically when tests pass. Here’s a simple GitHub Actions workflow for a PHP project:

# .github/workflows/deploy.yml
name: Test and Deploy

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: pdo, pdo_mysql, curl, mbstring

      - name: Install dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: Run PHP syntax check
        run: find . -name "*.php" -not -path "./vendor/*" -exec php -l {} \;

      - name: Run tests
        run: php artisan test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/html/your-php-project
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php artisan config:clear
            php artisan cache:clear
            php artisan migrate --force
            echo "Deployed successfully."

Add your server credentials as repository secrets in GitHub (Settings – Secrets and Variables – Actions). The workflow then:

  • Runs on every push to main and every pull request
  • Checks PHP syntax on all files
  • Runs your test suite
  • Deploys to the server only when tests pass and the push is to main

Pull requests get tested automatically before merging. Broken code never reaches the server.

Tracking What’s Deployed

# On the server - see exactly what version is running
cd /var/www/html/your-php-project

# Show the current commit
git log -1 --oneline

# Show the current tag if on a tagged commit
git describe --tags --exact-match 2>/dev/null || git log -1 --oneline

# Show when this version was deployed
git log -1 --format="%ci"

Output:

b5c6d7e Add product search filter with pagination
v1.3.0
2026-05-03 09:00:00 +0530

Expose this in your application’s admin panel for at-a-glance deployment status:

<?php
// In a PHP admin controller
function getDeploymentInfo() {
    $commit  = trim(shell_exec('git log -1 --oneline'));
    $version = trim(shell_exec('git describe --tags --exact-match 2>/dev/null') 
               ?: shell_exec('git log -1 --format="%h"'));
    $date    = trim(shell_exec('git log -1 --format="%ci"'));

    return [
        'commit'  => $commit,
        'version' => $version,
        'date'    => $date,
    ];
}

$info = getDeploymentInfo();
echo "Version: {$info['version']}" . PHP_EOL;
echo "Commit:  {$info['commit']}"  . PHP_EOL;
echo "Date:    {$info['date']}"    . PHP_EOL;
?>

Output:

Version: v1.3.0
Commit:  b5c6d7e Add product search filter with pagination
Date:    2026-05-03 09:00:00 +0530

Frequently Asked Questions

Is it safe to run git pull directly on a production server?

For small projects it works. The risks are that a merge conflict stops the pull mid-way leaving the server in an inconsistent state, and that untested code goes straight to production. The safer approach is to use tagged releases and deploy specific versions rather than pulling whatever is on main. For anything beyond a personal project, add automated tests that must pass before code reaches the server.

What’s the difference between CI and CD?

CI (Continuous Integration) is the practice of automatically running tests on every code push. It catches broken code before it merges to main. CD (Continuous Deployment or Delivery) is automatically deploying code to staging or production after tests pass. CI without CD is common – automated tests but manual deployment. CD without CI is dangerous – automatic deployment without verification.

How do I handle database migrations in a Git deployment?

Track your migration files in Git like any other code. During deployment, run migrations after pulling the latest code but before clearing the cache. In Laravel: php artisan migrate --force (the --force flag bypasses the production confirmation prompt for automated scripts). Always test migrations on a staging environment first. Write migrations that can be rolled back with php artisan migrate:rollback if something goes wrong.

My .env file isn’t in Git but the server needs it. How do I handle this?

The .env file should never be in Git. On the server, create it manually once and it stays there across deployments – git pull doesn’t overwrite files that aren’t tracked. For a new server setup, copy .env.example from the repository, fill in the production values, and save as .env. Some teams store environment variables in the server’s environment or a secrets management tool rather than a file at all.


Summary

Git-based PHP deployment gives you reproducibility, rollback capability, and automation that FTP uploads cannot:

  • Tag every release – deploy specific versions not “whatever is on main”
  • Check before deployinggit diff HEAD..origin/main --name-only shows exactly what will change
  • Automate with hookspost-receive on the server deploys on push, pre-commit locally catches errors before they commit
  • Add a CI pipeline – tests that must pass before code reaches production
  • Track what’s deployedgit describe --tags on the server tells you exactly what version is running

This series covered the complete Git workflow for PHP developers – from initial setup through team collaboration and production deployment. For the setup and daily commands that everything builds on, start with Git for PHP developers: setup and daily workflow. For automating PHP tasks beyond Git deployment, the PHP cron job automation guide covers scheduling, logging, and debugging automated scripts.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top