Back to Blog
Sep 16th, 2026

Ideal GitHub Actions for Symfony Apps

Written by kbond

Edit
Ideal GitHub Actions for Symfony Apps

The Ideal GitHub Actions Workflow for Symfony

GitHub Actions workflows have a habit of growing into a pile of copied snippets, old workarounds and checks that may or may not still be useful.

So, let's start fresh.

This is the GitHub Actions setup I'd use today for a typical Symfony application: a practical baseline for testing, linting, static analysis and ongoing dependency security.

This is also a living document. As GitHub Actions, PHP, Composer and Symfony evolve, we'll keep it updated.

One important distinction: this is for a Symfony application, not a reusable bundle or library. For an application, our goal is simple:

Prove that the exact code and dependencies we're about to ship actually work in a production-like environment.

Let's build that.

The Main CI Workflow

Let's start with the CI workflow I'd use today for a typical Symfony app:

# .github/workflows/ci.yml

name: CI

# Global environment variables.
env:
  APP_ENV: test
  # For an application, test the PHP version we actually use in production.
  PHP_VERSION: '8.5'

  # Point Doctrine at the PostgreSQL service below.
  DATABASE_URL: 'postgresql://app:password@127.0.0.1:5432/app?serverVersion=16&charset=utf8'

  # Using MySQL instead? Comment out PostgreSQL above and use this.
  # DATABASE_URL: 'mysql://app:password@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4'

# Run for every pull request, plus the final commit that lands on main.
on:
  push:
    branches:
      - main
  pull_request:

# CI only needs to read the repository.
permissions:
  contents: read

# If we push again, cancel the now-outdated run.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  tests:
    name: Tests
    runs-on: ubuntu-latest

    # Run PostgreSQL for this job.
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: app
          POSTGRES_USER: app
          POSTGRES_PASSWORD: password
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -d app -U app"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      # Using MySQL instead? Comment out the PostgreSQL service above and use this.
      # mysql:
      #   image: mysql:8.0
      #   env:
      #     MYSQL_DATABASE: app
      #     MYSQL_USER: app
      #     MYSQL_PASSWORD: password
      #     MYSQL_ROOT_PASSWORD: password
      #   ports:
      #     - 3306:3306
      #   options: >-
      #     --health-cmd "mysqladmin ping -h 127.0.0.1 -uapp -ppassword"
      #     --health-interval 10s
      #     --health-timeout 5s
      #     --health-retries 5

    steps:
      # Check out the application.
      - name: Checkout
        uses: actions/checkout@v7

      # Install PHP.
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          coverage: none

      # Install the exact locked dependencies and automatically cache Composer downloads.
      - name: Install dependencies
        uses: ramsey/composer-install@v4
        with:
          require-lock-file: true

      # Create the test database and bring its schema up to date.
      - name: Setup database
        run: |
          php bin/console doctrine:database:create --if-not-exists
          php bin/console doctrine:migrations:migrate --no-interaction

      # Verify that the migrations and Doctrine mapping agree. If not, show the missing SQL.
      - name: Validate database schema
        run: |
          php bin/console doctrine:schema:validate || {
            echo "::group::Schema changes"
            php bin/console doctrine:schema:update --dump-sql
            echo "::endgroup::"
            exit 1
          }

      # Run the test suite.
      - name: Run tests
        run: vendor/bin/phpunit

  lint:
    name: Lint
    runs-on: ubuntu-latest

    steps:
      # Check out the application.
      - name: Checkout
        uses: actions/checkout@v7

      # Install PHP and the Symfony CLI.
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          tools: symfony-cli
          coverage: none

      # Make sure composer.json is valid and composer.lock is in sync.
      - name: Validate Composer files
        run: composer validate --strict --no-check-publish

      # Install the exact locked dependencies and automatically cache Composer downloads.
      - name: Install dependencies
        uses: ramsey/composer-install@v4
        with:
          require-lock-file: true

      # Make sure the production service container compiles correctly.
      - name: Lint container
        run: php bin/console lint:container --env=prod

      # Catch invalid Twig before it reaches production.
      - name: Lint Twig
        run: php bin/console lint:twig --env=prod

      # Catch invalid Symfony YAML configuration.
      - name: Lint YAML
        run: php bin/console lint:yaml config --parse-tags

      #! Requires symfony/translation.
      # Validate the contents of all translation catalogs.
      - name: Lint translations
        run: php bin/console lint:translations

      # Catch Symfony-specific problems like unknown routes, templates and services.
      - name: Symfony diagnostics
        run: symfony lsp:check --format=github

      #! Requires symfony/asset-mapper.
      # Make sure production assets can be compiled.
      - name: Compile assets
        run: php bin/console asset-map:compile --env=prod

      # Exercise the production cache warmers before deployment.
      - name: Warm production cache
        run: APP_DEBUG=0 php bin/console cache:warmup --env=prod

  #! Requires php-cs-fixer/shim as a dev dependency.
  php-cs-fixer:
    name: PHP CS Fixer
    runs-on: ubuntu-latest

    steps:
      # Check out the application.
      - name: Checkout
        uses: actions/checkout@v7

      # Use the same PHP version as the application.
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          coverage: none

      # Install the project-local PHP CS Fixer version and its dependencies.
      - name: Install dependencies
        uses: ramsey/composer-install@v4
        with:
          require-lock-file: true

      # Check code style without changing files, and show the diff when it fails.
      - name: Check code style
        run: vendor/bin/php-cs-fixer check --diff --using-cache=no

  #! Requires phpstan/phpstan as a dev dependency.
  phpstan:
    name: PHPStan
    runs-on: ubuntu-latest

    steps:
      # Check out the application.
      - name: Checkout
        uses: actions/checkout@v7

      # Use the same PHP version as the application.
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          coverage: none

      # Install the project-local PHPStan version and its extensions.
      - name: Install dependencies
        uses: ramsey/composer-install@v4
        with:
          require-lock-file: true

      # Run static analysis using the project's PHPStan configuration.
      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --no-progress

Note

The ramsey/composer-install action handles Composer caching for us, so there's no separate actions/cache step.

There's nothing especially clever here - and that's kind of the point.

We have four jobs running in parallel: Tests, Lint, PHPStan, and PHP CS Fixer. Together, they check that the app works, Symfony can successfully build it, static analysis is happy, and the code style is consistent.

A Scheduled Composer Security Audit

Security advisories can appear long after a dependency was added, so running an audit only when code changes isn't enough. Instead, I like a small, separate workflow that runs once a week:

# .github/workflows/composer-audit.yml

name: Composer Security Audit

on:
  # Run automatically every Monday at noon UTC.
  schedule:
    - cron: '0 12 * * 1'

  # Also allow us to run the audit manually.
  workflow_dispatch:

permissions:
  contents: read

jobs:
  composer-audit:
    name: Composer Audit
    runs-on: ubuntu-latest

    steps:
      # Check out composer.lock.
      - name: Checkout
        uses: actions/checkout@v7

      # Install Composer. No project dependencies need to be installed.
      - name: Setup Composer
        uses: shivammathur/setup-php@v2
        with:
          tools: composer
          coverage: none

      # Fail on security advisories while still reporting abandoned packages.
      - name: Run Composer audit
        run: composer audit --locked --abandoned=report

      # Optional: notify Slack when the audit fails.
      # - name: Notify Slack on failure
      #   if: failure()
      #   uses: slackapi/slack-github-action@v4
      #   with:
      #     method: chat.postMessage
      #     token: ${{ secrets.SLACK_TOKEN }}
      #     payload: |
      #       channel: <CHANNEL_ID>
      #       username: "Composer Audit Failed"
      #       icon_emoji: ":rotating_light:"
      #       text: |
      #         Repo: ${{ github.repository }}
      #         Branch: ${{ github.ref_name }}
      #
      #         <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View details>

Note

Scheduled GitHub Actions workflows only run from the repository's default branch. Make sure composer-audit.yml is committed there, or the schedule won't trigger.

The key is composer audit --locked: Composer can check the exact dependencies in composer.lock without installing the app or its dependencies.

We don't even care which PHP version this workflow uses. It doesn't need to match production - we just need PHP to run Composer.

I also use --abandoned=report so abandoned packages are reported without failing a workflow that's specifically about security vulnerabilities.

And since scheduled workflow failures are easy to miss, I've included an optional Slack notification that fires when the audit fails.

Wrapping Up

A good CI setup should give you confidence without becoming its own project.

The setup above is intentionally pretty boring: test the app, lint it, analyze it, check the code style, and keep an eye on dependency security.

And since all of this evolves, we'll keep this post updated as GitHub Actions, PHP, Composer, and Symfony best practices change.

Got a check you always add, or one you think doesn't earn its place? Let us know in the comments below!

Happy Continuous Integrating!

3 Comments

Sort By
Login or Register to join the conversation

I like using the ones from Symfony Demo: https://github.com/symfony/demo/blob/main/.github/workflows/lint.yaml and https://github.com/symfony/demo/blob/main/.github/workflows/tests.yaml

I think it would be interesting to have a shared GitHub Actions setup for Symfony projects, perhaps in https://github.com/symfony/recipes. What do you think?

We could add a recipe in https://github.com/symfony/recipes-contrib/tree/main/phpstan to include the necessary PHPStan configuration, another in https://github.com/symfony/recipes/tree/main/friendsofphp/php-cs-fixer for the PHP-CS-Fixer configuration, and so on.

|

Hey @seb-jean!

Hmm, about the symfony/demo workflows: I would consider them more "package workflows", not "application workflows". The matrix is a big giveaway, you're apps shouldn't need to be tested on multiple PHP/Symfony versions or different operating systems.

It's also not optimized: the composer install step can be slow. Using ramsey/composer-install caches vendors making it much quicker. Not a big deal for public repos where action minutes aren't really a limit but for private repos, every minute counts!

GHA workflows in Flex recipes is an interesting idea!

--Kevin

1 |

Delete the comment?

Share this comment

astronaut with balloons in space

"Houston: no signs of life"
Start the conversation!