Continuous Integration
Run install, linters, static analysis and the full test suite against a real database on every push, and fail on deprecations.
composer install --prefer-dist --no-progress --no-interactionContinuous 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
composer validate --strict— fails on a malformedcomposer.jsonand on a lock file that no longer matches it.composer install --prefer-dist --no-progress --no-interaction— installs the exact versions recorded incomposer.lock. The shorthand for the last flag is-n; they are the same option.- Linters:
bin/console lint:yaml config,bin/console lint:twig templates, andbin/console lint:container, which compiles the service container and fails on a missing or wrongly typed service before any test runs. - PHPStan, so a type error is a build failure rather than a production exception.
- 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
<?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.
# .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/phpunitTurning 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.
<!-- 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 & in XML. -->
<env name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&max[direct]=0&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.
{
"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