Publishing Your Own Composer Packages: From Idea to Packagist
AI generated
<?php
8.4
PHP 8.4 · Composer · Packagist · Open Source
Publishing Your Own Composer Packages
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.

17 min read composer.json · PSR-4 · Packagist · CI PHP 8.4 · Composer 2.x · Framework-agnostic

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.

11. FAQ: Publishing Your Own Composer Packages

1When does an own Composer package pay off?
As soon as the same code is used across several projects or teams. Own versioning is then cheaper than maintaining copied code in several places.
2Does a package have to be public on Packagist?
No. VCS repositories and Private Packagist allow completely unpublished packages that are still installable normally.
3Where does Composer read the version from?
From Git tags, not from composer.json. A tag marks an immutable snapshot that Packagist offers.
4What happens if a tag is moved afterward?
Users with the old commit hash end up in contradictory states. Always use a new patch release instead of moving a tag.
5Why the Packagist webhook?
Without it Packagist only updates periodically. With it, a new version is installable within seconds.
6Why a dedicated autoload-dev section?
It only applies without --no-dev, keeping test classes and fixtures out of end users' vendor directories.
7Which license for a new package?
MIT is the most common standard in the PHP ecosystem and allows commercial use without restriction.
8Remove a public method without surprising users?
Mark it @deprecated first, for at least one minor version, before it disappears in a new major version.
9What does abandoned mean on Packagist?
A marker for an unmaintained package, optionally pointing to a successor, Composer warns users clearly during install.
10Monorepo package instead of a standalone one?
Sensible when code is shared only within one organization between a few projects, saves pipeline and versioning.