Skip to content
beginner Phase 8 · Composer and Autoloading

Initializing a Project with Composer

Create composer.json and install your first dependencies.

30m
0 problems
Topic Progress 0%

From Empty Directory to composer.json: What composer init Actually Writes

A new PHP project starts with a folder and a wish: dependencies should be reproducible. Composer answers with one file, composer.json, and composer init is the guided tour that produces it.

$ composer init

Package name (<folder>/my-project): acme/orders-api
Description: Order intake service for the Acme shop
Type of package (library is default if unsure): project
License (e.g. MIT, GPL, proprietary): MIT
Author [Name <email@example.com>] (no author): Ada Lovelace <ada@acme.dev>
Minimum PHP version (e.g. ^8.1): ^8.1

Would you like to define your dependencies (require) interactively? [yes]
Search for a package: symfony/var-dumper
...add symfony/var-dumper:^7.0? [yes]

{
    "name": "acme/orders-api",
    "description": "Order intake service for the Acme shop",
    "type": "project",
    "license": "MIT",
    "authors": [{ "name": "Ada Lovelace", "email": "ada@acme.dev" }],
    "require": { "php": "^8.1", "symfony/var-dumper": "^7.0" }
}

Every prompt maps to one field. Two prompts deserve attention:

Package name must be vendor/name, lowercase, digits and dashes allowed, and it is the project's identity if you ever publish to Packagist — symfony/console, guzzlehttp/guzzle. The vendor segment should be your GitHub/Org handle so namespace ownership is unambiguous. Teams that skip this end up renaming later, and every rename is a breaking change for downstream composer require.

Type defaults to library; choose project for a deployable application. It affects nothing at runtime, but tooling (deploy systems, composer install --no-dev heuristics, Packagist display) treats projects and libraries differently.

The require section is also where Composer enforces the platform. "php": "^8.1" is checked against the running interpreter before anything installs; "ext-json": "*" and similar ext-* entries make a missing extension a hard failure at install time with your PHP version (8.0.2) does not satisfy the requirement — loud, early, and in CI instead of at 3 a.m. in production. The ^8.1 syntax — caret ranges, tilde, exact pins — is exactly what you will manage daily in Installing and Updating Packages.

Two mechanics behind the wizard are worth knowing. Composer commands act on the nearest composer.json walking up from the working directory, so composer require run inside src/ still edits the project root's manifest — and a stray composer.json in a parent folder will silently capture commands meant for a different project. Second, init writes no autoload section: a fresh manifest cannot load a single one of your own classes. Namespace-to-directory mapping is a separate, deliberate step — PSR-4 Autoloading — and until it exists, require vendor/autoload.php buys you dependencies only.

One flag at the end of init matters more than it looks: composer init does not run an install. An empty composer.json with a require section is a declaration, not a working project. You can also skip the interactive flow entirely — CI scripts and experienced developers write the file directly or edit the template their team keeps, because a prompt cannot know your namespace mapping, autoload rules, or scripts. Those sections are covered in PSR-4 Autoloading and Running Composer Scripts.

"What goes in composer.json versus composer.lock?" is the opening question of almost every Composer interview screen. The manifest describes intent — names and ranges. The lock describes reality — exact versions and content hashes. The next chapter is about what happens when those two files disagree.

composer install and the Lock File: Why the Lock Is the Contract

Two developers clone the same repository on the same day and get different transitive dependency versions. Three weeks later one reproduces the other's production bug and cannot — the bug does not exist on their machine. This is what composer.lock prevents.

The first composer install in a project has no lock file, so Composer resolves your require ranges against Packagist and writes both the resolved tree and the lock file:

$ composer install
No composer.lock file present. Updating dependencies to latest instead of installing from lock file.
Installing symfony/var-dumper (v7.2.1)
  - Locking symfony/var-dumper (v7.2.1)
Writing lock file
Generating autoload files

From then on, composer install reads the lock and installs those exact versions — v7.2.1, not "whatever matches ^7.0 today." Every developer, every CI job, and every deploy box replays one dependency tree. That is the contract: composer.json says what you want, composer.lock says what everyone gets.

The lock file records far more than version numbers: the exact dist URL and reference for every package, every transitive dependency, package type and require metadata, and a content-hash computed from composer.json. That hash is the tripwire. Edit require and run composer install without updating the lock and Composer says:

Warning: The lock file is not up to date with the latest changes in composer.json.
You may be getting outdated dependencies. Run update to fix them.

It is a warning, not an error — which is the trap. A rushed developer sees it, hits install anyway, and now composer.json lies about what is installed. The fix is composer update (re-resolve; rewrites the lock — a decision that belongs to Installing and Updating Packages), or composer update --lock to refresh only the hash.

Flags that show up in real pipelines:

Flag Effect
--no-dev skip everything in require-dev (tests, linters) — use on deploys
--prefer-dist download zip archives instead of git clones (default)
--optimize-autoloader authoritative class map for production speed
--ignore-platform-reqs install despite missing ext/php — for cross-building only
--no-scripts skip lifecycle scripts — the escape hatch when one is broken

The install also generates vendor/composer/platform_check.php from the platform entries in require: every entry point that loads the autoloader runs the check and dies immediately with The requested module requires the extension mbstring instead of crashing on a missing function halfway through a request. Naming each extension your code actually calls is therefore a runtime guarantee, not tidiness.

Two habits make the lock reliable. First, commit composer.lock for applications — it is the reproducibility mechanism, not a build artifact. (Libraries are the one exception: consumers resolve their own tree, so many libraries gitignore the lock.) Second, run composer validate --strict in CI; it fails the build on a malformed manifest, a missing license, and — the one that pays for itself — a lock file that has drifted out of sync with the manifest.

The interview version, stated plainly: composer install installs the locked versions; only composer update re-resolves ranges and rewrites the lock; and for an application, the lock file is committed so every environment is identical by construction.

vendor/: Generated, Replaceable, and Never Edited by Hand

composer install materializes one directory: vendor/. Inside it sits every installed package (vendor/symfony/var-dumper/...), a vendor/composer/ directory with the generated autoloader and metadata, and vendor/bin/ with package-provided executables such as phpunit and php-cs-fixer.

The rule that keeps projects healthy: vendor/ is generated output, exactly like node_modules or a build directory — treat it as disposable and never edit it.

# .gitignore
/vendor/

The reasoning is mechanical. The next composer install, update, or remove regenerates autoload maps and reinstalls packages; every hand fix inside vendor/ vanishes silently, and the bug you patched "temporarily" six months ago comes back the first time anyone refreshes dependencies. The clean recovery from a corrupt or half-installed state is always the same two commands:

rm -rf vendor composer.lock
composer install        # rebuild from composer.json

That feels nuclear because it is — it re-resolves when the lock is deleted. When the lock is intact, rm -rf vendor && composer install alone is a perfect reinstall that changes nothing else. "Delete vendor and reinstall" is the PHP ecosystem's "turn it off and on again," and it resolves a surprising share of Class "..." not found reports that turn out to be a stale autoloader rather than a missing package.

The entry point is vendor/autoload.php. One require there registers the whole loading chain: Composer's own class loader, package-registered files (things like functions.php helper libraries that are included on every request), and your own PSR-4 namespace mappings from composer.json. Every file that needs classes — front controllers, CLI scripts, test runners — loads this one file and nothing else:

require __DIR__ . '/vendor/autoload.php';
\Symfony\Component\VarDumper\dump($order);

When a class exists on disk but does not load, the autoloader is the first suspect: composer dump-autoload regenerates the maps without touching any package, and composer dump-autoload -o builds the optimized class map for production.

vendor/bin deserves specific mention: packages expose executables there — vendor/bin/phpunit, vendor/bin/php-cs-fixer — and running those (or letting Composer scripts run them, as in Running Composer Scripts) is what keeps tool versions locked to the project. Installing PHPUnit globally reintroduces exactly the version skew the lock file exists to prevent. Two config knobs round out the directory's behavior: bin-dir moves the shims, and allow-plugins is Composer's default-deny gate for plugins that run code inside your install — a supply-chain control you review deliberately, not a speed bump. Composer itself is a single self-updating PHAR with no daemon and no footprint beyond its cache directory of downloaded archives.

Deployments follow the same rule. Production runs composer install --no-dev --optimize-autoloader — the lock guarantees identical versions, --no-dev keeps test tooling off the server, and the optimized autoloader trades slower composer install for faster requests. Building the vendor directory in CI and shipping it as an artifact is equally valid; what never works long-term is SSH-ing in and hand-patching a live vendor/.

"What is vendor/ and should it be committed?" earns a full answer with three parts: it is generated dependency output, it is disposable, and for applications it stays out of version control because composer.json plus composer.lock can rebuild it bit-for-bit on any machine.

Quiz

1. A teammate deletes composer.lock but keeps composer.json, then runs composer install. What happens?

Question 1 options

2. You widen a constraint in composer.json and run composer install. Composer warns that the lock file is not up to date. What does that mean?

Question 2 options

3. A developer patches a bug directly inside vendor/guzzlehttp/guzzle/src and it works. Why is this still a defect?

Question 3 options

4. What does the --no-dev flag do during composer install?

Question 4 options

Flashcards

Question

What does each composer init prompt write into composer.json?

Answer

Package name -> name (vendor/name, lowercase, the project's identity for Packagist); Description -> description; Type -> type (library vs project); License -> license; Author -> authors[]; PHP version and searched packages -> require (with php and ext-* platform entries). init only writes the manifest — it does not install anything.

Question

composer.json vs composer.lock — what does each contain and which is committed for an application?

Answer

composer.json is intent: names plus version ranges. composer.lock is reality: exact versions, dist URLs/references for the whole tree, and a content-hash of the manifest. For applications, commit both — the lock is what makes every environment identical. Libraries commonly gitignore the lock.

Question

What does 'The lock file is not up to date with the latest changes in composer.json' mean and how do you fix it?

Answer

The lock's content-hash no longer matches the edited manifest, so installed versions may not satisfy the new ranges. Composer install proceeds with the old lock anyway (warning, not error). Fix with composer update to re-resolve and rewrite the lock, or composer update --lock to refresh only the hash.

Question

What lives in vendor/ and what is the cardinal rule for it?

Answer

Installed packages (vendor/<vendor>/<package>), Composer's generated autoloader and metadata (vendor/composer/), and package binaries (vendor/bin/phpunit). The rule: it is generated output like node_modules — disposable, gitignored for apps, never hand-edited because the next install/update overwrites it. Clean reinstall: rm -rf vendor && composer install.

Question

What does vendor/autoload.php do and when is dump-autoload needed?

Answer

One require registers Composer's class loader, every package's autoloaded files (e.g. helper functions.php), and your PSR-4 mappings — every entry point loads it and nothing else. dump-autoload regenerates the maps without reinstalling packages (use -o for the optimized production class map), which fixes 'Class not found' caused by stale maps rather than missing packages.

Revision Notes

Key Takeaways

  • 1. composer init turns prompts into the composer.json manifest — the vendor/name identity and the php/ext-* platform requirements are the fields that matter downstream
  • 2. composer install replays composer.lock exactly; only composer update re-resolves ranges and rewrites the lock — conflating them is how environments drift
  • 3. The lock's content-hash detects manifest edits; the 'lock file is not up to date' warning is non-fatal, so CI must run composer validate --strict to catch drift
  • 4. vendor/ is disposable generated output: gitignore it for applications, never patch it by hand, and recover with rm -rf vendor && composer install
  • 5. vendor/autoload.php is the single entry point registering package autoloaders, included files, and your PSR-4 mappings; dump-autoload fixes stale maps without reinstalling

Interview Tips

  • • "What is composer.json vs composer.lock?" — intent (ranges) versus reality (pinned tree + content-hash); say applications commit the lock and libraries usually do not
  • • "composer install changed nothing but my new dependency is missing — why?" — install honors the lock; the manifest edit only updated the hash warning; the dependency needs composer update
  • • "Should vendor/ be in git?" — no for applications (rebuildable from json+lock), with the honest exception of servers with no Composer and no build step; then state the never-edit rule
  • • "Production deploy steps?" — composer install --no-dev --optimize-autoloader from a committed lock in CI; never update on the server

Cheat Sheet

Initializing a Project with Composer

Init maps prompts to fields: name (vendor/name, lowercase) · description · type (library/project) · license · authors · require (include "php": "^8.1" and ext-* platform entries). init writes the manifest only — no install.

The contract:

composer install          # no lock? resolves + writes one; lock present? replays exact versions
composer update           # re-resolve ranges, rewrite the lock (a team decision)
composer update --lock    # refresh only the content-hash
composer validate --strict  # CI: manifest + lock drift check

Deploy: composer install --no-dev --optimize-autoloader — committed lock guarantees identical versions everywhere.

vendor/ rules: generated output, gitignored for apps, never hand-edited (next install overwrites it). Reinstall: rm -rf vendor && composer install. Stale-map Class not found? composer dump-autoload.

Flags: --no-dev skips require-dev · --no-scripts escape hatch · --prefer-dist zips over git.

Continue Learning