Every PHP developer knows composer require and composer install. Those two commands are where most people's Composer knowledge begins and ends. But Composer is a deeply capable tool that handles version resolution, autoloading configuration, lifecycle hooks, local package development, and private distribution. Understanding what's available transforms Composer from a package downloader into a real project management tool.
This post covers the features you're probably not using yet — the ones that make dependency management predictable and give you full control over your project's ecosystem.
Version Constraints Decoded
Version constraints are the most misunderstood part of Composer. The difference between ^ and ~ looks trivial until it breaks your CI pipeline on a Monday morning. Here's what each operator actually does.
The caret operator (^) is the most common and the default when you run composer require. It allows updates that don't break backwards compatibility according to semantic versioning. ^8.1 resolves to >=8.1.0 <9.0.0. It won't jump to the next major version, but it will pick up minor and patch releases.
The tilde operator (~) is more restrictive. ~8.1 resolves to >=8.1.0 <9.0.0 — identical to the caret at first glance. The difference shows with three-part versions: ~8.1.3 resolves to >=8.1.3 <8.2.0, while ^8.1.3 resolves to >=8.1.3 <9.0.0. The tilde locks to the minor version, the caret locks to the major. For most packages, the caret is what you want.
You also have stability flags for pulling pre-release versions of specific packages without changing your global stability setting:
{
"require": {
"laravel/framework": "^12.0",
"vendor/experimental-package": "^2.0@beta",
"vendor/bleeding-edge": "dev-main"
},
"minimum-stability": "stable"
}
The @beta flag lets you install a beta version of one package while keeping everything else at stable releases. The dev-main constraint pins directly to a branch, which is useful during active development but should never appear in production.
Platform Requirements
Declaring a PHP version constraint in require isn't just documentation — it's enforced during dependency resolution. If your project requires PHP 8.2 features, declare it:
{
"require": {
"php": "^8.2",
"ext-pdo": "*",
"ext-redis": "*",
"ext-intl": "*"
}
}
This prevents Composer from installing a package version that needs PHP 8.3 when your production server runs 8.2. It also catches missing extensions before deployment instead of at runtime. Every extension your code depends on should be listed here.
The config.platform setting takes this further. It lets you simulate a specific PHP version during resolution, regardless of what your local machine runs:
{
"config": {
"platform": {
"php": "8.2.28"
}
}
}
This is essential for team consistency. If your production server runs PHP 8.2.28 but your developers are on 8.4, without this setting Composer might resolve a package version that only works on 8.4. When that code hits production, it fails. Setting the platform version forces Composer to resolve as if it's running on production, catching the problem on every developer's machine.
Autoloading Strategies
Composer's autoloader is what makes use App\Models\User work without manual includes. But there are four different strategies, and each exists for a reason.
PSR-4 is the default and the one you should use for all new code. It maps a namespace prefix to a directory. The class name must match the file name, and the namespace structure must match the directory structure:
{
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
}
}
PSR-0 is the legacy standard. It's similar to PSR-4 but includes the full namespace in the directory path. You'll encounter it in older packages but should never use it in new code.
Classmap scans specified directories and builds an index of every class it finds, regardless of namespace or file naming conventions. It's useful for legacy code that doesn't follow PSR conventions:
{
"autoload": {
"classmap": [
"legacy/",
"lib/old-sdk/"
]
}
}
Files loads specific files on every request. This is the strategy for helper functions or constants that don't belong to a class:
{
"autoload": {
"files": [
"app/helpers.php"
]
}
}
After changing any autoload configuration, run composer dump-autoload to regenerate the class map. For production, use composer dump-autoload --optimized which converts PSR-4 rules into a flat classmap for faster lookups. This is especially noticeable in applications with hundreds of classes.
Scripts and Hooks
The scripts section in composer.json lets you run commands automatically during Composer's lifecycle events or define custom shortcuts. This is where you enforce code standards, clear caches, and automate setup tasks.
{
"scripts": {
"post-install-cmd": [
"@php artisan clear-compiled",
"@php artisan package:discover"
],
"post-update-cmd": [
"@php artisan clear-compiled",
"@php artisan package:discover",
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"lint": "vendor/bin/pint",
"test": "php artisan test --compact",
"analyse": "vendor/bin/phpstan analyse",
"check": [
"@lint",
"@analyse",
"@test"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb923c,#67e8f9\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
]
}
}
The @ prefix references other scripts, so @lint inside the check script runs the lint command. The Composer\Config::disableProcessTimeout call is necessary for long-running processes like development servers. Custom scripts are invoked with composer run check or composer run dev, giving your team a consistent interface regardless of the underlying tools.
Path Repositories
When you're building a package alongside an application, pushing to Packagist after every change is painful. Path repositories let you require a local directory as a package, and Composer symlinks it into your vendor folder. Changes to the package are reflected immediately — no install or update needed.
{
"repositories": [
{
"type": "path",
"url": "../packages/my-custom-package"
}
],
"require": {
"my-vendor/my-custom-package": "dev-main"
}
}
The package directory needs its own composer.json with a valid name and autoload configuration:
{
"name": "my-vendor/my-custom-package",
"autoload": {
"psr-4": {
"MyVendor\\MyPackage\\": "src/"
}
},
"require": {
"php": "^8.2"
}
}
Run composer update my-vendor/my-custom-package and Composer creates a symlink from vendor/my-vendor/my-custom-package to your local directory. Edit a file in the package, and your application sees it instantly. This workflow is how most Laravel packages, Statamic addons, and Filament plugins are developed. When the package is ready, publish it to Packagist and swap the path repository for the real version constraint.
Private Packages and Satis
Not every package belongs on the public Packagist registry. Agencies and companies often build internal packages shared across projects — a custom authentication layer, a CRM integration, or a design system. You have two main options for hosting these privately.
Private Packagist is a hosted service by the Composer team. It mirrors private repositories from GitHub, GitLab, or Bitbucket and serves them exactly like public Packagist. Add it to your composer.json:
{
"repositories": [
{
"type": "composer",
"url": "https://your-org.repo.packagist.com"
}
]
}
Satis is the free, self-hosted alternative. It generates a static Composer repository from a list of Git repositories. You run it on your own server or even serve it from an S3 bucket. It requires more setup but gives you full control with no recurring cost. For smaller teams that only share a handful of internal packages, a simple VCS repository entry pointing to a private GitHub repo is often enough.
Useful Commands You're Probably Not Using
Composer ships with several commands that most developers never discover. These are the ones I use regularly.
composer why (alias depends) tells you why a package is installed — which other packages depend on it:
$ composer why nesbot/carbon
laravel/framework v12.6.0 requires nesbot/carbon (^2.72.2 || ^3.4)
composer outdated lists packages with newer versions available. Add --direct to only show packages you explicitly require, filtering out transitive dependencies:
$ composer outdated --direct
laravel/framework v12.6.0 v12.8.1 The Laravel Framework.
statamic/cms v6.1.0 v6.2.3 Statamic CMS
composer audit checks your installed packages against known security vulnerabilities. Run this in CI to catch compromised dependencies before they reach production:
$ composer audit
Found 0 security vulnerability advisories affecting 0 packages.
composer bump rewrites the version constraints in composer.json to match what is currently installed in composer.lock. If you have "^12.0" and version 12.6.0 is installed, it updates the constraint to "^12.6". This is useful before tagging a release to ensure your package declares the minimum versions it was actually tested against.
composer show --tree visualizes the full dependency tree, which is invaluable when debugging version conflicts or understanding why a particular package is being pulled in.
Composer is far more than a package installer. The version constraints keep your builds reproducible. Platform requirements catch environment mismatches before deployment. Autoload optimization speeds up production. Scripts standardize your team's workflow. Path repositories make local package development seamless. And the diagnostic commands give you visibility into your dependency graph when things go wrong. The time you invest in learning these features pays for itself every time you avoid a broken deployment or a mysterious version conflict.