← All posts

Tutorial CI/CD

How to Auto-Format PHP Code with GitHub Actions

Auto-format PHP code in GitHub Actions using PHP-CS-Fixer and commit style fixes back to the branch automatically.

Difficulty Intermediate Time 25 min
Robotic arm aligning misformatted PHP code blocks into a clean grid, representing automated code formatting in CI

I'll show you how to set up a GitHub Actions workflow that automatically formats PHP code on every push using PHP-CS-Fixer, then commits any style fixes back to the branch — so your codebase stays consistently formatted without anyone having to remember to run a formatter locally. By the end, every push triggers a format check, and if the code needs fixing, the workflow commits the corrected files and pushes them back automatically.

Prerequisites

  • A GitHub repository with a PHP project.
  • composer.json and composer.lock committed at the repository root.
  • GitHub Actions enabled for the repository.
  • PHP 8.1 or higher recommended.
  • An ubuntu-latest runner with the default Bash shell.
  • A GitHub personal access token (PAT) or the default GITHUB_TOKEN with write permissions — needed so the workflow can push commits back to the branch. I cover the exact permission setup in Step 4.

Step 1 — Install PHP-CS-Fixer as a dev dependency

I install PHP-CS-Fixer via Composer so the exact version is pinned in composer.lock and every environment — local and CI — uses the same formatter.

composer require --dev friendsofphp/php-cs-fixer

Commit both updated files immediately so CI picks up the correct version.

git add composer.json composer.lock git commit -m "Add PHP-CS-Fixer as a dev dependency"

Step 2 — Create a PHP-CS-Fixer configuration file

PHP-CS-Fixer reads its ruleset from a .php-cs-fixer.php file at the project root. I use the @PSR12 ruleset as a baseline — it covers indentation, blank lines, brace placement, and import ordering — and explicitly exclude vendor so the formatter never touches Composer's own files.

cat > .php-cs-fixer.php <<'PHP' <?php use PhpCsFixer\Config; use PhpCsFixer\Finder; $finder = Finder::create() ->in(DIR) ->exclude('vendor'); return (new Config()) ->setRules([ '@PSR12' => true, 'array_syntax' => ['syntax' => 'short'], 'ordered_imports' => ['sort_algorithm' => 'alpha'], 'no_unused_imports' => true, ]) ->setFinder($finder); PHP

Adjust the rules to match your project's conventions. The full list of available fixers is in the PHP-CS-Fixer documentation.

Gotcha: If you use a framework like Laravel or Symfony that ships its own directory layout, add those framework-generated directories to ->exclude() to avoid formatting files you don't own — for example ->exclude(['vendor', 'storage', 'bootstrap/cache']) on Laravel.

Commit the config file.

git add .php-cs-fixer.php git commit -m "Add PHP-CS-Fixer configuration with PSR-12 ruleset"

Step 3 — Verify PHP-CS-Fixer runs locally

Before touching the workflow, I do a dry run locally to confirm the config is valid and to see what would change. The --dry-run flag reports diffs without writing anything to disk.

./vendor/bin/php-cs-fixer fix --dry-run --diff

If the output is clean, the formatter is happy with the current code. If it shows diffs, apply the fixes now so the first CI run starts green.

./vendor/bin/php-cs-fixer fixgit add -u git commit -m "Apply initial PHP-CS-Fixer formatting"

Note: PHP-CS-Fixer writes a cache file at .php-cs-fixer.cache after each run. Add it to .gitignore — it's a local speed optimisation and should not be committed.

echo ".php-cs-fixer.cache" >> .gitignore git add .gitignore git commit -m "Ignore PHP-CS-Fixer cache file"

Step 4 — Configure workflow permissions

The workflow needs write access to push commits back to the branch. GitHub Actions provides a built-in GITHUB_TOKEN that can do this, but its default write permission must be enabled. Go to your repository's Settings → Actions → General → Workflow permissions and select Read and write permissions, then save.

Alternatively, if your organisation's policy locks down the default token, create a fine-grained personal access token with Contents: Read and write scoped to this repository, then store it as a repository secret named FORMAT_PAT under Settings → Secrets and variables → Actions → New repository secret. I show how to reference it in the workflow below.

Step 5 — Create the GitHub Actions workflow

Now I build the workflow. It checks out the code, installs PHP and dependencies, runs the formatter, and — if any files changed — commits and pushes those changes back to the branch. The job uses the Composer cache step so repeated runs stay fast.

mkdir -p .github/workflowscat > .github/workflows/php-format.yml <<'YAML' name: PHP Format on: push: branches-ignore: - main jobs: format: runs-on: ubuntu-latest text permissions:  contents: write steps:  - name: Check out the code    uses: actions/checkout@v4    with:      ref: ${{ github.head_ref || github.ref_name }}      token: ${{ secrets.GITHUB_TOKEN }}  - name: Set up PHP    uses: shivammathur/setup-php@v2    with:      php-version: '8.3'      coverage: none  - name: Get Composer cache directory    id: composer-cache    run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"  - name: Cache Composer downloads    uses: actions/cache@v4    with:      path: ${{ steps.composer-cache.outputs.dir }}      key: ${{ runner.os }}-php-8.3-composer-${{ hashFiles('composer.lock') }}      restore-keys: |        ${{ runner.os }}-php-8.3-composer-  - name: Install dependencies    run: composer install --prefer-dist --no-interaction --no-progress  - name: Run PHP-CS-Fixer    run: ./vendor/bin/php-cs-fixer fix --no-interaction  - name: Commit formatted files    run: |      git config user.name "github-actions[bot]"      git config user.email "github-actions[bot]@users.noreply.github.com"      git diff --quiet && git diff --staged --quiet || (        git add -u &&        git commit -m "style: apply PHP-CS-Fixer formatting [skip ci]"&&        git push      ) YAML

A few decisions worth calling out:

  • branches-ignore: main — I never let the bot push formatting commits directly to main. Formatting runs on feature branches only; main should only receive formatted code through pull requests.
  • permissions: contents: write — this grants the job write access using the built-in GITHUB_TOKEN. If you created a PAT instead, replace ${{ secrets.GITHUB_TOKEN }} with ${{ secrets.FORMAT_PAT }} in the checkout step's token field.
  • [skip ci] in the commit message — this tells GitHub Actions not to re-trigger the workflow on the bot's own commit, preventing an infinite loop.
  • git diff --quiet && git diff --staged --quiet || — the commit block only runs when there are actual changes. If PHP-CS-Fixer made no modifications, the step exits cleanly with no commit.

Step 6 — Push the workflow and confirm it works

Commit the workflow and push it on a feature branch — not main, since we excluded that above.

git add .github/workflows/php-format.yml git commit -m "Add PHP-CS-Fixer auto-format workflow" git push origin your-feature-branch

Open the Actions tab and watch the PHP Format workflow run. Because you already applied formatting locally in Step 3, the Commit formatted files step should report no changes and exit cleanly:

nothing to commit, working tree clean

To confirm the auto-commit path works, introduce a deliberate style violation on the branch — misplace a brace, add an extra blank line, or leave a trailing space — then push without fixing it:

git add -u git commit -m "Test: introduce a formatting violation" git push

Watch the workflow run again. This time the Commit formatted files step should produce a new commit on your branch with the message style: apply PHP-CS-Fixer formatting [skip ci]. Pull the branch locally to see the fix:

git pull

Wrap-up

You now have a fully automated PHP code formatting pipeline: PHP-CS-Fixer runs on every push to a feature branch, and any style violations are corrected and committed back without manual intervention. Combined with the PHPStan workflow from the previous tutorial, your CI pipeline now enforces both code style and type correctness on every push — leaving code review free to focus on logic rather than formatting nits.