Composer and PHP Dependency Management: The Basics That Actually Matter

The first time I ran composer require on a legacy project, I broke production for about forty minutes. Not because Composer is dangerous, but because nobody on that team understood what the tool was actually doing — we had a vendor/ folder committed to Git, no lock file, and a deploy script that ran composer update on the live server. Every one of those was a decision someone made without knowing the alternative.

If you write PHP and you still copy library folders into includes/ by hand, this is the post I wish someone had handed me in 2015.

What Composer actually does (and what it does not)

Composer is a dependency manager, not a package manager. That distinction matters more than it sounds. A package manager like apt installs software globally on a machine. Composer installs libraries per project, into a vendor/ directory that belongs to that project alone. Two projects on the same server can use Guzzle 6 and Guzzle 7 side by side and never collide.

It does three jobs:

  • Reads your composer.json to find out what you want.
  • Resolves the whole dependency tree — your packages, plus their packages, plus theirs — and finds a set of versions that all agree with each other.
  • Generates an autoloader so you never write require_once '../../lib/Whatever.php'; again.

That third one is the part people undervalue. The autoloader alone is worth adopting Composer for. You map a namespace to a folder once, and PHP finds your classes forever after.

composer.json vs composer.lock — the difference that bites people

This is the single most common misunderstanding I correct on my team, so let me be blunt about it.

composer.json is your intent. It says things like “I want Monolog, version 3-point-something.” It is loose on purpose.

composer.lock is the receipt. It records the exact version and commit hash of every single package that got installed — including the dependencies of your dependencies. It is precise on purpose.

From that, two rules follow:

  • composer install reads the lock file and installs exactly those versions. This is what runs on staging, on production, in CI, and on a new developer’s laptop.
  • composer update ignores the lock file, re-resolves everything against composer.json, and rewrites the lock. This runs on your machine, deliberately, when you intend to upgrade something — and never on a server.

Commit composer.lock to Git. Always, for applications. Without it, two developers running install a week apart get different code, and “works on my machine” becomes a real debugging cost instead of a joke.

Version constraints, decoded

The cryptic symbols in front of version numbers are the part most people skim past. They are worth ten minutes.

  • ^3.2 — caret. Allows anything from 3.2 up to but not including 4.0. This is the default when you run composer require, and it is the right choice roughly 90% of the time. It trusts the library to follow semantic versioning: no breaking changes within a major version.
  • ~3.2 — tilde. Allows 3.2 up to but not including 3.3. Tighter. Use it when a library has burned you with a sloppy minor release.
  • 3.2.1 — exact pin. No flexibility at all. Looks safe, is actually a trap: pin three packages this way and you will eventually hit an unresolvable conflict where two of them demand incompatible versions of a shared dependency.
  • * — anything. Do not do this. You are telling Composer that a major version jump with breaking changes is fine to install silently.

My default rule: caret for everything, and only tighten when a specific package has proven it needs tightening. Document why in a comment or commit message, because six months later nobody remembers.

require vs require-dev, and why your server should care

Composer splits dependencies into two buckets. require is what the application needs to run. require-dev is what you need to work on it — PHPUnit, PHP_CodeSniffer, a debug bar, faker.

On production, deploy with:

composer install --no-dev --optimize-autoloader --no-interaction

Three flags, three reasons. --no-dev skips test tooling you do not want sitting on a public server. --optimize-autoloader converts the autoloader into a flat class map instead of doing filesystem lookups on every class load — on a mid-sized app this is a measurable request-time win, not a micro-optimisation. --no-interaction stops the command from hanging on a prompt in a deploy pipeline at 2am.

And do not commit vendor/ to Git. Add it to .gitignore. Your repo does not need thirty thousand files it did not write, and your diffs become unreadable when a library update touches all of them.

The WordPress wrinkle

WordPress makes this harder than it should be, and it is worth naming why. WordPress core does not use Composer, and every plugin on a site shares one PHP process. If your plugin bundles Guzzle 7 and another plugin bundles Guzzle 6, whichever loads first wins — and the other one fatals.

Practical mitigations, in order of how often I reach for them:

  • Do not add the dependency. Before pulling in an HTTP client, check whether wp_remote_get() already does the job. It usually does. The best dependency is the one you did not add.
  • Scope your namespaces. Tools like PHP-Scoper rewrite your vendor packages under a prefix unique to your plugin, so collisions become impossible. Extra build step, but it is the correct answer for anything distributed publicly.
  • Guard your autoloader. At minimum, wrap the include in a class_exists() check and fail gracefully instead of white-screening the whole site.

For a client site where you control every plugin, a single project-root composer.json managing the whole install is cleaner than per-plugin vendor folders. For a distributed plugin, scoping is not optional.

A short audit you can run today

Open your current project and check four things. It takes five minutes.

  • Is composer.lock committed? If not, commit it.
  • Is vendor/ in .gitignore? If not, remove it from tracking.
  • Does your deploy script say update anywhere? Change it to install --no-dev.
  • Run composer audit. It checks your installed versions against a public database of known security advisories and tells you exactly which packages are vulnerable. Most developers I mention this to have never run it once.

The takeaway

Composer is not complicated — it is just under-explained. Two files, two commands, and one rule about which command runs where. Get those right and you eliminate an entire category of deployment bug: the kind where the code is identical but the behaviour is not.

The forty minutes of downtime I mentioned at the top came down to a single word in a deploy script. update instead of install. That is the whole lesson.

Faizan Khan
Faizan Khan

Technical PM & PHP developer — the manager who still ships code. 13 years turning “can we build this?” into “it’s live”.

Work with me →

Leave a comment

Enough talk. Let’s launch.

One call. An honest scope, a real timeline, and weekly updates until it ships.