Continuous Integration

Run install, linters, static analysis and the full test suite against a real database on every push, and fail on deprecations.

Syntaxcomposer install --prefer-dist --no-progress --no-interaction

Continuous integration means every push is checked on a clean machine before anyone merges it. The clean machine is the point. It catches the extension you installed by hand, the file you forgot to commit, and the test that only passes because of leftover data on your laptop.

The steps, in order

  1. composer validate --strict — fails on a malformed composer.json and on a lock file that no longer matches it.
  2. composer install --prefer-dist --no-progress --no-interaction — installs the exact versions recorded in composer.lock. The shorthand for the last flag is -n; they are the same option.
  3. Linters: bin/console lint:yaml config, bin/console lint:twig templates, and bin/console lint:container, which compiles the service container and fails on a missing or wrongly typed service before any test runs.
  4. PHPStan, so a type error is a build failure rather than a production exception.
  5. PHPUnit against the same database engine production uses, started as a service container. SQLite in CI hides differences in types, constraints and locking.

Failing on deprecations

Symfony's PHPUnit bridge counts deprecation notices and compares them with thresholds in SYMFONY_DEPRECATIONS_HELPER. Setting max[self]=0 and max[direct]=0 fails the build when your own code calls something deprecated, while still tolerating deprecations raised deep inside dependencies.

What trips people up

CI has no .env.local, because that file is not committed. The workflow has to supply DATABASE_URL itself, pointing at the service container.

Example

Example · php
<?php
// tests/Smoke/ContainerTest.php

declare(strict_types=1);

namespace App\Tests\Smoke;

use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

/**
 * The cheapest test worth having in CI: the container compiles and the
 * database the workflow started is actually reachable.
 */
final class ContainerTest extends KernelTestCase
{
    public function testContainerCompiles(): void
    {
        self::bootKernel();

        // The test container also exposes services that are private in prod.
        $container = static::getContainer();

        self::assertInstanceOf(
            ArticleRepository::class,
            $container->get(ArticleRepository::class),
        );
    }

    public function testDatabaseIsReachable(): void
    {
        self::bootKernel();

        $connection = static::getContainer()
            ->get(EntityManagerInterface::class)
            ->getConnection();

        // Fails loudly when DATABASE_URL points nowhere, instead of 40 confusing failures.
        self::assertNotFalse($connection->fetchOne('SELECT 1'));
    }
}

// vendor/bin/phpunit --testsuite smoke
//
// ..                                                                  2 / 2 (100%)
//
// Time: 00:01.312, Memory: 42.50 MB
//
// OK (2 tests, 2 assertions)

When to use it

  • Block a merge whose migrations do not run on an empty database.
  • Catch a missing service binding before it reaches a deployed environment.
  • Find out the day a dependency deprecates a method your code calls.
  • Prove the suite passes on the PHP version the production image ships.

More examples

The pipeline, with a real Postgres service

Order matters: the cheap checks run first so a typo in a YAML file does not cost a full test run. Migrations are applied rather than the schema being created from the mapping, which means CI also proves the migrations themselves still run on an empty database. Pin the PHP version to the one the production image uses, or CI tests a language version you do not deploy.

Example · yaml
# .github/workflows/ci.yaml
name: CI
on: [push, pull_request]

jobs:
  tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_DB: app
          POSTGRES_PASSWORD: app
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      APP_ENV: test
      DATABASE_URL: 'postgresql://postgres:[email protected]:5432/app?serverVersion=17&charset=utf8'
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.5'
          extensions: pdo_pgsql, intl
          coverage: none
      - run: composer validate --strict
      - run: composer install --prefer-dist --no-progress --no-interaction
      - run: php bin/console lint:yaml config
      - run: php bin/console lint:twig templates
      - run: php bin/console lint:container
      - run: vendor/bin/phpstan analyse
      - run: php bin/console doctrine:migrations:migrate --no-interaction
      - run: vendor/bin/phpunit

Turning deprecations into failures

max[self] counts deprecations triggered inside your own code; max[direct] counts deprecated framework code that your code calls directly. Both at zero means an upgrade blocker shows up as a red build on the day it appears, rather than during the next major upgrade. Indirect deprecations, raised by one dependency calling another, stay un-thresholded here because you cannot fix them. When an existing project has too many to fix at once, record them with baselineFile instead of raising the limits.

Example · xml
<!-- phpunit.dist.xml -->
<php>
    <ini name="display_errors" value="1" />
    <ini name="error_reporting" value="-1" />
    <server name="APP_ENV" value="test" force="true" />

    <!-- & must be escaped as &amp; in XML. -->
    <env name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&amp;max[direct]=0&amp;verbose=1" />
</php>

One command for the whole check suite

Worth adding as soon as the workflow has more than three steps. A developer runs composer ci locally and gets exactly what the pipeline will run, so a failure is reproducible without pushing. @php uses the PHP binary Composer itself is running under, and a leading @ in a script name calls another script in the same file; binaries in vendor/bin are on the path inside scripts, which is why phpstan needs no vendor/bin/ prefix here.

Example · json
{
    "scripts": {
        "lint": [
            "@php bin/console lint:yaml config",
            "@php bin/console lint:twig templates",
            "@php bin/console lint:container"
        ],
        "analyse": "phpstan analyse",
        "test": "phpunit",
        "ci": [
            "@lint",
            "@analyse",
            "@test"
        ]
    },
    "scripts-descriptions": {
        "ci": "Runs every check the pipeline runs."
    }
}

Discussion

  • Be the first to comment on this lesson.