Authoring a Composer Package: PSR-4, Docs, Licensing, and Versioning

Author a publishable, well-behaved Composer package: structure it for PSR-4 autoloading, design a clean public API, write the README and changelog a consumer needs, choose and declare a license, apply semantic versioning correctly, cut release tags, and manage the package's own dependency constraints responsibly.

Writing a package for other people to composer require is a different discipline from writing application code, because you're no longer the only consumer of your own decisions. Every public class, every dependency constraint, and every version bump becomes a promise to strangers you'll never meet, and Composer's tooling (PSR-4 autoloading, semantic versioning, Packagist) exists specifically to let those strangers trust your package without reading its source first.

PSR-4 project layout

PSR-4 is the PHP-FIG standard that maps namespace prefixes to directory paths, and it's what Composer's autoload section in composer.json actually configures. The convention almost every published package follows is "autoload": { "psr-4": { "Acme\\ShoppingCart\\": "src/" } }, meaning the class Acme\ShoppingCart\Cart must live at src/Cart.php, and Acme\ShoppingCart\Item\LineItem must live at src/Item/LineItem.php — the namespace structure and the directory structure mirror each other exactly, one-to-one, after the mapped prefix.

A companion autoload-dev section maps test-only namespaces (e.g. "Acme\\ShoppingCart\\Tests\\": "tests/") so test classes autoload during development without ever being included in a package's production install. Following PSR-4 exactly (correct casing, correct nesting, no stray files outside the mapped root) is what makes composer dump-autoload generate a correct, fast class map — a package with an inconsistent layout forces Composer to fall back to slower classmap scanning or produces outright autoload failures for consumers.

Designing a public API

Once a class, method, or property is public in a published package, other people's code will call it, and every later change to its signature or behavior is a potential breaking change for everyone depending on it — this is the core discipline of package authoring that application code doesn't force on you. The practical response is to keep the public surface as small and deliberate as possible: mark implementation-detail classes final or move them to an Internal\ sub-namespace documented as 'not covered by the backward compatibility promise', and keep genuinely public classes' constructors and methods stable once released.

Interfaces are the standard tool for letting consumers depend on a contract rather than a concrete implementation — publishing CartInterface alongside a concrete Cart class lets a consumer type-hint against the interface and freely swap in their own implementation (for testing, or for an entirely different storage backend) without ever touching your package's internals. A package that exposes only concrete classes with no interfaces forces every consumer into tight coupling with your specific implementation choices.

README, CHANGELOG, and LICENSE

A README.md at the package root is the first (and often only) thing a potential consumer reads before deciding to composer require a package — it should cover, at minimum, what problem the package solves, an installation command, a minimal working usage example, and a link to fuller documentation if one exists. A package with no README, or one that just restates the package name, meaningfully reduces adoption regardless of how good the underlying code is.

A CHANGELOG.md, ideally following the 'Keep a Changelog' convention (sections per version: Added, Changed, Deprecated, Removed, Fixed, Security), lets an existing consumer decide in seconds whether upgrading is safe or requires code changes, without having to diff two Git tags themselves. LICENSE (a full license text file) and the matching "license" field in composer.json (e.g. "MIT") tell consumers, including their legal/compliance teams, exactly what they're permitted to do with your code — Packagist actually surfaces this field, and many organizations have policies against depending on unlicensed code entirely, making its absence a real adoption blocker, not just a formality.

Semantic versioning and release tags

Composer packages are versioned and discovered through Git tags — Packagist reads a package's tags (typically formatted like v2.3.1 or 2.3.1) directly to know what versions exist, so 'releasing' a Composer package is literally just pushing an annotated Git tag; there's no separate build-and-upload step like some other ecosystems require. Semantic versioning gives that tag meaning: given a version MAJOR.MINOR.PATCH, increment MAJOR for any backward-incompatible change to the public API, MINOR for backward-compatible new functionality, and PATCH for backward-compatible bug fixes only.

Honoring semver precisely is what makes every consumer's caret constraint (^2.3.1, covered in the constraints lesson) trustworthy: a consumer who wrote ^2.3.1 is explicitly betting that you will never ship a breaking change under a 2.x tag, and violating that bet — even once, even for something that felt minor to you — breaks real applications silently the next time they run composer update. When a breaking change really is necessary, the correct response is a MAJOR bump, ideally accompanied by a migration guide in the changelog explaining exactly what consumers need to change.

Managing the package's own dependencies

A library's composer.json should generally declare the loosest constraints that are still actually safe, not the tightest — because every constraint a library adds becomes a constraint every consuming application inherits transitively, and an unnecessarily narrow range (pinning an exact version of a common dependency, say) is exactly what causes the unresolvable transitive conflicts discussed in the constraints lesson. A well-behaved library typically requires a wide caret range for its dependencies (^2.0 || ^3.0 to support two compatible major lines at once, when feasible) rather than pinning tightly 'to be safe'.

For this same reason, published libraries generally do NOT commit composer.lock (the opposite convention from applications) — a library's lock file only reflects one arbitrary snapshot used during the library's own development/testing, and committing it would mislead consumers into thinking it constrains their install, when in fact only the ranges in composer.json ever apply to a package installed as someone else's dependency.

A realistic library composer.json

"type": "library" (as opposed to "project") is a signal Composer and tooling use to treat this as an installable dependency rather than a standalone application. Requiring "psr/log": "^2.0 || ^3.0" (an OR of two major ranges) is a common pattern letting a library support two compatible major versions of a widely-used interface package at once, reducing transitive conflicts for consumers still on the older one.

{
    "name": "acme/shopping-cart",
    "description": "A framework-agnostic shopping cart and pricing engine.",
    "type": "library",
    "license": "MIT",
    "keywords": ["cart", "ecommerce", "pricing"],
    "authors": [
        { "name": "Jane Doe", "email": "jane@example.com" }
    ],
    "require": {
        "php": ">=8.1",
        "psr/log": "^2.0 || ^3.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^10.0",
        "phpstan/phpstan": "^1.10"
    },
    "autoload": {
        "psr-4": { "Acme\\ShoppingCart\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "Acme\\ShoppingCart\\Tests\\": "tests/" }
    },
    "scripts": {
        "test": "phpunit",
        "analyse": "phpstan analyse src"
    },
    "minimum-stability": "stable"
}

Code Examples

This class lives at src/Cart.php under the Acme\ShoppingCart\ namespace, exactly matching the composer.json PSR-4 mapping, so consumers who composer require acme/shopping-cart get use Acme\ShoppingCart\Cart; working automatically with zero manual autoload configuration on their end.

This CHANGELOG.md excerpt follows the Keep a Changelog convention: version 2.1.0 is clearly a safe, backward-compatible upgrade (Added/Fixed only, correctly a MINOR bump), while 2.0.0 is clearly flagged as breaking and points to a migration guide — a consumer reading this before running composer update can immediately tell which upgrade is routine and which needs code changes.

There is no separate 'build and publish' step for a Composer package beyond an annotated Git tag pushed to the remote — Packagist reads tags directly from the repository, so the entire release process is: update the changelog, tag with a semver-formatted version, and push. Whether that update reaches Packagist automatically depends on whether the package's GitHub/GitLab webhook to Packagist is configured.

git tag -a v2.1.0 -m "Release 2.1.0: add removeItem(), fix double-count bug"

<?php
// src/Cart.php — matches PSR-4 mapping "Acme\ShoppingCart\": "src/"
namespace Acme\ShoppingCart;

final class Cart
{
    /** @var LineItem[] */
    private array $items = [];

    public function addItem(LineItem $item): void
    {
        $this->items[] = $item;
    }

    public function total(): float
    {
        return array_sum(array_map(fn (LineItem $i) => $i->subtotal(), $this->items));
    }
}
## [2.1.0] - 2026-03-14
### Added
- `Cart::removeItem(string $id)` for removing a line item by id.

### Fixed
- `Cart::total()` no longer double-counts items added twice with `addItem()`
  before this fix; now the second call correctly replaces the quantity.

## [2.0.0] - 2026-01-08
### Changed
- **BREAKING**: `Cart::addItem()` now requires a `LineItem` object instead
  of a raw associative array. See MIGRATING.md for an upgrade example.
# Cutting a release for a Composer package
git checkout main
git pull
# ...update CHANGELOG.md with the new version's entries...
git add CHANGELOG.md
git commit -m "Prepare release 2.1.0"
git tag -a v2.1.0 -m "Release 2.1.0: add removeItem(), fix double-count bug"
git push origin main --tags
# Packagist (if auto-update webhook is configured) picks up the new tag
# automatically; otherwise trigger a manual update on packagist.org

From source layout to a Packagist release

A Composer release is just a well-formed, well-documented, correctly-tagged Git commit — Packagist adds no separate build step of its own.

src/Cart.php  (namespace Acme\ShoppingCart)
        |
        v  composer.json "psr-4": { "Acme\\ShoppingCart\\": "src/" }
        |
        v  README.md + CHANGELOG.md + LICENSE committed alongside
        |
        v  git tag -a v2.1.0 -m "..."  &&  git push --tags
        |
        v  Packagist reads the tag -> version 2.1.0 becomes installable
        |
        v  consumer: composer require acme/shopping-cart:^2.1

Important Rules

Match your PSR-4 namespace-to-directory mapping in composer.json exactly to your actual folder structure, including case, or autoloading breaks for consumers.

Keep the public API small and deliberate; mark internals final or namespace them separately from what you promise to keep backward-compatible.

Ship a README with install + a minimal usage example, a CHANGELOG following each release, and a LICENSE file matching the license field in composer.json.

Bump MAJOR for any breaking change, MINOR for backward-compatible additions, PATCH for backward-compatible fixes only — consumers' caret constraints depend on you being honest about this.

Prefer loose, wide dependency ranges in a library's own composer.json, and don't commit a library's composer.lock — only the version ranges apply to consumers.

Where this shows up

Widely-used packages like symfony/console, guzzlehttp/guzzle, and psr/log all follow this exact discipline: strict PSR-4 layout, a maintained CHANGELOG with clearly flagged BREAKING entries, semver-honest tags that make caret constraints trustworthy across the entire ecosystem, and deliberately wide dependency ranges (psr/log itself is required by thousands of unrelated packages, so an overly narrow range in any one of them would ripple out as install failures across huge swaths of the PHP ecosystem).

// composer.json "require": { "psr/log": "1.1.4" // exact pin inside a published library }

// composer.json "require": { "psr/log": "^1.1 || ^2.0 || ^3.0" // wide, compatible range }

Pinning an exact version of a widely-used dependency inside a library forces every application depending on that library to also use that exact version, which is exactly the kind of narrow constraint that produces unresolvable transitive dependency conflicts for consumers who need a different (but perfectly compatible) version elsewhere in their own dependency tree.

[multiple-choice] Under PSR-4, if composer.json maps "Acme\\ShoppingCart\\": "src/", where must the class Acme\ShoppingCart\Item\LineItem live? A. src/LineItem.php B. src/Item/LineItem.php C. src/Acme/ShoppingCart/Item/LineItem.php D. Anywhere, PSR-4 doesn't care about file location

B. src/Item/LineItem.php — PSR-4 maps a namespace prefix to a directory, then mirrors everything after that prefix as nested subdirectories: with "Acme\\ShoppingCart\\" mapped to "src/", the remaining "Item\\LineItem" portion becomes src/Item/LineItem.php.

[multiple-choice] Why should a published library generally avoid committing its own composer.lock file? A. Composer forbids libraries from having a lock file entirely B. A library's lock file only reflects a development-time snapshot; only the version ranges in its composer.json actually apply when it's installed as someone else's dependency C. Lock files only work for applications, they cause a syntax error in library packages D. It has nothing to do with correctness, it's purely a file-size convention

B. A library's lock file only reflects a development-time snapshot; only the version ranges in its composer.json actually apply when it's installed as someone else's dependency — When a package is installed as a dependency of another project, Composer resolves that package's own requirements using the ranges in its composer.json — its composer.lock is never consulted by a consuming project, so committing it would be misleading noise rather than something that actually constrains installs downstream.

You maintain acme/rate-limiter at version 1.4.2. You are adding a new optional constructor parameter with a default value (fully backward-compatible), and separately fixing a bug where the limiter undercounted requests by one. What should the next version number be, and what two CHANGELOG sections should the entries go under?

1.5.0 — a MINOR bump, since neither change breaks backward compatibility (the new parameter is optional, and fixing an undercount is a bug fix, not an API change). The new parameter goes under 'Added', and the undercount fix goes under 'Fixed'.

Neither change breaks any existing public API contract — think about which single-digit version segment covers both together.