Lesson 46 • Advanced
Composer Packages 📦
By the end of this lesson you'll manage third-party libraries with Composer, wire up PSR-4 autoloading so your classes load themselves, choose the right version constraints, and publish your own package to Packagist for the world to use.
What You'll Learn in This Lesson
- Install and update dependencies with require, install and update
- Tell composer.json apart from composer.lock — and know which to commit
- Map namespaces to folders with PSR-4 and load them via vendor/autoload.php
- Read version constraints: ^ vs ~ and what each one allows
- Separate runtime dependencies from dev-only tools, and add Composer scripts
- Publish your own library to Packagist so others can require it
namespace and use are new, revisit Namespaces before continuing, since PSR-4 autoloading builds directly on them.php file.php locally. The composer commands need a real terminal with Composer and git installed. Each Output panel shows exactly what to expect.composer require), and the package — plus everything it depends on — downloads ready to use. composer.lock is your receipt listing the exact versions installed, and publishing your own package is like submitting an app to the store for everyone else to download.1️⃣ What Composer Is & the Four Commands
Composer is PHP's dependency manager — a tool that downloads the third-party libraries your project needs and keeps track of their versions. You almost never write a logging system, a date library, or an HTTP client yourself; you require a battle-tested one. There are four commands you'll use daily: composer init creates a new composer.json, composer require adds a package, composer install installs the exact versions a project already locked, and composer update upgrades to the newest versions your rules allow.
# Composer is PHP's dependency manager. It downloads the libraries
# your project needs, and writes a tiny PHP file that loads them all.
# 1) Start a brand-new project. This asks a few questions, then
# writes a composer.json describing your project.
composer init
# 2) Add a dependency. This downloads the package into vendor/,
# records it in composer.json, and pins the exact version in composer.lock.
composer require monolog/monolog
# 3) On a fresh checkout (e.g. a teammate clones the repo),
# install grabs EXACTLY the versions listed in composer.lock.
composer install
# 4) update re-resolves to the newest versions your constraints allow
# and REWRITES composer.lock with what it picked.
composer update# composer require monolog/monolog
Using version ^3.7 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.7.0)
- Locking psr/log (3.0.2)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
- Installing psr/log (3.0.2)
- Installing monolog/monolog (3.7.0)
Generating autoload filescomposer require monolog/monolog prints.2️⃣ composer.json vs composer.lock
These two files trip up everyone at first, so make the distinction stick. composer.json is your wish list: it says which packages you want and the version ranges you'll accept. composer.lock is the receipt: Composer generates it automatically, recording the single exact version of every package — and every sub-dependency — it actually installed. Because the lock pins everything precisely, composer install reproduces the identical dependency tree on every machine.
// composer.json — what you WANT (a wish list with version ranges)
{
"name": "acme/blog",
"type": "project",
"require": {
"php": ">=8.1",
"monolog/monolog": "^3.7"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
}
}
// composer.lock — what you GOT (a receipt with EXACT versions)
// Generated automatically. You never edit it by hand.
// It records the precise version of every package AND every
// sub-dependency, so every machine installs the identical tree.
{
"packages": [
{ "name": "monolog/monolog", "version": "3.7.0" },
{ "name": "psr/log", "version": "3.0.2" }
]
}3️⃣ PSR-4 Autoloading & vendor/autoload.php
In the old days you wrote a require for every single class file — tedious and fragile. PSR-4 is the modern standard that ends that: it maps a namespace prefix to a folder, so Composer can find a class file from its name alone. You declare the mapping once in composer.json, run composer dump-autoload, then require 'vendor/autoload.php' a single time. From then on, the first time you use a class, Composer loads the matching file for you. A sub-namespace simply means a sub-folder.
<?php
// PSR-4 is the standard that maps a NAMESPACE to a FOLDER, so Composer
// can find a class file from its name alone — no require statements.
//
// In composer.json you declare the mapping:
// "autoload": { "psr-4": { "Acme\\Blog\\": "src/" } }
//
// That single line means:
// prefix Acme\Blog\ -> the src/ directory
//
// Acme\Blog\Post -> src/Post.php
// Acme\Blog\Model\Comment -> src/Model/Comment.php (sub-namespace = sub-folder)
// --- src/Post.php ---
namespace Acme\Blog; // namespace MUST match the folder under the prefix
class Post {
public function __construct(public string $title) {}
public function slug(): string {
return strtolower(str_replace(' ', '-', $this->title));
}
}
// --- index.php (your app entry point) ---
require 'vendor/autoload.php'; // ONE require — Composer wires up every class
use Acme\Blog\Post; // pull the class in by its full name
$post = new Post('Hello Composer World');
echo $post->slug() . "\n"; // Composer auto-loads src/Post.php on first usehello-composer-worldThe magic is vendor/autoload.php — a file Composer writes for you. Including it registers an autoloader: a function PHP calls whenever you reference a class it hasn't loaded yet. It translates Acme\Blog\Post into src/Post.php and loads it on demand. If you add a new class and PHP says it can't find it, you usually just need to run composer dump-autoload to refresh the map.
4️⃣ Semantic Versioning & Constraints (^ vs ~)
Every package version is MAJOR.MINOR.PATCH — a patch is a bug fix, a minor adds features without breaking anything, and a major bump signals a breaking change. In composer.json you don't pin one fixed version; you give a constraint so you keep receiving safe fixes. The caret ^ is the common default — it allows everything up to the next major. The tilde ~ is stricter, usually allowing patch releases only.
<?php
// Semantic Versioning (SemVer) is MAJOR.MINOR.PATCH, e.g. 3.7.2
// PATCH (3.7.2 -> 3.7.3) bug fix, backward compatible
// MINOR (3.7.0 -> 3.8.0) new feature, backward compatible
// MAJOR (3.x -> 4.0.0) BREAKING change, may need code changes
//
// In composer.json you don't pin one version — you give a RANGE
// so you keep getting safe fixes without surprise breakages.
$constraints = [
// ^ (caret): allow everything up to the next MAJOR — the common default
'^3.7.0' => '>=3.7.0 and <4.0.0', // gets 3.8, 3.9, 3.10 ... not 4.0
// ~ (tilde): allow up to the next MINOR — more cautious
'~3.7.0' => '>=3.7.0 and <3.8.0', // patches only: 3.7.1, 3.7.2 ...
'~3.7' => '>=3.7.0 and <4.0.0', // (two parts) up to next major
'3.7.*' => '>=3.7.0 and <3.8.0', // wildcard, same as ~3.7.0
];
foreach ($constraints as $written => $means) {
echo str_pad($written, 9) . " means " . $means . "\n";
}^3.7.0 means >=3.7.0 and <4.0.0
~3.7.0 means >=3.7.0 and <3.8.0
~3.7 means >=3.7.0 and <4.0.0
3.7.* means >=3.7.0 and <3.8.05️⃣ Dev Dependencies & Scripts
Not every dependency ships to production. Tools like PHPUnit (testing) and PHPStan (static analysis) belong under require-dev — they're installed on your laptop and CI server but skipped on a production deploy with composer install --no-dev. You can also define scripts: named shortcuts for commands you run constantly, so a long incantation becomes a short composer test.
// composer.json can define SCRIPTS: named shortcuts for commands you
// run all the time. They live under the "scripts" key.
{
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.12"
},
"scripts": {
"test": "phpunit",
"stan": "phpstan analyse src --level=6",
"check": [ "@stan", "@test" ]
}
}
// Run them from the terminal — Composer finds the tool inside vendor/bin:
// composer test -> runs phpunit
// composer stan -> runs phpstan
// composer check -> runs BOTH (the @-prefix calls another script)6️⃣ Your Turn: Install & Autoload
Now you try. The terminal session below is almost complete — fill in each ___ using the 👉 hint, then check it against the Output panel.
# 🎯 YOUR TURN — fill in each blank marked ___ , then run it.
# You're starting a project and want the Guzzle HTTP client library.
# 1) The Composer subcommand that ADDS a dependency
composer ___ guzzlehttp/guzzle # 👉 the verb that means "I require this"
# 2) After cloning a repo, the subcommand that installs the
# EXACT versions already pinned in composer.lock
composer ___ # 👉 not "update" — the one that respects the lock
# ✅ Expected: line 1 downloads guzzlehttp/guzzle into vendor/ ;
# line 2 installs the locked versions for everyone on the team.composer require guzzlehttp/guzzle
composer install___ blanks with the correct Composer subcommands. One adds a package; the other installs the locked versions.One more — this time PSR-4. The class is at src/Money/Price.php and the prefix Shop\ maps to src/. Give the class the matching namespace and load the autoloader.
<?php
// 🎯 YOUR TURN — a PSR-4 class needs the right namespace and the autoloader.
// The class file is src/Money/Price.php , and composer.json maps:
// "autoload": { "psr-4": { "Shop\\": "src/" } }
// 1) Give the class the namespace that matches its folder under the prefix.
// File is src/Money/Price.php and the prefix Shop\ maps to src/ ...
___ Shop\Money; // 👉 the keyword that declares a namespace + Shop\Money
class Price {
public function __construct(public int $cents) {}
public function pounds(): string { return '£' . number_format($this->cents / 100, 2); }
}
// 2) In index.php, load every Composer class with ONE line.
require '___'; // 👉 the file Composer generates: vendor/autoload.php
use Shop\Money\Price;
echo (new Price(2599))->pounds() . "\n";
// ✅ Expected output:
// £25.99£25.99___ with the keyword that declares the namespace Shop\Money, and the second with the path to Composer's autoloader. Output should be £25.99.Common Errors (and the fix)
- You committed the whole
vendor/folder to git — never do this.vendor/is generated bycomposer installand can be hundreds of megabytes. Add a.gitignoreline/vendor/and commit onlycomposer.jsonandcomposer.lock. - "Class not found" even though the file exists — your autoload map is stale. After adding a new class (or changing the PSR-4 mapping) run
composer dump-autoloadto regeneratevendor/autoload.php. Also check the namespace exactly matches the folder under the prefix. - Teammates get different versions / "it works on my machine" — you didn't commit
composer.lockfor your application. Without it, each person'scomposer installresolves the ranges differently. Commit the lock file so everyone installs identical versions. - A
composer updatebroke your app — your constraint was too loose, like*or>=1.0, so it pulled in a breaking major release. Use a real range such as^3.7, and update one package at a time withcomposer update vendor/package.
Pro Tips
- 💡 Default to
^constraints. They give you bug fixes and features automatically while protecting you from breaking major releases. - 💡 Run
composer outdatedto see which dependencies have newer versions, andcomposer auditto flag known security vulnerabilities. - 💡 Deploy with
composer install --no-dev --optimize-autoloader— it skips test tools and builds a faster class map for production.
📋 Quick Reference — Composer
| Command / Term | Example | What It Does |
|---|---|---|
| composer init | composer init | Create a new composer.json |
| composer require | composer require monolog/monolog | Add & install a dependency |
| composer install | composer install | Install exact versions from composer.lock |
| composer update | composer update | Upgrade within constraints, rewrite lock |
| composer dump-autoload | composer dump-autoload | Regenerate the PSR-4 autoload map |
| ^1.2 | "monolog/monolog": "^3.7" | >=3.7.0 and <4.0.0 (up to next major) |
| ~1.2 | "psr/log": "~3.0.0" | >=3.0.0 and <3.1.0 (patches only) |
| PSR-4 | "Acme\\": "src/" | Map a namespace prefix to a folder |
Frequently Asked Questions
Q: What is the difference between composer.json and composer.lock?
composer.json is your wish list: it states which packages you want and the version ranges you'll accept, like "^3.7". composer.lock is the receipt: after Composer resolves those ranges it records the single exact version of every package and every sub-dependency it installed. composer install reads the lock file so every machine ends up with the identical set of versions, while composer update ignores the lock, re-resolves your ranges to the newest allowed, and rewrites the lock with the result.
Q: Should I commit composer.lock to git?
For an application (a website or service you deploy), yes — always commit composer.lock so every developer and your production server install byte-for-byte identical dependencies, giving you reproducible builds. For a reusable library you publish to Packagist, do not commit it: a library should be tested against the full range of versions its consumers might have, so you let each project resolve its own lock. Either way, never commit the vendor/ directory itself.
Q: How does PSR-4 autoloading actually find my classes?
PSR-4 is a naming convention that maps a namespace prefix to a base folder. In composer.json you write something like "autoload": { "psr-4": { "Acme\\Blog\\": "src/" } }. After running composer dump-autoload, the namespace Acme\Blog\Post is expected at src/Post.php, and Acme\Blog\Model\Comment at src/Model/Comment.php — each sub-namespace is a sub-folder. You then require 'vendor/autoload.php' once, and Composer loads each class file automatically the first time you use it.
Q: What is the difference between the ^ and ~ version constraints?
The caret ^ is the relaxed default: ^3.7.0 allows anything up to the next major version (>=3.7.0 and <4.0.0), so you receive new features and bug fixes that promise to be backward compatible. The tilde ~ is more cautious: ~3.7.0 allows patch releases only (>=3.7.0 and <3.8.0), while ~3.7 (two parts) allows up to the next major. Use ^ for most libraries and ~ when you want to lock a minor version and accept only bug-fix patches.
Q: How do I publish my own package to Packagist?
Push your library to a public git repository (GitHub, GitLab), make sure it has a valid composer.json with a vendor/package name and a PSR-4 autoload block, then tag a release: git tag v1.0.0 and git push --tags — Composer reads git tags as version numbers. Finally, sign in at packagist.org, submit your repository URL, and enable the GitHub webhook so new tags publish automatically. After that, anyone can run composer require your-vendor/your-package.
Mini-Challenge: Build a Publishable Library
No code is filled in this time — just a brief and an outline. Write it yourself, run the PHP part on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This mirrors how you'd actually scaffold a real package.
<?php
// 🎯 MINI-CHALLENGE: publish-ready composer.json + a PSR-4 class.
// No code is filled in — work from the steps, then run the PHP part.
//
// PART A — write a composer.json (as a PHP array, json_encode it) for a
// library called "acme/slugger" that:
// 1. has "name", "description" and "license": "MIT"
// 2. requires php ">=8.1"
// 3. has a require-dev on "phpunit/phpunit": "^11.0"
// 4. autoloads PSR-4: "Acme\\Slugger\\" -> "src/"
//
// PART B — write the class Acme\Slugger\Slugger with a method
// slugify(string $text): string that lowercases the text and
// replaces spaces with hyphens. Then echo slugify('Hello World').
//
// PART C — list, in comments, the terminal steps to publish it:
// git tag v1.0.0 -> push tags -> submit the repo URL at packagist.org
//
// ✅ Expected output of Part B: hello-world
// your code herecomposer.json, write the Slugger class, and list the publish steps. The slugify call should print hello-world.🎉 Lesson Complete!
- ✅ Composer manages dependencies:
init,require,install,update - ✅
composer.jsonis your wish list (ranges);composer.lockis the receipt (exact versions) - ✅ Commit
composer.lockfor apps, not for libraries — and never commitvendor/ - ✅ PSR-4 maps a namespace to a folder;
require 'vendor/autoload.php'loads classes on demand - ✅
^allows up to the next major;~is stricter (usually patches only) - ✅
require-devholds test/lint tools;scriptsadd named shortcuts - ✅ Publish to Packagist by tagging a git version and submitting the repo URL
- ✅ Next lesson: Deployment — ship your PHP app with Nginx, PHP-FPM, and Docker
Sign up for free to track which lessons you've completed and get learning reminders.