Getting started with dependency management
When you're working on production systems handling real customer transactions, you quickly realize that reinventing the wheel for every feature is neither practical nor safe. That's where Composer and autoloading come in. Composer and autoloading: a hands-on guide for engineers means understanding how to pull in vetted packages, wire them into your codebase efficiently, and keep your team on the same version of every dependency. This guide walks through the mechanics that power modern PHP applications, from the initial composer require command through vulnerability audits before deployment.
Whether you're joining a team that's already using Composer or starting a new project from scratch, these patterns will feel immediately familiar once you see them in action. We've all hit the scenario where a junior developer somehow ends up with a different version of a package than what's in production, causing hours of debugging. The practices we cover here prevent exactly that kind of friction.
Composer require: adding packages to your project
Every project starts with dependencies. When you need to work with HTTP requests, database queries, or authentication, you don't write those libraries yourself—you declare them in Composer and let it handle the rest.
To add a package to your project, you run:
composer require monolog/monologThat single command does several things at once. Composer downloads the package and its own dependencies from Packagist (the default repository), resolves version conflicts, and updates your composer.json and composer.lock files. Your composer.json now shows that you depend on Monolog, and composer.lock records the exact version that was installed—critical for reproducibility.
Let's see this in a real scenario. You're building a logging system for a ride-hailing platform. Your composer.json might look like this after a few require commands:
{
"name": "acme/ride-hailing",
"require": {
"php": "^8.1",
"monolog/monolog": "^3.0",
"symfony/http-client": "^6.0",
"doctrine/orm": "^2.14"
}
}
The caret (^) prefix tells Composer you're okay with minor and patch updates, but not major breaking changes. If you need a specific version or want to lock to an exact release, you use different constraints—~ for patch-only updates, or just 1.2.3 for exact versions.
For development-only packages like testing frameworks, you use --dev:
composer require --dev phpunit/phpunitThis keeps your production environment lean. When you deploy, you run composer install --no-dev to skip test dependencies, reducing image size and attack surface.
PSR-4 autoloading: mapping namespaces to folders
Once packages are installed, they need to be loaded into memory when your code runs. PSR-4 is the PHP Standard Recommendation that defines how namespaces map to directory structures. Composer generates and maintains an autoloader that implements PSR-4, eliminating the need for manual require or include statements.
Here's how it works. When you define PSR-4 mappings in your composer.json:
{
"autoload": {
"psr-4": {
"RideHailing\\": "src/"
}
}
}
You're telling Composer: "Any class in the namespace RideHailing should be found in the src/ directory." If you have a class RideHailing\Drivers\Driver, Composer will look for it at src/Drivers/Driver.php. The namespace hierarchy maps directly to the folder structure.
Let's make this concrete. Your project structure looks like this:
ride-hailing/
├── composer.json
├── src/
│ ├── Drivers/
│ │ ├── Driver.php
│ │ └── DriverRepository.php
│ ├── Rides/
│ │ └── Ride.php
│ └── Notifications/
│ └── NotificationService.php
└── vendor/
Your Driver.php file starts with:
namespace RideHailing\Drivers;
class Driver {
public function __construct(private string $id) {}
}And in another file, you can use it immediately without any require:
namespace RideHailing\Rides;
use RideHailing\Drivers\Driver;
class Ride {
public function __construct(private Driver $driver) {}
}Composer generates an autoloader file at vendor/autoload.php. Your entry point (usually public/index.php in a web app) includes just one line:
require_once __DIR__ . '/../vendor/autoload.php';That single include wires up all PSR-4 mappings for both your code and every third-party package. The autoloader uses a class map for installed packages (generated once during installation) and lazy-loads your own application code on demand. This is fast in production and flexible during development.
You can define multiple PSR-4 namespaces if your project is organized into distinct domains:
{
"autoload": {
"psr-4": {
"RideHailing\\Drivers\\": "src/domain/drivers/",
"RideHailing\\Rides\\": "src/domain/rides/",
"RideHailing\\Payments\\": "src/domain/payments/"
}
}
}
After modifying composer.json, run composer dump-autoload to regenerate the autoloader without fetching packages again.
Committing composer.lock for reproducible deployments
Here's a critical practice that separates teams that deploy reliably from those that don't: always commit your composer.lock file to version control.
When you run composer install, Composer first checks if composer.lock exists. If it does, it installs the exact versions listed in the lock file, ignoring the version constraints in composer.json. This means your local environment, your colleague's machine, your CI/CD pipeline, and your production servers all run identical versions of every dependency.
Without composer.lock committed, each environment runs composer install and picks up whatever the latest version satisfying your constraints happens to be at that moment. If a package author releases a minor version bump that inadvertently breaks something, your production environment might get it while your development environment doesn't. Debugging that kind of environment mismatch is nightmarish.
Your git workflow looks like this:
- Developer adds a new package:
composer require some/package - Both
composer.jsonandcomposer.lockare modified - Both files are committed together to version control
- Other team members pull and run
composer install - They get the exact same package versions
If you need to update dependencies—say, to get a security patch—you run composer update, which updates composer.lock` to newer versions that still satisfy your constraints. That updated lock file then gets committed, and everyone's environment updates together.
In a production deployment, you typically run:
composer install --no-dev --prefer-distThe --no-dev flag skips development dependencies. The --prefer-dist flag downloads pre-built archives instead of cloning git repositories, which is significantly faster and reduces disk I/O during deployment.
composer audit: checking for known vulnerabilities
Dependencies aren't just about convenience—they're also a vector for security issues. A vulnerable package in your dependency tree can expose your entire application. Composer provides composer audit to scan your installed packages against a database of known vulnerabilities.
Running it is straightforward:
composer auditThe output tells you which packages have vulnerabilities, their severity level, and which versions fix the issue. For example:
symfony/http-kernel 6.0.0 has 2 vulnerability/vulnerabilities.
├─ Remote Code Execution (RCE) via deserialization
└─ Information Disclosure via error handler
symfony/http-kernel 6.0.1 resolves the issues.
You'd then update that package and re-run composer audit` to confirm the vulnerability is gone:
composer require symfony/http-kernel:^6.0.1In CI/CD pipelines, add composer audit --exit-code as a build step. The exit code 1 signals failure if vulnerabilities are found, preventing merged PRs or deployments that introduce known security issues. This is as critical as running your test suite.
Best practice: run audits regularly, not just when adding new packages. New vulnerabilities are discovered all the time. Many teams run composer audit daily or weekly and get alerted if issues are found.
Common mistakes
- Not committing composer.lock: This creates environment drift. Every engineer ends up with different versions, causing "works on my machine" bugs.
- Using require without specifying constraints:
composer require symfony/consolewithout a version constraint can pull major versions that introduce breaking changes. Be explicit with constraints like^6.0. - Running composer install on production without the lock file: If your lock file isn't in version control, production can install different versions than your staging environment tested.
- Ignoring composer audit warnings: Postponing security updates is a technical debt that compounds. Address vulnerabilities as soon as patches are available.
- Keeping development dependencies in production: Forgetting
--no-dev` during production deployment increases image size and attack surface. Test frameworks don't belong in production. - Modifying vendor/ manually: The vendor directory is generated by Composer. Any manual changes get wiped on the next
composer install. Always update through Composer.
Key takeaways
composer requirefetches packages and their dependencies from Packagist, updating bothcomposer.jsonandcomposer.lock.- PSR-4 autoloading maps namespaces to directories, eliminating manual
requirestatements and enabling clean class loading. - Always commit
composer.lockto version control to guarantee that every environment runs identical dependency versions. - Run
composer auditregularly in CI/CD pipelines to catch vulnerabilities before they reach production. - Use version constraints like
^thoughtfully; they balance flexibility with stability. - Deploy with
--no-devand--prefer-distfor faster, leaner production environments.






Comments
Loading comments…