from idea to Packagist
Anyone who copies the same helper code into several projects eventually loses track of which version runs where. Your own Composer package solves this: one composer.json, one PSR-4 namespace, one version number per Git tag, and a single source of truth. This article walks the complete path from the first directory structure through Git tags to registration on Packagist, with real, traceable PHP 8.4 code.
Table of Contents
- 1. Why publish a package instead of copying code
- 2. Basic structure: setting up composer.json correctly
- 3. Defining namespace, PSR-4 and directory structure
- 4. Versioning and Git tags for the package
- 5. Registering the package on Packagist and setting up a webhook
- 6. README, LICENSE and metadata for end users
- 7. CI pipeline: tests, PHPStan and code style before every release
- 8. Maintenance after release: issues, PRs and deprecations
- 9. Your own package compared to copy-paste and monorepo
- 10. Summary
- 11. FAQ
1. Why publish a package instead of copying code
In almost every PHP team, the same helper code eventually gets written twice: a money value class, a small HTTP wrapper, a set of validation rules. If this code is copy-pasted into several projects, it effectively exists as multiple independent copies that drift apart unnoticed. A bugfix in project A only reaches project B if someone actively remembers to port it manually. Your own Composer package solves exactly this problem by keeping the code in a single place and distributing it to all dependent projects through a controlled version number.
The second benefit of an own Composer package lies in the forced separation of concerns. Once code lives in its own repository with its own composer.json, it inevitably has to work independently of any specific application, without silent assumptions about global state, autoloading order, or framework-specific helper functions. This isolation exposes coupling problems that often stay hidden for years in a monolithic project, simply because the code is never executed outside its familiar context.
Not every piece of code is a good fit for an own Composer package. Highly project-specific business logic tightly coupled to a single domain model usually does not belong in a reusable package. Better candidates are generic, domain-neutral building blocks: value objects, HTTP clients, formatting and validation logic, small adapters for external APIs. Drawing this line early avoids building a package that is really only useful for one project, yet still carries the full maintenance overhead of a public library.
2. Basic structure: setting up composer.json correctly
composer.json is the central description of every Composer package and determines how name, dependencies and autoloading appear to the outside world. The name field must follow the vendor/package schema in lowercase, where the vendor part usually reflects the company or GitHub name. The type field should explicitly say library for a reusable package, the standard value Composer expects when a package is meant to be installed as a dependency in other projects rather than being a standalone project itself.
A precise require section is particularly important for an own Composer package. Every dependency actually used belongs here explicitly, even if it would already be available transitively through another library, because that can change at any time and would otherwise cause the own package to suddenly break for no apparent reason. The PHP version requirement itself also belongs in require, such as "php": "^8.2", so Composer rejects incompatible installations upfront instead of failing at runtime with a syntax error.
The autoload section points to the source directory via PSR-4, while a separate autoload-dev section applies only to tests and is completely absent from production installs. This separation is particularly important for a Composer package, because an end user who installs the package via composer require should never see test classes or fixtures in their own vendor directory.
{
"name": "mironsoft/money-value",
"description": "Immutable money value object for PHP 8.4 with currency-safe arithmetic",
"type": "library",
"license": "MIT",
"keywords": ["php", "money", "value-object", "currency"],
"authors": [
{ "name": "Mironsoft", "homepage": "https://mironsoft.de" }
],
"require": {
"php": "^8.2",
"ext-bcmath": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.11"
},
"autoload": {
"psr-4": { "Mironsoft\\MoneyValue\\": "src/" }
},
"autoload-dev": {
"psr-4": { "Mironsoft\\MoneyValue\\Tests\\": "tests/" }
},
"minimum-stability": "stable",
"prefer-stable": true
}
3. Defining namespace, PSR-4 and directory structure
The namespace of a Composer package should mirror the vendor name from composer.json, so users can tell where a class comes from just by its namespace. A directory structure with src/ for production code and tests/ for tests has become the de facto standard, because both humans and tools like PHPStan and PHPUnit understand it without any extra configuration. Inside src/, the directory structure mirrors exactly the namespace below the PSR-4 prefix, which is not a recommendation but a technical requirement of autoloading.
A common beginner mistake in a new own Composer package is exposing too many public classes and not thinking through API boundaries enough. Anyone who consistently distinguishes from the start between a narrow, documented public API and internal implementation details, for example through final classes and a deliberately small set of exported interfaces, saves themselves later breaking changes when internal details need to be adjusted after all. This very discipline separates a package that stays stable over years from one that forces a new major version with every internal refactor.
<?php
declare(strict_types=1);
namespace Mironsoft\MoneyValue;
/**
* Immutable money value object, part of the package's small public API.
* Internal helpers stay in Mironsoft\MoneyValue\Internal and are not
* covered by the package's backward compatibility promise.
*/
final readonly class Money
{
public function __construct(
private int $amountInCents,
private string $currencyCode,
) {
}
public function add(self $other): self
{
if ($this->currencyCode !== $other->currencyCode) {
throw new CurrencyMismatchException($this->currencyCode, $other->currencyCode);
}
return new self($this->amountInCents + $other->amountInCents, $this->currencyCode);
}
public function formatted(): string
{
return number_format($this->amountInCents / 100, 2) . ' ' . $this->currencyCode;
}
}
4. Versioning and Git tags for the package
Composer reads the version of a Composer package primarily from Git tags, not from a field inside composer.json itself. A tag such as v1.2.0 or 1.2.0 marks an immutable snapshot of the repository that Packagist recognizes and offers as an installable version. Without consistent tags, Composer can technically fall back to branches or commit hashes, but that produces unstable installs, because the content of a branch can change at any time while a tag by definition stays fixed.
For an own Composer package, a fixed release flow is recommended: first all tests and static analysis pass, then a meaningful changelog entry, then the tag, and only at the end the push to the remote repository. If an already published tag is moved afterward because a bug was found in the release, users who already installed against the old commit hash can end up in contradictory states. A broken tag should therefore never be moved, but always replaced by a new patch release.
#!/usr/bin/env bash
set -euo pipefail
# Full release flow for a Composer package
composer test
composer phpstan
composer cs-check
# Tag the release, annotated tags keep author and message
git tag -a v1.2.0 -m "Add currency conversion helper"
git push origin v1.2.0
# Verify what Packagist will see for this tag
git show v1.2.0 --stat
5. Registering the package on Packagist and setting up a webhook
Packagist is the central, public repository for Composer packages and is queried by Composer by default without any extra configuration. Registering an own package is done through a form on packagist.org, into which only the Git repository URL is entered. Packagist then reads composer.json from the repository, extracts name, description and available versions from the existing tags, and makes the package immediately installable via composer require vendor/package.
Without an additional step, however, Packagist only updates a Composer package periodically, which causes unnecessary delay for a fresh release. The GitHub webhook under repository settings fixes this: on every push, especially every new tag, GitHub actively notifies Packagist, and the new version is ready to install within seconds. GitLab and Bitbucket have equivalent webhook integrations serving the same purpose.
Anyone who no longer maintains a package should explicitly mark it as abandoned on Packagist, optionally with a pointer to a successor. Composer shows users a clear warning when installing a package marked as abandoned, which is fairer to the community than a silently unchanged but effectively dead Composer package.
6. README, LICENSE and metadata for end users
A Composer package without a README is practically undiscoverable for potential users, even if installation via Packagist technically works. A useful README describes the package's purpose in a few sentences, shows a minimal install and usage example right at the top, and only then points to more detailed documentation. Badges for build status, test coverage and supported PHP versions give potential users a sense of quality and freshness within a few seconds.
The LICENSE file is practically mandatory for any publicly installable Composer package, MIT being by far the most common standard in the PHP ecosystem because it allows commercial use without restriction. Without an explicit license file, it is legally unclear whether and how a package may be used at all, which keeps many companies from installing it for compliance reasons, regardless of how good the code actually is.
.gitattributes also controls which files actually end up in the archive Composer ships to end users when a release is created. Tests, CI configuration and documentation sources belong in the repository, but not in every end user installation, since they only take up unnecessary space there without offering any functional value.
#!/usr/bin/env bash
# Create .gitattributes so release archives stay lean
cat > .gitattributes << 'EOF'
/tests export-ignore
/.github export-ignore
/phpunit.xml.dist export-ignore
/phpstan.neon export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
EOF
git add .gitattributes
git commit -m "Exclude dev files from release archives"
7. CI pipeline: tests, PHPStan and code style before every release
A Composer package without an automated CI pipeline relies entirely on the maintainer's discipline to run every test manually before every release. That works for a while, but reliably breaks down once several people contribute to the same package or a single maintainer skips a step under time pressure. A GitHub Actions pipeline that automatically runs PHPUnit, PHPStan and a code style check on every push and every pull request turns this discipline into something technically enforced rather than a mere recommendation.
For an own Composer package that wants to support several PHP versions, a test matrix that runs the same test suite against every supported version pays off. That way a compatibility problem with an older or newer PHP version shows up immediately in the pipeline, instead of only when a user with exactly that version reports an error. In addition, a Composer validation step checks whether composer.json itself is syntactically and semantically correct before any test even runs.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- run: composer validate --strict
- run: composer install --no-progress --prefer-dist
- run: vendor/bin/phpstan analyse --level=8
- run: vendor/bin/php-cs-fixer fix --dry-run --diff
- run: vendor/bin/phpunit
8. Maintenance after release: issues, PRs and deprecations
Publishing a Composer package is not an endpoint, it is the beginning of an ongoing maintenance responsibility. Once other projects develop against a specific version of the package, every change to the public API becomes a decision with real consequences for someone else's code. An issue tracker that is actually watched, and a clear response time on pull requests, separate a living package from one that is effectively abandoned right after its first version.
If a public method of a Composer package is going to be removed in the future, it should first be marked with @deprecated annotations and, where possible, with trigger_error(..., E_USER_DEPRECATED), for at least one minor version before it actually disappears in a new major version. This approach gives users time to adjust their integration instead of being confronted with a fatal error after an update without warning. A well-kept changelog documents every deprecation for everyone running the package in production.
9. Your own package compared to copy-paste and monorepo
Not every situation justifies a fully standalone Composer package with its own repository and its own release pipeline. The following overview compares the three most common strategies for shared PHP code and shows which one offers the lowest effort for sufficient robustness in each case.
| Strategy | Reusability | Maintenance effort | When it makes sense |
|---|---|---|---|
| Copy-paste | None, copies drift apart | Practically none, but expensive for bugfixes | Only for a one-off, very small snippet |
| Package inside a monorepo | Good within the same organization | Low, one repository, one CI run | Several applications inside the same company |
| Own Composer package | Very high, across company boundaries | Higher, own pipeline and versioning | Reuse across teams or publicly |
For code shared exclusively within a single organization between a few projects, a package inside a monorepo is often the more pragmatic choice, because a single CI pipeline and a single version state are enough. Your own Composer package with its own repository pays off once external teams, other companies, or the public are meant to use the code, because only then does the extra isolation and the dedicated release discipline pay off in full.
Mironsoft
PHP architecture, Composer tooling and package strategy
Code copied into five projects instead of living as a package?
We extract shared PHP code into clean, versioned Composer packages, set up CI pipelines and Packagist integration, and establish a sustainable release discipline for your team.
Package extraction
Identify shared code and turn it into a standalone Composer package
Release automation
CI pipeline with tests, PHPStan and automatic Packagist integration
PHP 8.4 consulting
Establish modern language features and PSR standards in your own libraries
10. Summary
Your own Composer package pays off whenever code is used across several projects or teams and a single source of truth matters more than the extra maintenance effort of an own release pipeline. The path there is clearly structured: a clean composer.json with precise dependencies, a PSR-4 compliant directory structure with a narrow public API, consistent Git tags following Semantic Versioning, and registration on Packagist including a webhook for immediate availability of new releases.
README, LICENSE and an automated CI pipeline are not optional extras, they are the basic prerequisite for a Composer package to actually be trusted and used by others. Anyone who additionally maintains a clear deprecation policy and actively works through issues and pull requests turns a one-off release into a long-term reliable dependency for everyone building on top of it.
Publishing your own Composer packages: the key points at a glance
Basic structure
composer.json with name, type: library, a precise require section and PSR-4 autoloading.
Versioning
Git tags following Semantic Versioning, never moved, fix mistakes via a new patch release.
Packagist
Register the repository URL, set up the GitHub webhook for instant updates after every tag.
Quality assurance
CI pipeline with a test matrix across several PHP versions, PHPStan and code style check before every release.