What Composer does

Almost every PHP application uses code written by others: a logging library, an HTTP client, a PDF generator, a whole framework. Copying those files into the project by hand works until you need an update, or until two libraries need different versions of a third. Composer is PHP's dependency manager: you declare which packages a project needs, and Composer finds versions that satisfy all requirements together, including the packages those packages need, downloads them into the project's vendor/ folder and generates an autoloader for them.

Composer works per project, not per machine: two applications on one server can use different versions of the same library without conflict. That is the main difference from a system package manager such as apt, which installs one version for the whole system. As of October 2026 the current release is Composer 2.10, which itself needs PHP 7.2 or later; the 2.2 LTS line exists for very old PHP versions.

Packagist

Packagist (packagist.org) is the public registry Composer searches by default. It holds the metadata of each package (names, versions, requirements) and points to the source code, usually a Git repository on GitHub or GitLab. As of October 2026 it lists about 470,000 packages. Package names have two parts, vendor and project: monolog/monolog, guzzlehttp/guzzle, symfony/console. Anyone can publish a package, so a name on Packagist says nothing about quality or trustworthiness. Companies host internal packages in a private registry (such as Private Packagist or a GitLab package registry) and list it under repositories in composer.json.

Installing Composer

Composer is a single PHP program, composer.phar. The download page on getcomposer.org gives an installer script with a checksum to verify; most distributions also package it (on Debian and Ubuntu: the composer package, often an older version). Check that it works with:

composer --version
composer diagnose

Do not run Composer as root. Packages can contain scripts and plugins that run during installation, and Composer itself warns when it runs with root rights.

composer.json and version constraints

composer.json is the file in the project root that describes the project for Composer: its name, the packages it needs, the PHP version and extensions it requires, and how its own classes are autoloaded. You can write it by hand or let composer init and composer require create and update it.

{
    "name": "kestrel/meter-portal",
    "description": "Customer portal for submitting meter readings",
    "type": "project",
    "require": {
        "php": ">=8.4",
        "ext-pdo": "*",
        "guzzlehttp/guzzle": "^7.0",
        "monolog/monolog": "^3.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^13.0"
    },
    "autoload": {
        "psr-4": { "Kestrel\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "Kestrel\\Tests\\": "tests/" }
    },
    "config": {
        "sort-packages": true
    }
}
  • require lists what the application needs to run. The entries php and ext-pdo are platform requirements: Composer refuses to install when the PHP version or an extension is missing.
  • require-dev lists tools only needed while developing and testing, such as PHPUnit or PHPStan (see modern PHP). They are left out of production installs.
  • autoload tells Composer where the project's own classes live (see autoloading).

Version constraints

Packages on Packagist use semantic versioning: a new major version may break your code, minor and patch versions should not. Constraints in composer.json describe which versions you accept:

Constraint Accepts Typical use
^3.4 >=3.4.0 and <4.0.0 the normal choice: all compatible updates
^0.3.1 >=0.3.1 and <0.4.0 below 1.0, each minor version may break things
~3.4.2 >=3.4.2 and <3.5.0 patch updates only
3.4.2 exactly 3.4.2 rarely; blocks security fixes
>=8.4 8.4 or anything newer the PHP version
* any version extensions; avoid for packages

A range in composer.json is a promise about what could work. Which version is actually installed is decided once, by composer update, and written down in the lock file.

Installing and updating packages

The everyday commands, run in the project root:

# Add a package: updates composer.json and composer.lock, installs it
composer require monolog/monolog
composer require --dev phpunit/phpunit

# Install exactly what composer.lock says (after a clone, on a server, in CI)
composer install

# Look for newer versions within the constraints, then update one package
composer outdated --direct
composer update monolog/monolog

# What is installed, and why is this package here?
composer show
composer why psr/log

# Remove a package and everything only it needed
composer remove guzzlehttp/guzzle
Using version ^3.x for monolog/monolog
./composer.json has been updated
Running composer update monolog/monolog
Loading composer repositories with package information
Updating dependencies
Lock file operations: 2 installs, 0 updates, 0 removals
  - Locking monolog/monolog (3.x.y)
  - Locking psr/log (3.x.y)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
  - Installing psr/log (3.x.y): Extracting archive
  - Installing monolog/monolog (3.x.y): Extracting archive
Generating autoload files

Adding Monolog also installed psr/log, because Monolog requires it. Such indirect dependencies are usually the majority of vendor/: every one is code that runs in your application.

composer.lock: install versus update

composer.lock is the lock file Composer writes after resolving the dependencies: the exact version of every installed package, direct and indirect, with its download location and the exact commit (reference) it was built from. The two main commands treat it differently:

  • composer install reads the lock file and installs exactly those versions. When there is no lock file yet, it behaves like update and creates one.
  • composer update ignores the old lock file, picks the newest versions that fit the constraints and writes a new lock file. Run it on purpose, on a development machine, then test and commit the new composer.lock.

For an application, commit composer.lock: then your laptop, the CI pipeline and production all run the versions you tested, and an update is a reviewable change in Git. Put vendor/ in .gitignore; it can always be rebuilt from the lock file. A library is installed into other projects, which resolve their own versions; its lock file only affects its own tests.

Because the lock file is plain JSON, a script can read it, for example to report the installed versions on a server. In practice composer show --locked gives the same list:

<?php
// A shortened composer.lock with example versions (the real file also has
// download URLs, source commits, licenses and more for each package)
file_put_contents('composer.lock', json_encode([
    'packages' => [
        ['name' => 'guzzlehttp/guzzle', 'version' => '7.10.0'],
        ['name' => 'monolog/monolog', 'version' => '3.9.0'],
        ['name' => 'psr/log', 'version' => '3.0.2'],
    ],
    'packages-dev' => [
        ['name' => 'phpunit/phpunit', 'version' => '13.0.5'],
    ],
]));

$lock = json_decode(file_get_contents('composer.lock'), true, flags: JSON_THROW_ON_ERROR);
foreach (['packages' => 'production', 'packages-dev' => 'development'] as $key => $label) {
    foreach ($lock[$key] ?? [] as $package) {
        printf("%-20s %-8s %s\n", $package['name'], $package['version'], $label);
    }
}
guzzlehttp/guzzle    7.10.0   production
monolog/monolog      3.9.0    production
psr/log              3.0.2    production
phpunit/phpunit      13.0.5   development

Autoloading with PSR-4

PHP loads a class file only when told to, with require. Instead of hundreds of require lines, PHP lets a program register an autoloader with spl_autoload_register(): a function PHP calls the first time an unknown class is used, which finds and loads the right file. Composer generates such an autoloader for every package in vendor/ and for your own code. An application includes it once, at the top of its entry point:

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Kestrel\Billing\Invoice;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$log = new Logger('portal');
$log->pushHandler(new StreamHandler('php://stderr'));
$invoice = new Invoice(customerId: 7);       // loaded from src/Billing/Invoice.php

PSR-4 autoloading is the convention that makes this predictable. PSR-4 is one of the PHP-FIG standards (see PHP-FIG and PSRs): a namespace prefix is mapped to a base folder, and the rest of the class name becomes the path below it, one folder per namespace level, with .php added. With "Kestrel\\": "src/" (the backslash is doubled because this is JSON), the class Kestrel\Billing\Invoice is loaded from src/Billing/Invoice.php.

The code uses new Kestrel\Billing\Invoice. PHP does not know the class yet and calls the autoloader registered by vendor/autoload.php. The autoloader finds the PSR-4 mapping from the prefix Kestrel\ to the folder src/, turns the rest of the name into the path Billing/Invoice.php, and requires src/Billing/Invoice.php once. Your code new Kestrel\… \Billing\Invoice Autoloader vendor/ autoload.php PSR-4 map Kestrel\ → src/ require src/Billing/ Invoice.php Only the first use of a class triggers a lookup; a class that is never used is never loaded.
The namespace decides the file: prefix to base folder, each further namespace level to a subfolder.

The autoloader Composer generates is more refined, but the idea fits in a few lines. This example creates one class file the PSR-4 way and registers its own autoloader for the same mapping:

<?php
// A tiny project with two classes in src/, laid out the PSR-4 way
mkdir('src/Billing', 0777, true);
file_put_contents('src/Billing/Invoice.php', <<<'PHP'
<?php
namespace Kestrel\Billing;

class Invoice
{
    public function __construct(public int $customerId) {}
}
PHP);

// What Composer's autoloader does for the mapping "Kestrel\\": "src/"
spl_autoload_register(function (string $class): void {
    $prefix = 'Kestrel\\';
    if (!str_starts_with($class, $prefix)) {
        return;                              // not ours: let other autoloaders try
    }
    $path = 'src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
    echo "Looking up $class in $path\n";
    if (is_file(__DIR__ . '/' . $path)) {
        require __DIR__ . '/' . $path;
    }
});

$first = new Kestrel\Billing\Invoice(7);     // triggers the autoloader
$second = new Kestrel\Billing\Invoice(8);    // already loaded: no lookup
echo get_class($second), " for customer {$second->customerId}\n";
var_dump(class_exists('Kestrel\Reports\Monthly'));
Looking up Kestrel\Billing\Invoice in src/Billing/Invoice.php
Kestrel\Billing\Invoice for customer 8
Looking up Kestrel\Reports\Monthly in src/Reports/Monthly.php
bool(false)

The class name and the file name must match exactly, including upper and lower case: src/billing/invoice.php may work on a case-insensitive file system (Windows, macOS) and fail on the Linux server. After changing the autoload section of composer.json, run composer dump-autoload to regenerate the autoloader.

Composer in deployment

A production install leaves out the development tools and builds a faster autoloader:

composer install --no-dev --optimize-autoloader --no-interaction
  • --no-dev skips require-dev: test tools do not belong on a public server, where they only add code that can be attacked.
  • --optimize-autoloader turns the PSR-4 rules into a class map (one lookup table of class to file), which saves file system checks on every request.
  • --no-interaction makes Composer fail instead of waiting for a question nobody will answer in a pipeline.

Usually this runs in the build step of a CI/CD pipeline, and the result, code plus vendor/, is deployed as one release (see deploying a release). Keep vendor/, composer.json and composer.lock outside the web root, so they cannot be requested in a browser: only the public/ folder with index.php should be reachable.

Checking dependencies for vulnerabilities

Every package is code that runs with all the rights of your application: it can read the database password, the session data and the files. Two kinds of risk follow. A package can have a known security bug that is fixed in a newer version, or a package can be malicious itself: a hijacked maintainer account, or a look-alike name, which is a supply chain attack. The OWASP Top 10 of 2025 lists software supply chain failures as A03.

composer audit

composer audit checks the installed packages, or with --locked those in composer.lock, against the security advisories that Packagist collects, and also flags packages marked as malware or abandoned (by default an abandoned package fails the audit too). It exits with a non-zero code when it finds a problem, so a CI job fails on it:

composer audit --locked
composer audit --locked --no-dev --format=json      # for scripts and reports
Found 1 security vulnerability advisory affecting 1 package:
+-------------------+----------------------------------------------+
| Package           | example/pdf-renderer                         |
| Severity          | high                                         |
| Advisory ID       | PKSA-xxxx-xxxx-xxxx                          |
| CVE               | CVE-2026-xxxxx                               |
| Title             | Path traversal in font loading               |
| Affected versions | >=2.0.0,<2.4.3                               |
+-------------------+----------------------------------------------+

The usual fix is composer update example/pdf-renderer to a version outside the affected range, then the tests and a new lock file. Run the audit in the pipeline on every change and on a schedule: new advisories appear for code you have not touched. It fits into the wider vulnerability management process.

Recent Composer versions also act before installation. As of 2026: since Composer 2.9, update, require and remove refuse package versions with a known security advisory, and since 2.10 packages flagged as malware are blocked even during install; abandoned packages are reported but not blocked unless you configure it. The rules live under policy in the config section of composer.json, and --no-blocking switches them off for one command.

Choosing and maintaining packages

  • Check before you add. Is it maintained (recent releases, answered issues), widely used, and is the name exactly right? Fewer dependencies mean fewer things to patch.
  • Allow plugins on purpose. Composer plugins and scripts run code during installation. Composer asks before it enables a plugin and records the answer under allow-plugins; review that list like code.
  • Update regularly, in small steps. A monthly composer outdated and update is easier to test than two years of changes at once. Tools such as Dependabot or Renovate open the update as a pull request.
  • Mind abandoned packages. A package whose maintainer stopped gets no security fixes; Packagist marks it abandoned and often names a replacement.
  • Check the lock file into review. A pull request that changes composer.lock changes code that runs in production, even if no file of your own changed.

composer validate checks that composer.json is valid and that the lock file is up to date with it, a useful first step in a pipeline.