Vendoring #
Before Composer existed, managing PHP dependencies was done manually — download a library, drop it in a folder, include it one by one. That approach doesn’t scale: how do you ensure every developer on the team uses the same version? How do you handle libraries that depend on other libraries? Composer solves all of this. It isn’t just a package downloader — it’s a dependency resolver that understands version constraints, manages transitive dependencies, generates an efficient PSR-4 autoloader, and ensures every environment (development, staging, production) runs bit-identical code. This article covers Composer thoroughly: from installation to the nuances of composer.lock, correct version constraints, autoloading management, and the practices that keep a PHP project maintainable over the long term.
How Composer Works #
Composer works in two layers that need to be understood:
flowchart TD
A[composer.json\nDescription of requirements] --> B[Composer]
B --> C{Does composer.lock\nalready exist?}
C -- Yes --> D[Install EXACT versions\nper the lock file]
C -- No --> E[Resolve dependency\ngraph from Packagist]
E --> F[Create composer.lock\nwith pinned versions]
F --> D
D --> G[Download to vendor/]
G --> H[Generate vendor/autoload.php]
H --> I[Ready to use]
style A fill:#dbeafe
style F fill:#fef9c3
style H fill:#dcfce7composer.json is the description of requirements — “I need monolog version 3.x or newer”. composer.lock is the record of the resolution — “the exact version installed is monolog 3.5.0 with all its dependencies”. When install is run and the lock already exists, Composer uses the versions recorded in the lock without renegotiating. This is what guarantees every environment gets identical code.
Installing Composer #
Global Installation (Recommended) #
# Linux / macOS
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
chmod +x /usr/local/bin/composer
# Verify
composer --version
# Composer version 2.x.x
Installation on Windows #
Download and run Composer-Setup.exe from getcomposer.org. The installer automatically adds Composer to your PATH so it can be called from any Command Prompt.
Checking and Updating Composer #
# Check the installed version
composer --version
# Update to the latest version
composer self-update
# Update to a specific stable version
composer self-update 2.7.0
# Rollback if there's a problem
composer self-update --rollback
Project Structure with Composer #
Once Composer is initialized, a typical PHP project directory structure looks like this:
my-project/
├── composer.json ← dependency definitions — commit to Git
├── composer.lock ← exact installed versions — commit to Git
├── vendor/ ← all installed libraries — DON'T commit to Git
│ ├── autoload.php ← autoloading entry point
│ ├── composer/ ← Composer's internal metadata
│ ├── monolog/monolog/ ← the monolog library
│ └── guzzlehttp/guzzle/ ← the guzzle library
├── src/ ← your application source code
│ └── App/
│ └── UserService.php
├── tests/ ← unit tests
└── index.php ← application entry point
The correct .gitignore file:
vendor/
.env
Don’t commit thevendor/directory to Git. It can reach hundreds of MB and contains code that can be regenerated withcomposer install. What matters is committingcomposer.jsonandcomposer.lock. Anyone who clones the repository can get an identical environment by runningcomposer install.
composer.json — Complete Anatomy
#
{
"name": "acme/online-store",
"description": "PHP-based online store application",
"type": "project",
"license": "MIT",
"authors": [
{
"name": "Budi Santoso",
"email": "[email protected]"
}
],
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": "^8.2",
"monolog/monolog": "^3.0",
"guzzlehttp/guzzle": "^7.0",
"vlucas/phpdotenv": "^5.6",
"ramsey/uuid": "^4.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"fakerphp/faker": "^1.23",
"phpstan/phpstan": "^1.10",
"squizlabs/php_codesniffer": "^3.0"
},
"autoload": {
"psr-4": {
"Acme\\OnlineStore\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Acme\\OnlineStore\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit --testdox",
"test:coverage": "phpunit --coverage-html coverage/",
"lint": "phpcs src/ tests/ --standard=PSR12",
"analyse": "phpstan analyse src/ --level=8",
"post-install-cmd": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-update-cmd": [
"composer dump-autoload --optimize"
]
},
"config": {
"sort-packages": true,
"preferred-install": "dist",
"optimize-autoloader": true
},
"extra": {
"branch-alias": {
"dev-main": "1.0-dev"
}
}
}
Version Constraints — Specifying Allowed Versions #
Understanding version constraints is one of the most important parts of Composer. A wrong constraint can make dependency updates impossible or install incompatible versions.
PHP uses Semantic Versioning (semver): MAJOR.MINOR.PATCH. MAJOR changes may break, MINOR adds backward-compatible features, PATCH is only bugfixes.
{
"require": {
"vendor/package": "version"
}
}
| Constraint | Meaning | Allowed Examples |
|---|---|---|
3.5.0 | Exact version | Only 3.5.0 |
>=3.5.0 | 3.5.0 and above | 3.5.0, 3.6.0, 4.0.0, etc. |
>=3.5 <4.0 | Range | 3.5.x, 3.6.x, 3.7.x |
^3.5.0 | Caret: same MAJOR, MINOR can rise | 3.5.0 – 3.x.x (not 4.x) |
^3.5 | Caret shorthand | 3.5 – 3.x.x |
~3.5.0 | Tilde: only PATCH can rise | 3.5.0 – 3.5.x (not 3.6) |
~3.5 | Tilde shorthand: MINOR can rise | 3.5 – 3.x.x |
3.* | Wildcard: all 3.x patches | 3.0, 3.1, 3.9, etc. |
dev-main | Git branch | the main branch |
# Examples of using constraints in commands
composer require monolog/monolog "^3.0" # MAJOR 3, MINOR free
composer require guzzlehttp/guzzle "~7.5" # 7.5.x only
composer require vlucas/phpdotenv "*" # any version (not recommended)
Version Constraint Best Practices #
✓ Use caret ^MAJOR.MINOR for most libraries
→ ^3.0 means "compatible 3.x", safely accepts bugfixes and new features
✓ Use tilde ~MAJOR.MINOR.PATCH if the library has breaking changes in minor versions
→ ~3.5.0 means "patch updates only"
✗ Avoid overly strict constraints (exact 3.5.0)
→ You miss out on bugfixes and security patches
✗ Avoid overly loose constraints (>=3.0 or *)
→ Could install a version with breaking changes
The Most Frequently Used Composer Commands #
Initialization and Installation #
# Create composer.json interactively
composer init
# Install all dependencies (uses the lock file if it exists)
composer install
# Install WITHOUT development dependencies (for production)
composer install --no-dev --optimize-autoloader
# Add a new dependency
composer require vendor/package
composer require vendor/package "^2.0"
# Add a development dependency
composer require --dev phpunit/phpunit
Updating Dependencies #
# Update ALL dependencies (updates composer.lock)
composer update
# Update only one package
composer update vendor/package
# Check whether updates are available
composer outdated
# Check only for security-fix updates
composer audit
Autoloading and Diagnostics #
# Regenerate the autoloader (after adding new namespaces)
composer dump-autoload
# Autoloader optimization for production
composer dump-autoload --optimize
composer dump-autoload -o
# Check dependencies graphically
composer show
composer show vendor/package # detailed info about one package
composer show --tree # display the dependency tree
# Validate composer.json
composer validate
Removing Dependencies #
# Remove a package and update composer.json + lock
composer remove vendor/package
composer remove --dev phpunit/phpunit
PSR-4 Autoloading #
Composer generates an autoloader that loads PHP classes automatically — no manual require or include for every file. The standard used is PSR-4, which maps namespaces to directories.
Autoloading Configuration #
{
"autoload": {
"psr-4": {
"Acme\\App\\": "src/",
"Acme\\Core\\": "core/"
},
"files": [
"src/helpers.php"
],
"classmap": [
"legacy/"
]
}
}
With the configuration above:
- The class
Acme\App\UserServiceis looked up insrc/UserService.php - The class
Acme\App\Http\Requestis looked up insrc/Http/Request.php src/helpers.phpis always loaded (for global functions)- All classes in the
legacy/directory are scanned and mapped
The Correct Directory Structure #
src/
├── UserService.php → namespace Acme\App;
├── Http/
│ ├── Request.php → namespace Acme\App\Http;
│ └── Response.php → namespace Acme\App\Http;
├── Repository/
│ └── UserRepository.php → namespace Acme\App\Repository;
└── Exception/
└── NotFoundException.php → namespace Acme\App\Exception;
<?php
// src/UserService.php
namespace Acme\App;
use Acme\App\Repository\UserRepository;
use Acme\App\Exception\NotFoundException;
class UserService
{
public function __construct(
private UserRepository $repo
) {}
public function findOrFail(int $id): array
{
return $this->repo->find($id)
?? throw new NotFoundException("User $id not found");
}
}
<?php
// index.php — just require this one file
require __DIR__ . '/vendor/autoload.php';
use Acme\App\UserService;
use Acme\App\Repository\UserRepository;
// All classes are loaded automatically on first use
$service = new UserService(new UserRepository());
$user = $service->findOrFail(1);
require vs require-dev
#
require is for dependencies needed in every environment (development and production). require-dev is for dependencies only needed during development — testing, linting, debugging.
{
"require": {
"php": "^8.2",
"monolog/monolog": "^3.0",
"guzzlehttp/guzzle": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"fakerphp/faker": "^1.23",
"phpstan/phpstan": "^1.10",
"barryvdh/laravel-debugbar": "^3.0"
}
}
# Development — install everything including require-dev (default)
composer install
# Production — skip require-dev, lighter and safer
composer install --no-dev
# If you already installed with dev, clean up for production
composer install --no-dev --optimize-autoloader
In a CI/CD pipeline deploying to production, always usecomposer install --no-dev --optimize-autoloader. The--no-devflag ensures debug libraries don’t end up in production, and--optimize-autoloaderproduces a classmap that’s far faster than PSR-4 traversal.
Composer Scripts #
The scripts section in composer.json lets you define commands that can be run with composer run-script or the shorthand composer script-name:
{
"scripts": {
"test": "phpunit --testdox --colors=always",
"test:unit": "phpunit --testsuite=Unit",
"test:integration": "phpunit --testsuite=Integration",
"test:coverage": "phpunit --coverage-html coverage/",
"lint": "phpcs src/ tests/ --standard=PSR12",
"lint:fix": "phpcbf src/ tests/ --standard=PSR12",
"analyse": "phpstan analyse src/ tests/ --level=8",
"check": [
"@lint",
"@analyse",
"@test"
],
"post-install-cmd": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"composer dump-autoload"
],
"post-update-cmd": "@composer dump-autoload --optimize",
"post-create-project-cmd": [
"@php artisan key:generate"
]
}
}
# Run defined scripts
composer test
composer lint:fix
composer check # runs lint, analyse, and test in sequence
# Lifecycle scripts run automatically
# post-install-cmd → after composer install
# post-update-cmd → after composer update
# pre-install-cmd → before composer install
composer.lock — Why It’s So Important
#
composer.lock stores the exact versions of every installed package — including all transitive dependencies (dependencies of dependencies). This file ensures everyone on the team and every environment runs truly identical code.
{
"_readme": [...],
"content-hash": "abc123...",
"packages": [
{
"name": "monolog/monolog",
"version": "3.5.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "e5c62b8a58f842c5..."
},
"dist": {
"type": "zip",
"url": "https://api.github.com/...",
"shasum": "..."
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
}
}
],
"packages-dev": [...],
"platform": {
"php": "8.2"
}
}
When to Update the Lock File #
composer install → does NOT update the lock, installs exactly per the lock
composer update → UPDATES the lock with the newest versions meeting constraints
When to run composer update:
✓ When you deliberately want to upgrade dependencies (and are ready to test regressions)
✓ For security patches to a specific package
✓ After changing constraints in composer.json
Don't run composer update in production — use composer install only
Production Optimization #
# The complete command for production deployment
composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader \
--classmap-authoritative
# Flag explanations:
# --no-dev → skip require-dev
# --no-interaction → no prompts (good for CI/CD)
# --prefer-dist → download zips, faster than git clone
# --optimize-autoloader → build a classmap from PSR-4 (faster)
# --classmap-authoritative → only look up classes in the classmap (fastest)
Autoloader performance differences:
| Mode | How It Works | Performance |
|---|---|---|
| Default (PSR-4) | Directory traversal when a class is first called | Slow |
--optimize-autoloader | Classmap + PSR-4 fallback | Fast |
--classmap-authoritative | Classmap only, no fallback | Fastest |
Common Composer Anti-Patterns #
# ✗ Anti-pattern 1: committing the vendor/ directory
git add vendor/
# Solution: add vendor/ to .gitignore
# ✗ Anti-pattern 2: running composer update in production
ssh production "composer update"
# Solution: run composer update only in development, commit the lock file, deploy with composer install
# ✗ Anti-pattern 3: not committing composer.lock
echo "composer.lock" >> .gitignore
# Solution: ALWAYS commit composer.lock — this is what guarantees reproducible builds
# ✗ Anti-pattern 4: overly loose constraints
"require": { "vendor/package": "*" }
# Solution: always specify a minimum constraint like "^3.0"
# ✗ Anti-pattern 5: mixing require and require-dev
"require": {
"phpunit/phpunit": "^11.0" # test library in production require!
}
# Solution: testing, debugging, and linting libraries always go in require-dev
# ✗ Anti-pattern 6: not running composer audit regularly
# Solution: add it to the CI pipeline
# composer audit — check security vulnerabilities across all dependencies
composer audit
# Package monolog/monolog (3.4.0) is affected by CVE-2024-XXXX...
# Integrate into a CI/CD pipeline
composer audit --no-interaction --format=json
Finding Packages on Packagist #
Packagist.org is the official PHP package repository. How to search and evaluate packages:
# Search for packages from the terminal
composer search monolog
# View package details
composer show monolog/monolog
composer show monolog/monolog --all # complete info including all versions
Criteria for evaluating a package before adding it to a project:
- Weekly downloads high enough (popularity)
- Active maintenance — when was the last commit?
- Number of stars and open issues on GitHub
- Does it have a good test suite?
- Does it support the PHP version you’re using?
- A license compatible with your project
Summary #
composer.jsondefines the requirements (what and which versions),composer.lockrecords the resolution (the exact installed versions). Both must be committed to Git; thevendor/directory must not.composer installinstalls the exact versions from the lock file — use it in staging and production.composer updatelooks for the newest versions meeting the constraints and updates the lock file — use it only when you want to upgrade.- Caret
^is the most often appropriate constraint:^3.0means “compatible 3.x”, accepting bugfixes and new features but not a major version with breaking changes.require-devfor dependencies only needed during development (testing, linting, debugging). Deploy to production withcomposer install --no-devto exclude them.- PSR-4 autoloading maps namespaces to directories — after adding new classes or changing namespaces, run
composer dump-autoloadto update the classmap.- Production optimization with
--optimize-autoloaderor--classmap-authoritative— turning PSR-4 traversal into a far faster classmap lookup.composer auditchecks all dependencies against the security vulnerability database — integrate it into your CI/CD pipeline and run it regularly.composer scriptslets you define project commands (test, lint, analyse) incomposer.jsonso all developers use the same commands.