Part 5 of 12 in the PHP Package Development series

You've built a package. You've written tests. It works. Now what? It's time to share it with the world. In this post, we'll take our jakovic/text-toolkit package from a local project to a published Composer package that anyone can install with a single command.

Publishing to Packagist is surprisingly straightforward, but there are several things you need to get right - your repository structure, versioning strategy, and release workflow. Get these right from the start and you'll save youself headaches down the road.

Preparing Your Package for Release

Before pushing anything public, your package needs a few essential files beyond the code itself. Think of these as the packaging around your product - they tell people what it does, how to use it, and what they're allowed to do with it.

The README File

Your README.md is the first thing anyone sees on GitHub and Packagist. It needs to answer three questions immediately: what does this package do, how do I install it, and how do I use it?

Here's a solid starting template for our package:

# Text Toolkit

A collection of text manipulation utilities for PHP.

## Installation

```bash
composer require jakovic/text-toolkit
```

## Requirements

- PHP 8.1 or higher

## Usage

### Slugify

```php
use Jakovic\TextToolkit\Slugify;

$slugify = new Slugify();
echo $slugify->handle('Hello World!'); // "hello-world"
```

### Truncate

```php
use Jakovic\TextToolkit\Truncate;

$truncate = new Truncate();
echo $truncate->handle('This is a long sentence', 10); // "This is a..."
```

## Testing

```bash
composer test
```

## License

The MIT License (MIT). Please see [License File](LICENSE) for more information.

Don't overthink the README for your first release. You can always improve it later. The important thing is that someone can copy-paste the install command and see a working example within 30 seconds.

The LICENSE File

You specified "license": "MIT" in your composer.json back in Part 2. Now you need the actual license file. Create a LICENSE file in your project root:

MIT License

Copyright (c) 2026 Your Name

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

MIT is the most popular license in the PHP ecosystem. It basically says "do whatever you want with this code, just don't blame me if something goes wrong." If you're unsure which license to pick, MIT is almost always the right choice for open-source packages.

The .gitignore File

You don't want to commit everything to your repository. The vendor/ directory, IDE files, and OS files should all be excluded. Create a .gitignore file:

/vendor/
composer.lock
.phpunit.result.cache
.phpunit.cache/
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db

A quick note on composer.lock - for libraries (packages), you should not commit the lock file. This is the opposite of applications, where you always commit it. The reason? When someone installs your package, Composer resolves dependencies based on their project's constraints, not yours. Committing a lock file for a library would serve no purpose.

Final composer.json Check

Before publishing, make sure your composer.json has everything Packagist needs. Here's what ours should look like at this point in the series:

{
 "name": "jakovic/text-toolkit",
 "description": "A collection of text manipulation utilities for PHP",
 "type": "library",
 "license": "MIT",
 "keywords": ["text", "string", "slugify", "truncate", "utilities"],
 "authors": [
 {
 "name": "Your Name",
 "email": "you@example.com"
 }
 ],
 "require": {
 "php": "^8.1"
 },
 "require-dev": {
 "pestphp/pest": "^2.0"
 },
 "autoload": {
 "psr-4": {
 "Jakovic\\TextToolkit\\": "src/"
 }
 },
 "autoload-dev": {
 "psr-4": {
 "Jakovic\\TextToolkit\\Tests\\": "tests/"
 }
 },
 "scripts": {
 "test": "pest"
 },
 "minimum-stability": "stable",
 "prefer-stable": true
}

The keywords field helps people find your package when searching on Packagist. Add relevant terms - but don't stuff it with unrelated words. The minimum-stability and prefer-stable fields tell Composer to use stable releases of your dependencies by default.

Setting Up Your Git Repository

Packagist reads packages directly from Git repositories (usually GitHub, but GitLab and Bitbucket work too). Let's initialize the repo and get it pushed up.

First, initialize Git in your package directory:

cd text-toolkit
git init
git add .
git commit -m "Initial commit"

Now create a repository on GitHub. You can do this through the web interface or with the GitHub CLI:

gh repo create jakovic/text-toolkit --public --source=. --push

If you prefer doing it manually, create the repo on GitHub, then add the remote and push:

git remote add origin git@github.com:jakovic/text-toolkit.git
git branch -M main
git push -u origin main

Your package directory should now look like this:

text-toolkit/
 src/
 Slugify.php
 Truncate.php
 TextToolkit.php
 tests/
 SlugifyTest.php
 TruncateTest.php
 .gitignore
 composer.json
 LICENSE
 README.md
 phpunit.xml (or pest config)

Understanding Semantic Versioning

Before you tag your first release, you need to understand semantic versioning (SemVer). This isn't just a convention - it's a contract with your users. Composer relies on it to decide which versions are safe to install.

A semantic version has three parts:

MAJOR.MINOR.PATCH

Example: 1.4.2
 ^ ^ ^
 | | |
 | | +-- Patch: bug fixes (no new features, no breaking changes)
 | +---- Minor: new features (backward-compatible)
 +------ Major: breaking changes (existing code might break)

Here's what each number means in practice:

  • PATCH (1.0.0 to 1.0.1) - You fixed a bug. Maybe Slugify wasn't handling Unicode correctly. Users can safely update without changing any of their code.
  • MINOR (1.0.0 to 1.1.0) - You added a new feature. Maybe you added a new Excerpt class. Existing code still works exactly the same - you just added something new.
  • MAJOR (1.0.0 to 2.0.0) - You changed or removed something that existing users depend on. Maybe you renamed a method from handle() to convert(), or changed a method signature. Upgrading might require code changes.

Let's look at some real examples to make this concrete:

v1.0.0 - Initial stable release
v1.0.1 - Fixed: Slugify now handles consecutive spaces correctly
v1.1.0 - Added: New Excerpt class for generating text excerpts
v1.1.1 - Fixed: Excerpt handles HTML tags properly
v1.2.0 - Added: Truncate now accepts a custom suffix parameter
v2.0.0 - Changed: Renamed handle() to convert() across all classes
v2.0.0 - Removed: Dropped PHP 8.0 support

The golden rule: if someone has working code using your package, a PATCH or MINOR update should never break it. Only a MAJOR update is allowed to break things.

What About 0.x Versions?

Versions starting with 0. (like 0.1.0, 0.5.0) are considered unstable. During the 0.x phase, anything can change at any time. The API isn't locked down yet, and users understand they're using something that's still being figured out.

Many packages start at 0.1.0 and stay in the 0.x range until the developer is confident in the API. Once you're happy with the public interface and ready to commit to backward compatibility, release 1.0.0.

For our text-toolkit package, the API is simple and stable, so we'll start directly at 1.0.0. But if you're building something more complex and you're still experimenting with the design, starting at 0.1.0 is perfectly fine.

Tagging Releases with Git Tags

Packagist uses Git tags to determine your package versions. When you push a tag like v1.0.0, Packagist sees it as version 1.0.0 of your package. No tags means no installable versions.

Let's tag our first release:

# Make sure everything is committed
git status

# Create an annotated tag
git tag -a v1.0.0 -m "Initial stable release"

# Push the tag to GitHub
git push origin v1.0.0

There are two types of Git tags - lightweight and annotated. Always use annotated tags (the -a flag). They store extra information like the tagger's name, date, and a message. Packagist works with both, but annotated tags are teh standard practice.

You can also push all tags at once:

git push origin --tags

To see all your tags:

# List all tags
git tag

# List tags with details
git tag -n

If you made a mistake and need to delete a tag:

# Delete locally
git tag -d v1.0.0

# Delete from remote
git push origin --delete v1.0.0

A word of caution: never delete or move a tag after people have started using it. If someone's composer.lock references v1.0.0 and you change what that tag points to, their builds could break in unpredictable ways. If you find a bug in v1.0.0, fix it and release v1.0.1 instead.

Tag Naming Convention

You'll see packages using both v1.0.0 and 1.0.0 for tags. Composer accepts both - it strips the v prefix automatically. The v prefix is more common in the PHP ecosystem (Laravel, Symfony, and most popular packages use it), so stick with v1.0.0.

Registering on Packagist

Now for the exciting part - getting your package on Packagist so anyone in the world can composer require it.

Step 1: Create a Packagist Account

Go to packagist.org and click "Sign up." The easiest option is to sign in with your GitHub account - this also makes setting up webhooks easier later.

Step 2: Submit Your Package

Once logged in, click the "Submit" button in the top navigation. You'll see a single field asking for your repository URL. Enter your GitHub repository URL:

https://github.com/sjakovic/text-toolkit

Click "Check" and Packagist will fetch your composer.json, validate it, and show you a preview. If everything looks good, click "Submit."

That's it. Your package is now live on Packagist. You'll see a page showing your package name, description, versions (based on your Git tags), and install statistics.

Common Submission Issues

If Packagist rejects your submission, it's usually one of these:

  • Package name already taken - Someone else already registered that vendor/package combination. Pick a different name.
  • No composer.json found - Make sure your composer.json is in the repository root, not in a subdirectory.
  • Invalid composer.json - Run composer validate locally to check for syntax errors.
  • Repository not accessible - Your repository must be public. Private repos require Packagist's paid "Private Packagist" service or Satis.

Before submitting, always validate your composer.json locally:

composer validate --strict

This catches issues like missing fields, invalid license identifiers, or autoload problems before Packagist does.

Setting Up Auto-Updates with GitHub Webhooks

By default, Packagist checks your repository periodically for new tags and updates. But "periodically" means there can be a delay between pushing a tag and Packagist picking it up. You want instant updates.

The solution is a GitHub webhook that notifies Packagist every time you push to your repository.

Setting It Up

  1. On Packagist, go to your profile and find your API token. You'll find this under your account settings.
  2. On GitHub, go to your repository's Settings > Webhooks > Add webhook.
  3. Configure the webhook:
    • Payload URL: https://packagist.org/api/github?username=YOUR_PACKAGIST_USERNAME
    • Content type: application/json
    • Secret: Your Packagist API token
    • Events: Select "Just the push event"
  4. Click "Add webhook."

Now, every time you push a new tag (or any commit), GitHub will ping Packagist, and your package listing updates within seconds.

You can verify the webhook is working by checking the "Recent Deliveries" tab on the webhook settings page. A green checkmark means Packagist received and processed the notification successfully.

If you signed in to Packagist with your GitHub account, there's an even easier option. Go to your Packagist profile page, and you'll see a button to automatically configure GitHub webhooks for all your packages. One click and you're done.

Testing Your Published Package

The moment of truth. Let's create a test project and install our package from Packagist, just like any other developer would.

# Create a test project somewhere separate
mkdir test-project
cd test-project
composer init --no-interaction --name="test/playground"

# Install your package!
composer require jakovic/text-toolkit

If everything is set up correctly, Composer will download your package and set up autoloading. You can verify it works by creating a quick test script:

<?php
// test-project/test.php

require_once 'vendor/autoload.php';

use Jakovic\TextToolkit\Slugify;

$slugify = new Slugify();
echo $slugify->handle('Publishing to Packagist!');
// Output: publishing-to-packagist
php test.php

If you see the slugified output, congratulations - your package is live and working. Anyone in the world can now install it.

Version Constraints in Composer

When someone installs your package, they specify a version constraint. Understanding these constraints is important both as a package maintainer and as a package consumer. Let's break down the most common ones.

Exact Version

"jakovic/text-toolkit": "1.0.0"

Installs exactly 1.0.0 and nothing else. You won't get bug fixes or new features automatically. This is rarely what you want - it's too restrictive.

Caret (^) - Recommended

"jakovic/text-toolkit": "^1.0"

This is the most common constraint and the one Composer uses by default when you run composer require. The caret means "compatible with this version" - it allows updates that don't break backward compatibility.

In practice:

  • ^1.0 allows >=1.0.0 and <2.0.0
  • ^1.2 allows >=1.2.0 and <2.0.0
  • ^1.2.3 allows >=1.2.3 and <2.0.0
  • ^0.3 allows >=0.3.0 and <0.4.0 (stricter for 0.x versions)

Notice the special behavior for 0.x versions - the caret is stricter because the API is considered unstable. ^0.3 only allows 0.3.x, not 0.4.0.

Tilde (~) - Next Significant Release

"jakovic/text-toolkit": "~1.2"

The tilde allows the last digit to go up. Think of it as "at least this version, up to but not including the next significant release."

  • ~1.2 allows >=1.2.0 and <2.0.0 (same as ^1.2 in this case)
  • ~1.2.3 allows >=1.2.3 and <1.3.0 (this is where it differs from caret)

The key difference: ~1.2.3 won't allow 1.3.0, but ^1.2.3 will. The tilde is more conservative when you specify all three version numbers. For most cases, the caret (^) is the better choice.

Wildcard (*)

"jakovic/text-toolkit": "1.0.*"

Matches any version starting with 1.0. So 1.0.0, 1.0.1, 1.0.99 - all fine. But 1.1.0? Nope. This is equivalent to >=1.0.0 <1.1.0.

Range Operators

You can also use comparison operators for full control:

"jakovic/text-toolkit": ">=1.0 <2.0"

This is explicit but verbose. The caret and tilde operators exist so you don't have to write these out.

Quick Reference

Here's a cheat sheet you can refer back to:

Constraint Meaning Example Range
---------- ------- -------------
1.0.0 Exact version 1.0.0 only
^1.0 Compatible with 1.0 >=1.0.0, <2.0.0
^1.2.3 Compatible with 1.2.3 >=1.2.3, <2.0.0
~1.2 Approximately 1.2 >=1.2.0, <2.0.0
~1.2.3 Approximately 1.2.3 >=1.2.3, <1.3.0
1.0.* Any 1.0.x release >=1.0.0, <1.1.0
>=1.0 <2.0 Range >=1.0.0, <2.0.0

The recommendation: use ^ (caret) for almost everything. It's what Composer defaults to, and it gives your users the right balance of stability and updates.

Pre-release Versions

Sometimes you want to release a version for testing before it's fully stable. Maybe you've rewritten a major component and want early feedback. That's what pre-release versions are for.

SemVer supports pre-release identifiers by appending a hyphen and label to the version number:

2.0.0-alpha.1 First alpha - very early, expect bugs
2.0.0-alpha.2 Second alpha - still rough
2.0.0-beta.1 First beta - feature complete, testing needed
2.0.0-beta.2 Second beta - more polishing
2.0.0-RC.1 Release Candidate 1 - should be final, last chance for bug reports
2.0.0-RC.2 Release Candidate 2 - fixed issues found in RC1
2.0.0 Stable release

The naming convention is:

  • Alpha - Early development. Features may be incomplete, APIs might change. "Use at your own risk."
  • Beta - Feature complete but not fully tested. APIs are mostly locked down. "Help us find bugs."
  • RC (Release Candidate) - Should be identical to the final release. Only critical bug fixes will be made. "Last call before we ship."

To create pre-release versions, just tag them like any other release:

git tag -a v2.0.0-alpha.1 -m "v2.0.0 Alpha 1 - New API preview"
git push origin v2.0.0-alpha.1

By default, Composer won't install pre-release versions. Users need to explicitly opt in. They can do this either by requiring the specific pre-release version:

composer require jakovic/text-toolkit:2.0.0-alpha.1

Or by setting the minimum stability in their project's composer.json:

{
 "minimum-stability": "beta",
 "prefer-stable": true
}

The prefer-stable flag is important here - it tells Composer "use stable versions when available, but allow beta versions if that's all there is." Without it, Composer might install beta versions of all your dependencies, which you definitely don't want.

Your Release Workflow

Now that you understand all the pieces, let's put together a practical release workflow. This is what you'll follow every time you want to publish a new version of your package.

1. Make Your Changes

Write your code, add tests, make sure everything passes:

composer test

2. Decide the Version Number

Ask yourself: did I break anything? Did I add something new? Or did I just fix a bug?

  • Bug fix only - bump PATCH (1.0.0 to 1.0.1)
  • New feature, nothing broken - bump MINOR (1.0.0 to 1.1.0)
  • Breaking change - bump MAJOR (1.0.0 to 2.0.0)

3. Commit and Tag

git add .
git commit -m "Add excerpt generator with configurable length"
git tag -a v1.1.0 -m "Add excerpt generator"
git push origin main --tags

4. Verify on Packagist

Go to your package page on Packagist and confirm the new version appears. If you set up the webhook, it should be there within seconds. If not, you can click the "Update" button on your package page to trigger a manual sync.

5. Test the Update

In a project that uses your package, run:

composer update jakovic/text-toolkit

Composer should pull in the new version. Verify it works as expected.

Using GitHub Releases (Bonus)

While Git tags are all Packagist needs, GitHub Releases add a nicer layer on top. They give you a dedicated page for each version with release notes, and they make it easy for users to see what changed.

You can create a release through the GitHub web interface (go to your repo, click "Releases," then "Draft a new release") or with the CLI:

gh release create v1.1.0 \
 --title "v1.1.0" \
 --notes "## What's New
- Added Excerpt class for generating text excerpts
- Excerpt supports configurable length and suffix

## Bug Fixes
- Fixed Slugify handling of consecutive spaces"

GitHub Releases are optional but recommended. They give your package a more professional appearance and make it easy for users to track changes between versions. We'll cover this in more detail in the next post when we talk about changelogs.

Common Mistakes to Avoid

Before we wrap up, here are the most common mistakes I see developers make when publishing packages:

  • Forgetting to tag. You push code to main but don't create a tag. Packagist has no new version to serve. Always tag your releases.
  • Committing vendor/. Your vendor/ directory should never be in your repository. It makes the package massive and causes conflicts.
  • Breaking changes in a minor version. You renamed a method and bumped from 1.2.0 to 1.3.0 instead of 2.0.0. Now everyone who has ^1.0 in their project gets broken code on their next composer update. This is the fastest way to lose users' trust.
  • No README. People find your package, see no documentation, and move on. Even a minimal README is better than nothing.
  • No license. Without a license file, your package is technically "all rights reserved." Nobody can legally use it. Always include a LICENSE file.
  • Tagging before tests pass. Always run your test suite before creating a tag. A broken release is worse than no release.

What's Next

Your package is published. People can install it. That's a huge milestone - seriously, give yourself a pat on the back. Most developers never get this far.

But publishing is just the beginning. In Part 6, we'll focus on package quality essentials - writing a proper README, maintaining a CHANGELOG, setting up code quality tools like PHPStan and Pint, and automating everything with GitHub Actions CI. These are the things that separate a hobby project from a professional package.

See you there.