Publishing Your First Composer Package: A Complete Guide

Philip Rehberger Sep 4, 2026 5 min read

Take an internal helper from `app/Support` to Packagist. Covers semver, autoload, CI, and abandonment policy.

You wrote a helper class, used it across three projects, and finally got tired of copying it. The cleanest answer is to publish it as a Composer package — yours, properly named, properly versioned, usable by anyone with a composer require. This post is the full path from "I have a useful class" to "we depend on this in production."

Decide What Goes In

A Composer package should solve one well-defined problem. If you find yourself describing it as "a collection of utilities," you have a private utilities library, not a package. Split it.

Good package candidates:

  • A wrapper around a specific API that you have written more than once
  • An abstraction over a vendor-specific feature (Stripe webhooks, Slack message blocks)
  • A standalone tool that does one thing (slug generator, CSV writer, retry decorator)

Things that should not be packages, at least not yet:

  • Application-specific logic that depends on your domain
  • Trivial code (function add(int $a, int $b): int)
  • Code that bundles secrets or environment-specific defaults

Repository Layout

A working layout for a Laravel-compatible Composer package:

my-package/
├── src/
│   ├── MyClass.php
│   └── ServiceProvider.php
├── tests/
│   └── MyClassTest.php
├── config/
│   └── my-package.php
├── composer.json
├── phpunit.xml
├── .github/
│   └── workflows/
│       └── tests.yml
├── README.md
├── LICENSE
└── CHANGELOG.md

If your package is framework-agnostic, drop the service provider and the config; keep the rest.

composer.json

This is the most important file. It defines your package's identity, its dependencies, and its autoload rules.

{
    "name": "scopeforged/example-package",
    "description": "A clear, single-sentence description.",
    "type": "library",
    "license": "MIT",
    "keywords": ["laravel", "scopeforged", "example"],
    "authors": [
        { "name": "Philip Rehberger", "email": "philip@scopeforged.com" }
    ],
    "require": {
        "php": "^8.2",
        "illuminate/contracts": "^11.0|^12.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^11.0",
        "orchestra/testbench": "^9.0|^10.0"
    },
    "autoload": {
        "psr-4": {
            "Scopeforged\\ExamplePackage\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Scopeforged\\ExamplePackage\\Tests\\": "tests/"
        }
    },
    "extra": {
        "laravel": {
            "providers": [
                "Scopeforged\\ExamplePackage\\ServiceProvider"
            ]
        }
    },
    "minimum-stability": "stable",
    "prefer-stable": true
}

A few things people get wrong:

  • require should be the narrowest set you actually need. Each line is a constraint on your users.
  • Version constraints should be permissive at the high end. Use ^11.0|^12.0 for Illuminate, not ^11.0 — your users will be on different Laravel versions.
  • Autoload paths must match your namespace exactly. PSR-4 violations break in production in surprising ways.

Semantic Versioning

The version contract is:

  • Major (X.0.0): Breaking changes. Users must update their code.
  • Minor (X.Y.0): New functionality, backward-compatible.
  • Patch (X.Y.Z): Bug fixes, backward-compatible.

Treat breaking changes seriously. Once a function exists with a signature, changing it is a major version. Renaming a method is a major version. Tightening a type is a major version.

When in doubt, deprecate first. Mark old behavior with @deprecated, keep it working, ship a minor version, then remove in the next major.

Tests

A package without tests is a maintenance bomb you are handing to your users. The minimum:

namespace Scopeforged\ExamplePackage\Tests;

use PHPUnit\Framework\TestCase;
use Scopeforged\ExamplePackage\MyClass;

final class MyClassTest extends TestCase
{
    public function test_it_does_the_thing(): void
    {
        $result = (new MyClass())->doTheThing('input');
        $this->assertEquals('expected', $result);
    }
}

For Laravel packages, use Orchestra Testbench:

use Orchestra\Testbench\TestCase;

class FeatureTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [\Scopeforged\ExamplePackage\ServiceProvider::class];
    }
}

Run them in CI on every supported PHP and framework version. GitHub Actions makes this cheap:

strategy:
  matrix:
    php: ['8.2', '8.3', '8.4']
    laravel: ['11.*', '12.*']

README

The README is your front door. It should answer four questions in the first screen:

  1. What does this do?
  2. How do I install it?
  3. How do I use it in three lines?
  4. Where do I get help?

Anything more elaborate goes in dedicated docs or the wiki.

License

Pick a license before you publish. MIT is the default for permissive open source — short, well-understood, compatible with almost everything. Add a LICENSE file at the repo root.

If the package is internal-only, you can use a proprietary license, but Packagist's free tier expects open source. Internal packages typically live in a private repository registered with Composer's vcs repositories, not on public Packagist.

Publishing to Packagist

  1. Push the repo to GitHub with at least one tagged release: git tag v1.0.0 && git push origin v1.0.0.
  2. Create an account at packagist.org.
  3. Submit your package URL: it autodetects the GitHub repo.
  4. Set up the GitHub webhook for auto-updates.

After the first publish, new versions are detected automatically when you push tags. The cadence becomes: write code, commit, tag, push.

Maintenance Realities

Publishing a package is the easy part. Maintenance is where most packages die.

  • Issues take time. Every issue is a debug session you did not plan for.
  • Compatibility shifts. New Laravel and PHP versions require testing and sometimes code changes.
  • Security updates propagate. A vulnerability in a dependency means yours has one too.
  • Deprecation is hard. Removing functionality always breaks someone.

Before you publish, ask: am I willing to maintain this for at least two years? If not, consider whether your project is better served by keeping the code internal and copying it where needed. Open-sourcing creates expectations you have to meet.

Abandonment

If you cannot maintain a package any longer, abandon it cleanly. Composer has a composer abandon command and Packagist has a "mark as abandoned" feature that warns installers. Suggest a replacement if one exists. This is much better than letting the package rot silently.

Internal vs Public

The decision between an internal-only package and a public one is mostly about audience. If only your team uses it, internal (private repo + Composer VCS) is simpler — no docs polish, no community, no deprecation policy. Public packages are worth the effort when the code is genuinely useful outside your domain and you are ready to support users.

Many useful packages start internal and go public after a year or two of internal use proves they solve a real problem cleanly.


Thinking about publishing internal tooling but not sure if it is package-shaped yet? We help teams extract reusable code into proper packages without taking on more maintenance than they want. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.