Dependencies and Supply Chain
Treat composer.lock as the truth, audit it on every build, review updates in pull requests, and keep pipeline credentials narrow.
composer audit --locked --abandoned=reportA Symfony application is mostly other people's code. The supply chain is everything that code travels through to reach production: Packagist, git tags, your lock file, your pipeline's credentials. Each is a way for something you did not write to start running.
The lock file is the truth
composer.json holds ranges, such as 8.1.*. composer.lock holds the one version and commit reference that was resolved from each range. composer install reads the lock and installs exactly that; composer update ignores the lock and resolves the ranges again. Only install belongs in CI and in an image build, which is why the lock file is committed. composer install -o and composer install --optimize-autoloader are the same flag, long and short, and production images use it to turn PSR-4 lookups into a flat class map.
Knowing when something is vulnerable
composer audit --locked checks the locked versions against published advisories and exits non-zero on a hit, which makes it a build step rather than a chore. --format=json, short form -f json, gives output a script can read. Dependabot is a bot that opens a pull request when a dependency releases a new version, so an upgrade arrives as reviewable code with CI attached.
What trips people up
The pipeline is part of the supply chain. A deploy token that can push to any repository or read every secret turns one compromised action into a full breach. Scope it to what the job needs.
Example
<?php
// src/Diagnostics/DependencyReport.php
declare(strict_types=1);
namespace App\Diagnostics;
use Composer\InstalledVersions;
/**
* Reports the versions actually installed in this container, read from the
* data Composer writes into vendor/, not from composer.json's ranges.
*/
final class DependencyReport
{
/**
* @param list<string> $packages
*/
public function __construct(
private readonly array $packages = [
'symfony/framework-bundle',
'doctrine/orm',
],
) {
}
/**
* @return array<string, string>
*/
public function versions(): array
{
$versions = [];
foreach ($this->packages as $name) {
if (!InstalledVersions::isInstalled($name)) {
continue;
}
// getPrettyVersion() returns null for a package with no version info.
$versions[$name] = InstalledVersions::getPrettyVersion($name) ?? 'unknown';
}
return $versions;
}
}
// $report->versions();
// Output:
// [
// 'symfony/framework-bundle' => 'v8.1.0',
// 'doctrine/orm' => '3.3.0',
// ]When to use it
- Make a published advisory fail the build instead of waiting for a pen test.
- Keep the production image on exactly the versions CI tested.
- Review a framework upgrade as a single pull request with the suite attached.
- Stop a compromised pipeline step from reaching repositories it has no business in.
More examples
Auditing the lock file in the pipeline
Run the audit on every build and also on a nightly schedule, because an advisory can be published for code you have not touched in months. --locked reads composer.lock directly, so the step is fast and needs no install. --abandoned=report lists unmaintained packages without failing the build; --abandoned=fail makes them an error once you have dealt with the existing ones.
# Check the locked versions, even before vendor/ exists.
$ composer audit --locked --abandoned=report
# No security vulnerability advisories found.
# Production only: ignore dev tools that never ship in the image.
$ composer audit --locked --no-dev
# Machine-readable, for a job that posts the result somewhere.
$ composer audit --locked -f json
# Confirm composer.lock still matches composer.json.
$ composer validate --strictLetting updates arrive as pull requests
Grouping is what makes this usable: Symfony's components release together, and separate pull requests for each one cannot pass CI on their own. The docker entry matters as much as the composer one, because the base image carries the system libraries and the PHP build. Every update then goes through the same pipeline as your own code, so the suite is what decides whether it is safe.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: composer
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
# One pull request for the whole framework, not thirty.
symfony:
patterns:
- "symfony/*"
doctrine:
patterns:
- "doctrine/*"
- package-ecosystem: docker
directory: "/"
schedule:
interval: weeklyPinning what resolution is allowed to assume
config.platform tells Composer which PHP version to resolve against, so a developer on a newer PHP cannot lock a dependency that the production image's PHP rejects. allow-plugins is an explicit list of packages permitted to run code during install: anything not listed is ignored instead of executed, which is the point. extra.symfony.require keeps every Symfony component on one minor version, so an update pull request is the whole framework moving together.
{
"require": {
"php": ">=8.5",
"symfony/framework-bundle": "8.1.*",
"doctrine/orm": "^3.3"
},
"config": {
"sort-packages": true,
"platform": {
"php": "8.5.0"
},
"allow-plugins": {
"symfony/flex": true,
"symfony/runtime": true
}
},
"extra": {
"symfony": {
"require": "8.1.*"
}
}
}
Discussion