Publishing a package is one of the best ways to give back to the PHP community and sharpen your skills as a developer. Whether it's a small utility you keep copying between projects or a more ambitious library, extracting it into a standalone Composer package forces you to think about clean interfaces, documentation, and testing. The good news is that the process is far simpler than most developers think. Let's walk through every step, from an empty directory to a published package on Packagist.
Setting Up the Directory Structure
The PHP ecosystem has settled on a conventional layout that Composer, PHPUnit, and most CI tools expect. Sticking to it means less configuration and fewer surprises for anyone who installs your package.
acme-slug-generator/
├── src/
│ └── SlugGenerator.php
├── tests/
│ └── SlugGeneratorTest.php
├── .gitignore
├── composer.json
├── LICENSE
├── phpunit.xml
└── README.md
The src/ directory holds your production classes. tests/ mirrors it for test classes. Everything else lives at the project root. This separation keeps autoloading clean — your consumers only load src/, while your dev dependencies and tests stay out of their dependency tree. Start by creating the directory and initializing Git:
mkdir acme-slug-generator
cd acme-slug-generator
git init
mkdir src tests
Add a .gitignore that excludes the vendor/ directory and composer.lock. Libraries should not commit their lock file — that's reserved for applications. You want consumers to resolve dependencies according to their own constraints, not yours.
Writing Your composer.json
The composer.json file is the heart of your package. It tells Composer how to install it, where to find your classes, and what it depends on. Here's a complete example for our hypothetical slug generator:
{
"name": "acme/slug-generator",
"description": "A lightweight, configurable slug generator for PHP strings.",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Acme\\SlugGenerator\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Acme\\SlugGenerator\\Tests\\": "tests/"
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Let's break down the key fields. name follows the vendor/package convention and must be lowercase. type should be library for most packages. The autoload block maps the Acme\SlugGenerator\ namespace to the src/ directory using PSR-4, while autoload-dev does the same for tests — but only when the package is installed as a root project, not as a dependency. Keep your require block as lean as possible. Every dependency you add is a dependency your consumers inherit.
Writing Your First Class
With the namespace mapping in place, any class inside src/ will be autoloaded as long as the file name matches the class name and the namespace matches the PSR-4 prefix. Here's a simple but useful slug generator:
<?php
namespace Acme\SlugGenerator;
class SlugGenerator
{
public function __construct(
private string $separator = '-',
private bool $lowercase = true,
) {}
public function generate(string $text): string
{
$slug = preg_replace('/[^\w\s-]/u', '', $text);
$slug = preg_replace('/[\s_]+/', $this->separator, trim($slug));
$slug = preg_replace('/-+/', $this->separator, $slug);
return $this->lowercase ? mb_strtolower($slug) : $slug;
}
}
This file lives at src/SlugGenerator.php. The namespace Acme\SlugGenerator maps directly to the src/ directory as declared in composer.json. If you added a subdirectory like src/Support/, classes in there would use the namespace Acme\SlugGenerator\Support. Constructor property promotion keeps the configuration clean, and the class does one thing well — a principle that makes packages easy to test and easy to use.
Adding PHPUnit Tests
No package should ship without tests. Start by creating a phpunit.xml configuration at the project root:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Tests">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
Now write a test class at tests/SlugGeneratorTest.php that covers meaningful scenarios:
<?php
namespace Acme\SlugGenerator\Tests;
use Acme\SlugGenerator\SlugGenerator;
use PHPUnit\Framework\TestCase;
class SlugGeneratorTest extends TestCase
{
public function test_it_generates_a_basic_slug(): void
{
$generator = new SlugGenerator();
$this->assertSame('hello-world', $generator->generate('Hello World'));
}
public function test_it_strips_special_characters(): void
{
$generator = new SlugGenerator();
$this->assertSame('hello-world', $generator->generate('Hello, World!'));
}
public function test_it_collapses_multiple_separators(): void
{
$generator = new SlugGenerator();
$this->assertSame('hello-world', $generator->generate('Hello World'));
}
public function test_it_supports_a_custom_separator(): void
{
$generator = new SlugGenerator(separator: '_');
$this->assertSame('hello_world', $generator->generate('Hello World'));
}
}
Install your dev dependencies and run the suite:
composer install
vendor/bin/phpunit
Four passing tests give you a solid baseline. As a rule, every public method should have at least a happy-path test and a test for edge cases. If someone submits a bug report later, the first thing you do is write a failing test before touching the implementation.
Continuous Integration with GitHub Actions
Automated testing on every push catches regressions before they reach your users. Create a workflow file at .github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3', '8.4']
name: PHP ${{ matrix.php }}
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run tests
run: vendor/bin/phpunit
The strategy.matrix block runs your test suite against PHP 8.2, 8.3, and 8.4 in parallel. This is critical for library maintainers — your users are on different PHP versions, and you need to know immediately if something breaks on any of them. The shivammathur/setup-php action handles PHP installation and extension management, and it's the de facto standard for PHP CI on GitHub.
Publishing to Packagist
With your code tested and pushed to a public GitHub repository, publishing to Packagist is straightforward:
First, tag a release. Packagist uses Git tags to determine available versions. Tag your first stable release and push it:
git tag v1.0.0
git push origin v1.0.0
Second, submit your package. Go to packagist.org, sign in with your GitHub account, and click "Submit." Paste your repository URL and Packagist will read your composer.json to index the package.
Third, set up auto-updates. In your GitHub repository settings, add the Packagist integration under Settings > Webhooks (or use the Packagist GitHub integration). This ensures Packagist re-indexes your package automatically whenever you push new tags or commits. Without this, you'd have to manually click "Update" on Packagist every time you release.
Once published, anyone can install your package with a single command:
composer require acme/slug-generator
Semantic Versioning Done Right
Semantic versioning — MAJOR.MINOR.PATCH — is a contract between you and your users. It tells them how safe it is to upgrade. Getting it right is one of the most important things you can do as a package maintainer.
PATCH (e.g. 1.0.0 to 1.0.1): bug fixes only. No new features, no changed behavior. Your users should be able to upgrade without reading the changelog.
MINOR (e.g. 1.0.0 to 1.1.0): new features that are fully backward compatible. You can add new methods, new classes, new optional parameters — anything that doesn't break existing usage. Users can upgrade safely, but they might want to check what's new.
MAJOR (e.g. 1.0.0 to 2.0.0): breaking changes. Renamed methods, removed parameters, changed return types, altered behavior — anything that could cause existing code to fail. This is the only version bump that tells users "you will need to update your code."
The most common mistake new maintainers make is underestimating what counts as a breaking change. Renaming a method is obviously breaking, but so are more subtle changes: making a previously optional parameter required, changing the type of a return value, or even throwing a new exception type from a method that previously returned null. When in doubt, bump major. Your users will thank you for being cautious rather than surprising them with a broken build on a Tuesday morning.
A practical tip: keep an UPGRADE.md file in your repository. Every time you tag a major release, document exactly what changed and how users should update their code. Pair it with a CHANGELOG.md that covers all versions, and your users will never feel lost during an upgrade.
Your first package doesn't need to be revolutionary. Find something useful you keep copying between projects — a string helper, a validation rule, a data transformer — extract it into a clean class, give it a solid test suite, and ship it. The process of maintaining even a small package will teach you more about API design, backward compatibility, and developer experience than any tutorial. The PHP ecosystem gets better one small package at a time.