Designing, Versioning, and Maintaining a PHP Package
Design a clean public API for a reusable PHP package, apply Semantic Versioning correctly when evolving it, document and deprecate changes responsibly, and set up CI and release practices so the package stays trustworthy for the people who depend on it.
Writing code that only you will ever call is a different discipline from writing code other developers will add to their composer.json and never read the source of again. A published package is a promise: callers write code against your public API today and expect it to keep working, or to break only in ways they were warned about, tomorrow. This lesson covers what it actually takes to publish and maintain a PHP package well — from designing a public surface worth committing to, through Semantic Versioning and deprecation practices that let you evolve the package without breaking every consumer overnight, to the CI and release habits that keep a package trustworthy over years of use on Packagist.
Designing a Public API Worth Committing To
The single most important decision in package design is drawing a hard line between what is public API and what is internal implementation detail, because everything public becomes a promise you have to keep, and everything internal you're free to change at will. A good default is to make the public surface as small as possible: a handful of well-named classes and interfaces that express the package's core concept, with everything else — helper classes, internal state, formatting logic — marked final and, where PHP 8.1+'s readonly properties fit, made immutable so consumers can't reach in and mutate internals even if they find a way to access them.
Marking a class final is a design statement, not a restriction for its own sake: it tells consumers 'do not extend this — if you need different behavior, compose with an interface or configuration option instead', which closes off an entire category of breaking change (someone extending your class and overriding a protected method that you then need to rename or remove). Interfaces, in contrast, are exactly where you want extension points to live: if a package needs to support custom behavior, expose a small interface (a CacheInterface, a LoggerInterface-style contract) rather than a base class meant to be subclassed, because interfaces communicate a real contract while inheritance from a concrete class exposes far more surface area than most consumers should be able to depend on.
Method signatures deserve the same restraint as class design. Prefer accepting narrow, well-known types (a specific interface, a scalar with a clear meaning) over mixed or loosely-typed arrays, because a narrow accepted type gives you room to widen it later (a compatible, non-breaking change) whereas a wide accepted type gives you no room to narrow it later (a breaking change). The same logic runs in reverse for return types: return a narrow, specific type so you have room to widen it in the future without breaking anyone who type-hinted against your return value.
A useful mental exercise before publishing version 1.0 is to write down, in the README, every class and method a consumer is expected to touch directly. If that list is long, or if it's hard to explain why any given item needs to be public, that's a signal to shrink the surface before committing to it — because the moment real consumers depend on it, removing it becomes a breaking change you have to schedule, document, and wait out a deprecation window for, rather than a quick refactor.
• Draw a hard line: public API is a promise, internal implementation is free to change. • Mark classes not meant for extension as final; expose extension points via small interfaces instead. • Use readonly properties (PHP 8.1+) on value objects to prevent consumers from mutating internal state. • Accept narrow parameter types (room to widen later); return narrow types (room to widen later) — both directions of "narrow now, widen later" are backward compatible. • Before 1.0, write down every class/method a consumer should touch directly — a long or hard-to-justify list is a sign to shrink the public surface.
Declaration Compatibility vs Runtime Compatibility
When you change a package, there are two different, often-confused questions to ask: does existing calling code still compile/parse against the new version (declaration compatibility), and does it still behave the same way at runtime (runtime compatibility)? A change can break one without breaking the other, and both matter for different reasons. Widening a parameter's type from string to string|int is declaration-compatible (old calls still type-check) and usually runtime-compatible too, as long as the new branch's behavior for the added type doesn't surprise anyone. Adding a new required constructor parameter, on the other hand, breaks declaration compatibility outright — every existing call site that constructs the class now fails to compile against the new signature.
The trickier failures are runtime-only: code that still parses and runs without error but now produces a different result. Changing a method's internal algorithm so it returns results in a different order, changing a default parameter value, or changing what a method does with an edge-case input (throwing where it used to return null, say) can all be declaration-compatible — nothing about the method's signature changed — while still breaking every consumer who relied on the old behavior. These are the changes that are easiest to ship by accident because static analysis and even PHP's own type system won't catch them; they only surface when a consumer's tests (or, worse, their production traffic) hits the changed path.
This is why a package's public behavior contract has to be documented, not just its signatures: a docblock or README section that says 'returns results sorted by insertion order' is part of the contract just as much as the return type is, and changing that behavior is a breaking change even though nothing in the method signature moved. Packages that take compatibility seriously write down these behavioral guarantees explicitly, and add regression tests specifically to lock in the wording of the contract, not just to check that the method returns some value of the right type.
• Declaration compatibility: does old calling code still type-check/compile against the new version? • Runtime compatibility: does old calling code still behave the same way when it actually runs? • A signature can stay identical while runtime behavior changes underneath it — declaration-compatible, but still a breaking change. • Document behavioral guarantees (ordering, exceptions thrown, edge-case handling) explicitly — they are part of the public contract even though a type signature can't express them. • Add regression tests that lock in documented behavior, not just tests that check return types.
Semantic Versioning for a PHP Library
Semantic Versioning (semver) gives version numbers a meaning consumers can rely on without reading a changelog first: given a version MAJOR.MINOR.PATCH, a PATCH bump (1.4.2 → 1.4.3) contains only backward-compatible bug fixes, a MINOR bump (1.4.3 → 1.5.0) adds backward-compatible functionality, and a MAJOR bump (1.5.0 → 2.0.0) contains breaking changes. Composer's version constraints (^1.4, ~1.4.2) are built entirely around this promise — ^1.4 means 'any version compatible with 1.4, i.e. up to but not including 2.0.0' — so a package that violates semver (shipping a breaking change in a minor or patch release) silently breaks that promise for every consumer who trusted the constraint operator instead of pinning an exact version.
For a PHP library specifically, the following are unambiguously breaking changes and require a MAJOR bump: removing a public class, method, constant, or property; changing a method's visibility from public to protected or private; removing or renaming a public method's required parameter; narrowing an accepted parameter type (e.g. from string|int to string — code that used to legally pass an int now fails); widening a return type in a way that removes information the old type guaranteed (e.g. from string to ?string — code that assumed a non-null string can now receive null and break); adding a new required constructor parameter to a class consumers instantiate directly; and changing documented runtime behavior in a way existing callers can observe and depend on.
The following are backward-compatible and only require a MINOR bump: adding a new public method, class, or interface; adding a new optional parameter with a default value; widening an accepted parameter type (e.g. from string to string|int); narrowing a return type in a way that adds information without removing a guarantee (e.g. from ?string to string, if the method genuinely never returned null in practice — though this specific case should be verified carefully, since some codebases still count it as breaking if it changes documented nullability); and adding new interfaces to an existing class as long as no existing method's contract changes.
Two commonly-missed sources of accidental breaking changes are worth calling out explicitly. First, adding a new method to a public interface is a breaking change for anyone implementing that interface, because their existing implementation no longer satisfies it — this is why some libraries add a @method docblock hint or a separate extended interface rather than modifying an existing public interface once it has consumers. Second, changing a constructor's parameter order (even without adding or removing any) breaks any caller using positional arguments, though as of PHP 8.0's named arguments, callers using new Thing(name: 'x', size: 3) are unaffected — which is itself a good argument for encouraging named-argument usage in your own documentation for constructors likely to grow.
• MAJOR.MINOR.PATCH — PATCH: bug fixes only; MINOR: backward-compatible additions; MAJOR: breaking changes. • Breaking (MAJOR): remove public API, reduce visibility, remove/rename required params, narrow accepted types, widen return types that drop a guarantee (e.g. add null), add required constructor params, change documented behavior. • Non-breaking (MINOR): add public API, add optional params with defaults, widen accepted types, add new interfaces without changing existing contracts. • Adding a method to an existing public interface is breaking for its implementers, even though it looks purely additive from the interface's own perspective. • Changing constructor parameter order breaks positional-argument callers but not named-argument callers (PHP 8.0+).
Documentation: README, CHANGELOG, and PHPDoc
A README is the first and often only thing a potential consumer reads before deciding whether to depend on a package, and it should answer, in order: what problem does this solve, how do you install it (the composer require line), a minimal working example, and where to find fuller documentation or the API reference. A README that opens with installation instructions before explaining what the package does, or that only shows a contrived example instead of the actual common use case, loses potential adopters before they get to the parts that would have sold them.
A CHANGELOG, kept in CHANGELOG.md at the package root, is a separate and equally important document: it is the record consumers check before running composer update to know whether the update is safe. The widely-adopted convention is Keep a Changelog (keepachangelog.com), which specifies a consistent structure — an ## [Unreleased] section at the top for changes not yet released, then dated version sections in reverse-chronological order, each broken into ### Added, ### Changed, ### Deprecated, ### Removed, ### Fixed, and ### Security subsections. Writing changelog entries as you make each change (not reconstructed from git log right before a release) keeps them accurate and keeps the language focused on user-facing impact rather than implementation detail.
PHPDoc comments serve a narrower but very practical purpose: they give IDEs and static analysis tools (PHPStan, Psalm) information the PHP type system itself can't express — generic-like array shapes (@param array<int, string> $ids), the specific class contained in a collection, or a deprecation notice with a target version and migration path. Even in a codebase using full PHP type hints everywhere, PHPDoc remains valuable specifically for the gaps native types leave: array key/value types, generics-style parameterization, and structured deprecation metadata that tooling can parse and warn about.
• README order: what it solves → install command → minimal working example → link to full docs/API reference. • CHANGELOG.md follows Keep a Changelog: dated version sections, each split into Added/Changed/Deprecated/Removed/Fixed/Security. • Write changelog entries as changes happen, not reconstructed from git history right before a release. • PHPDoc fills gaps native PHP types can't express: array shapes, generics-style parameterization, and machine-readable @deprecated metadata.
Deprecating Responsibly Before Removing
Removing a public method outright, even a rarely-used one, is a breaking change and forces every consumer who happens to call it to fix their code the moment they upgrade to your next major version — with no warning beforehand. The responsible path is a deprecation window: mark the old method @deprecated with a docblock explaining exactly what to use instead, keep the old method fully functional, and only remove it in a subsequent major release, giving consumers one or more minor-version releases of overlap in which both the old and new paths work.
A @deprecated docblock tag alone is silent — it helps IDEs show a strikethrough and helps static analysis tools flag usage, but a consumer who never re-runs their IDE's inspection or a linter can miss it entirely. Pairing the docblock with a runtime trigger_error($message, E_USER_DEPRECATED) call inside the deprecated method makes the deprecation visible even to consumers who only run the code without static analysis — most frameworks and testing tools surface E_USER_DEPRECATED warnings prominently (PHPUnit, for instance, can be configured to report or even fail on them), which is exactly the visibility you want before a breaking removal.
The deprecation message itself should always answer two questions concretely: what should the caller use instead, and by when does the old method disappear. "Deprecated" alone, with no migration path, is nearly useless to a consumer trying to plan an upgrade; "deprecated since 2.3, use fetchAll() instead, will be removed in 3.0" gives them everything needed to act. A reasonable deprecation window for most libraries is at least one full minor version cycle — long enough that consumers pinned to ^2.x will see the deprecation warning in their test output before the next major version removes the method entirely.
• Deprecation window: mark @deprecated, keep the old method working, remove only in a later major version — never remove without a warning period. • @deprecated docblock alone is silent to non-static-analysis consumers; pair it with trigger_error(..., E_USER_DEPRECATED) for runtime visibility. • PHPUnit and other tooling can surface or fail on E_USER_DEPRECATED, making deprecations visible in ordinary test runs. • Every deprecation message needs: what to use instead, and the version in which the old path will be removed. • Give consumers at least one minor-version cycle of overlap between the deprecation warning and the removal.
CI and Release Practices
A published package should be tested against every PHP version and dependency combination it claims to support, not just the one version the maintainer happens to run locally. GitHub Actions makes this a matrix build: a single workflow file defines a matrix of PHP versions (say, 8.1, 8.2, 8.3) and optionally dependency sets (--prefer-lowest to test against the oldest allowed versions of dependencies alongside the newest), and GitHub Actions runs the full test suite once per combination in parallel, catching version-specific regressions — a function deprecated in 8.3, a behavior change in a bundled extension — before a release ships with a compatibility claim it can't actually meet.
Beyond the test matrix, a healthy CI pipeline for a package typically also runs static analysis (PHPStan or Psalm at a meaningful strictness level) and a code style checker (PHP-CS-Fixer or PHP_CodeSniffer against PSR-12) on every pull request, so style and type issues are caught before merge rather than accumulating for a maintainer to clean up later. Some packages also run a compatibility linter that specifically checks for breaking-change patterns (removed public methods, changed signatures) against the previous tagged release, catching accidental semver violations before they ship.
Releasing is the last step, and it should be deliberate: after CI is green on the target commit, tag the release with an annotated git tag matching the version (git tag -a v2.4.0 -m "Release 2.4.0"), push the tag, and let Packagist's GitHub webhook pick up the new tag automatically if the package is already registered there (or trigger packagist.org's update manually the first time a package is published). The CHANGELOG.md's dated section for that version should already exist, moved out of ## [Unreleased], before the tag is pushed — the tag should represent a commit where the changelog, the version-bearing files, and the actual code are all consistent with each other.
• GitHub Actions matrix build: test every supported PHP version (and optionally --prefer-lowest dependencies) in parallel on each PR/push. • Run static analysis (PHPStan/Psalm) and a style checker (PHP-CS-Fixer/PHPCS against PSR-12) as separate CI jobs. • Tag releases with annotated git tags (git tag -a vX.Y.Z) matching the version exactly. • Packagist picks up new tags via a GitHub webhook once the package is registered there. • Move the CHANGELOG's [Unreleased] section into a dated version section before tagging, so the tag, changelog, and code agree.
Code Examples
The matrix runs the full suite once per PHP version crossed with both the lowest and the most stable allowed dependency versions — six jobs in parallel from one workflow file. This is what actually justifies a composer.json claim of \"php\": \"^8.1\": without testing 8.1 specifically, a maintainer only knows the code works on whatever version they personally run.
The old method keeps working exactly as before — no consumer's code breaks on upgrade — but both the docblock and the runtime trigger_error() call point at exactly what to switch to and exactly when the old method disappears. A consumer running PHPUnit configured to surface deprecations will see this warning in their own test output long before 3.0.0 ships, giving them time to migrate on their own schedule.
Each version is dated and scoped to exactly the changes it contains, split into the standard Keep a Changelog categories. A consumer deciding whether composer update 2.2.1 → 2.3.0 is safe can read this file in ten seconds and see it's purely additive plus one deprecation notice — no digging through commit history required.
// v1.4.0 tag: removed the old fetch() method entirely, replaced by fetchAll()
// v1.4.0: add fetchAll(), mark fetch() @deprecated with trigger_error(); remove fetch() only in v2.0.0
Removing a public method in a minor version violates semver outright — every consumer using ^1.x in their composer.json will have their build silently break on the next composer update, with no warning. Breaking changes belong exclusively in major versions, and even then only after a deprecation period in a prior minor release.
public function process(string $input): string { /* changed to sometimes throw on empty input */ } public function process(string $input): string { /* documented: throws InvalidArgumentException on empty input since 1.6.0 (breaking — bump to 2.0.0) */ }
Changing what a method does with an input it previously handled silently (returning empty string) to instead throw is a runtime-incompatible change even though the method signature never moved. This kind of change is easy to ship by accident because neither PHP's type system nor a naive diff of the public API surface will flag it — it has to be caught by reading the changelog discipline and treated as a MAJOR bump.
/** * @deprecated */ public function oldMethod() {... } /** * @deprecated 2.3.0 Use newMethod() instead. Will be removed in 3.0.0. */ public function oldMethod() { trigger_error('oldMethod() is deprecated, use newMethod() instead.', E_USER_DEPRECATED);... }
A bare @deprecated tag with no replacement named and no removal version tells a consumer only that something is wrong, not what to do about it or how urgently — and without a runtime trigger_error() call, consumers who don't run static analysis on the package's internals never see the warning at all.
Under Semantic Versioning, which of these changes to a public method requires a MAJOR version bump? A. Adding a new optional parameter with a default value B. Narrowing an accepted parameter type from string|int to string C. Adding a new public method to the class D. Widening an accepted parameter type from string to string|int
B. Narrowing an accepted parameter type from string|int to string — Narrowing an accepted type removes a capability existing callers relied on — code that used to legally pass an int now fails to type-check — which is a breaking change requiring a MAJOR bump. The other three options are all backward-compatible additions that only require a MINOR bump.
Why can a change be 'declaration-compatible' but still break consumers at runtime? A. Declaration compatibility and runtime compatibility always match; this cannot happen B. PHP's type system automatically catches all behavioral changes C. A method's signature can stay identical while its internal behavior changes (e.g. throwing instead of returning null on an edge case), so old code still compiles but produces different results D. Runtime compatibility only applies to interfaces, never to concrete classes
C. A method's signature can stay identical while its internal behavior changes (e.g. throwing instead of returning null on an edge case), so old code still compiles but produces different results — Type signatures describe shape, not behavior. A method can keep an unchanged signature while its documented behavior (ordering, exception handling, edge-case results) changes underneath it, which is exactly the kind of breaking change PHP's type system and static analysis cannot detect — only documentation discipline and behavioral regression tests catch it.
What is the purpose of pairing an @deprecated PHPDoc tag with a runtime trigger_error($message, E_USER_DEPRECATED) call? A. It automatically removes the deprecated method in the next release B. It makes the deprecation visible to consumers who only run the code (not static analysis), since many test tools like PHPUnit surface or fail on E_USER_DEPRECATED warnings C. It is required by PHP's syntax and the method will not compile without it D. It silences IDE warnings about the deprecated method
B. It makes the deprecation visible to consumers who only run the code (not static analysis), since many test tools like PHPUnit surface or fail on E_USER_DEPRECATED warnings — A @deprecated docblock alone only helps tools that statically analyze the source (IDEs, PHPStan). trigger_error(..., E_USER_DEPRECATED) fires at actual runtime, so consumers who merely execute the code — including through their own test suites — see the warning even if they never run a linter against the package's internals.
What does a GitHub Actions matrix build across multiple PHP versions actually verify for a published package? A. That the package has no security vulnerabilities B. That the code compiles identically on every operating system C. That the test suite actually passes on every PHP version the package's composer.json claims to support, not just the maintainer's local version D. That the package's version number follows Semantic Versioning correctly
C. That the test suite actually passes on every PHP version the package's composer.json claims to support, not just the maintainer's local version — A composer.json constraint like "php": "^8.1" is a claim of support for 8.1 through the latest 8.x. Without actually running the test suite on each of those versions in CI, that claim is untested — a matrix build (e.g. PHP 8.1, 8.2, 8.3 each in their own job) is what turns the claim into a verified fact before a release ships.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
dependency-version: [prefer-lowest, prefer-stable]
steps:
- uses: actions/checkout@v4
- name: Set up PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- name: Install dependencies
run: composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction
- name: Run test suite
run: vendor/bin/phpunit
- name: Static analysis
run: vendor/bin/phpstan analyse --level=8<?php
declare(strict_types=1);
namespace Acme\Http;
final class ResponseBuilder
{
/**
* Build a JSON response.
*
* @deprecated 2.3.0 Use toJsonResponse() instead, which returns a
* PSR-7 ResponseInterface rather than a raw string, and
* sets the correct Content-Type header automatically.
* This method will be removed in 3.0.0.
*/
public function toJson(array $data): string
{
trigger_error(
'ResponseBuilder::toJson() is deprecated since 2.3.0 and will be removed in 3.0.0. '
. 'Use ResponseBuilder::toJsonResponse() instead.',
E_USER_DEPRECATED
);
return json_encode($data, JSON_THROW_ON_ERROR);
}
/**
* Build a JSON response as a proper PSR-7 response object.
*
* @since 2.3.0
*/
public function toJsonResponse(array $data): \Psr\Http\Message\ResponseInterface
{
$body = json_encode($data, JSON_THROW_ON_ERROR);
return new \Nyholm\Psr7\Response(
200,
['Content-Type' => 'application/json'],
$body
);
}
}# Changelog
All notable changes to this project are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [2.3.0] - 2026-08-14
### Added
- \`ResponseBuilder::toJsonResponse()\` returning a PSR-7 \`ResponseInterface\`.
### Deprecated
- \`ResponseBuilder::toJson()\` is deprecated in favor of \`toJsonResponse()\`
and will be removed in 3.0.0.
## [2.2.1] - 2026-06-02
### Fixed
- Fixed an issue where empty arrays serialized to \`[]\` instead of \`{}\`
when a JSON object was expected.
## [2.2.0] - 2026-05-11
### Added
- Support for PHP 8.3.