Part 6 of 12 in the PHP Package Development series
Your package works. It has tests. But would you actually install it if you found it on Packagist? Probably not - because right now it's missing everything that signals "this is a well-maintained, trustworthy package." A bare repo with code and nothing else screams "weekend experiment."
In this post, we're going to add every quality signal that separates professional packages from abandoned side projects: a proper README, a license, a changelog, static analysis, code style enforcement, and a CI pipeline that checks everything automatically on every push.
We'll keep working with our jakovic/text-toolkit package from previous posts.
Writing a Great README
Your README is the front door of your package. It's the first thing people see on GitHub and Packagist. Most developers decide within 30 seconds whether they'll try your package or move on. A great README answers three questions immediately: What does it do? How do I install it? How do I use it?
Here's a complete README template for our text-toolkit package. Create a README.md file in your project root:
# Text Toolkit
[](https://packagist.org/packages/jakovic/text-toolkit)
[](https://github.com/sjakovic/text-toolkit/actions/workflows/tests.yml)
[](https://github.com/sjakovic/text-toolkit/actions/workflows/tests.yml)
[](https://packagist.org/packages/jakovic/text-toolkit)
A collection of text manipulation utilities for PHP. Transform strings, generate slugs, analyze text, and more.
## Installation
You can install the package via Composer:
```bash
composer require jakovic/text-toolkit
```
## Usage
### String Transformer
```php
use Jakovic\TextToolkit\StringTransformer;
$transformer = new StringTransformer();
// Generate a slug
echo $transformer->toSlug('Hello World!'); // "hello-world"
// Convert to title case
echo $transformer->toTitleCase('the quick brown fox'); // "The Quick Brown Fox"
// Truncate with ellipsis
echo $transformer->truncate('This is a long sentence', 10); // "This is a..."
```
### Text Analyzer
```php
use Jakovic\TextToolkit\TextAnalyzer;
$analyzer = new TextAnalyzer();
$stats = $analyzer->analyze('The quick brown fox jumps over the lazy dog');
echo $stats->wordCount; // 9
echo $stats->characterCount; // 43
echo $stats->readingTime; // "1 min read"
```
## API Reference
### StringTransformer
| Method | Description | Return Type |
|--------|-------------|-------------|
| `toSlug(string $text)` | Converts text to a URL-friendly slug | `string` |
| `toTitleCase(string $text)` | Converts text to title case | `string` |
| `truncate(string $text, int $length)` | Truncates text with ellipsis | `string` |
| `toCamelCase(string $text)` | Converts text to camelCase | `string` |
### TextAnalyzer
| Method | Description | Return Type |
|--------|-------------|-------------|
| `analyze(string $text)` | Returns full text statistics | `TextStats` |
| `wordCount(string $text)` | Counts words in text | `int` |
| `readingTime(string $text)` | Estimates reading time | `string` |
## Testing
```bash
composer test
```
## Changelog
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
## Contributing
Please see [CONTRIBUTING](CONTRIBUTING.md) for details.
## License
The MIT License (MIT). Please see [License File](LICENSE) for more information.
Let's break down what makes this README effective.
Badges at the Top
Badges are those little status icons you see on popular packages. They instantly communicate key information: the current version, whether tests are passing, how many downloads the package has. We'll set up the CI workflows later in this post so these badges actually work.
The most common badges for PHP packages are:
- Packagist version - shows the latest stable release
- CI/Tests status - shows if the test suite is passing
- Total downloads - social proof that people actually use it
- License - lets people know at a glance if they can use it
- PHPStan level - shows your static analysis strictness
Installation and Usage First
Don't bury the installation instructions. They should be right after the description. Developers are impatient - they want to know how to install it and see a working code example within the first scroll. The usage section should show real, copy-pasteable examples that actually work. Don't show abstract pseudo-code.
API Reference
For simple packages, a table of methods with descriptions is enough. For more complex packages, you might want a dedicated docs site (we'll cover that in a later post). The key is that every public method should be documented somewhere. If someone has to read your source code to figure out how to use your package, your documentation has failed.
Choosing a License
No license means no one can legally use your code. Seriously. Without a license, copyright law defaults to "all rights reserved," which means other developers technically can't use, modify, or distribute your package. Every open source package needs a license file.
Here are the three most common choices for PHP packages:
MIT License (Recommended for Most Packages)
The MIT license is the most popular choice in the PHP ecosystem. Laravel, Symfony, and most packages on Packagist use it. It basically says: "Do whatever you want with this code, just include the copyright notice. I'm not liable for anything."
Create a LICENSE file in your project root:
MIT License
Copyright (c) 2025 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.
Apache 2.0
Similar to MIT but includes an explicit patent grant. If your package implements something that could be patent-related, Apache 2.0 gives users extra legal protection. It's used by many Google and Apache Foundation projects.
GPL (v2 or v3)
The GPL is a "copyleft" license - anyone who uses your code in their project must also open-source their project under the GPL. This is great for some projects (WordPress core uses GPLv2), but it limits adoption for packages. Many companies won't use GPL-licensed packages because it could force them to open-source their proprietary code.
My recommendation: Use MIT unless you have a specific reason not to. It maximizes adoption and is the community standard for PHP packages.
Make sure your composer.json includes the license field:
{
"license": "MIT"
}
Keeping a CHANGELOG
A changelog tells your users what changed between versions. Without one, they have to dig through commits to figure out if upgrading is safe or what new features are available. That's a terrible experience.
The best format is Keep a Changelog. It's human-readable, widely adopted, and has clear categories for different types of changes.
Create a CHANGELOG.md file:
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.1.0] - 2025-03-15
### Added
- `TextAnalyzer::readingTime()` method for estimating reading time
- Support for PHP 8.4
### Changed
- `StringTransformer::toSlug()` now handles Unicode characters
### Deprecated
- `StringTransformer::slugify()` - use `toSlug()` instead
## [1.0.1] - 2025-02-20
### Fixed
- Fixed `truncate()` breaking on multi-byte characters
## [1.0.0] - 2025-01-15
### Added
- Initial release
- `StringTransformer` class with `toSlug()`, `toTitleCase()`, `truncate()`, and `toCamelCase()` methods
- `TextAnalyzer` class with `analyze()`, `wordCount()`, and `readingTime()` methods
The categories in Keep a Changelog are:
- Added - for new features
- Changed - for changes in existing functionality
- Deprecated - for features that will be removed soon
- Removed - for features that were removed
- Fixed - for bug fixes
- Security - for security-related changes
A few tips for maintaining your changelog:
- Update it with every PR, not just at release time. It's much easier to write changelog entries while the changes are fresh in your mind.
- Use the
[Unreleased]section at the top for changes that haven't been tagged yet. When you cut a release, just move those entries under the new version heading. - Write entries from the user's perspective. "Added support for custom delimiters in toSlug()" is better than "Refactored slug generation internals."
Static Analysis with PHPStan
Tests tell you if your code works correctly. Static analysis tells you if your code is correct - before you even run it. PHPStan reads your code and finds bugs without executing anything: type mismatches, undefined methods, dead code, impossible conditions, and more.
If you're not using static analysis yet, you'll be surprised how many bugs it catches. Things that would only surface as runtime errors in production get caught immediately.
Installing PHPStan
composer require --dev phpstan/phpstan
Configuration
Create a phpstan.neon file in your project root:
parameters:
level: 8
paths:
- src
tmpDir: build/phpstan
That's it. Three lines of configuration. Let's break it down:
- level - PHPStan has 10 levels (0-9). Level 0 catches basic errors like calling undefined methods. Level 9 is the strictest, catching things like mixed types and missing type hints on every parameter and return value.
- paths - which directories to analyze. You want to analyze
srcfor sure. You can optionally addtestsas well. - tmpDir - where PHPStan caches its analysis results. Add
build/to your.gitignore.
Understanding PHPStan Levels
Here's a quick overview of what each level adds:
- Level 0 - basic checks, unknown classes, unknown functions, wrong argument count
- Level 1 - possibly undefined variables, unknown magic methods
- Level 2 - unknown methods on all expressions (not just variables)
- Level 3 - return types, parameter types
- Level 4 - basic dead code checking
- Level 5 - checking types of arguments passed to methods and functions
- Level 6 - strict checking of missing typehints
- Level 7 - checking union types, partial checks for partially wrong union types
- Level 8 - report calls to methods on nullable types
- Level 9 - strict about the
mixedtype, everything must be explicitly typed
My recommendation: Start at level 5 or 6 for existing projects. For new packages like ours, go straight to level 8 or 9. It's much easier to write strict code from the start than to retrofit it later. Level 8 is the sweet spot for most packages - strict enough to catch real bugs without being annoyingly pedantic.
Running PHPStan
vendor/bin/phpstan analyse
If everything is clean, you'll see:
[OK] No errors
If there are issues, PHPStan gives you clear error messages with file names and line numbers:
------ ----------------------------------------------------------------
Line src/StringTransformer.php
------ ----------------------------------------------------------------
25 Method Jakovic\TextToolkit\StringTransformer::truncate() should
return string but returns string|null.
------ ----------------------------------------------------------------
Add a Composer script so you can run it easily:
{
"scripts": {
"test": "vendor/bin/pest",
"analyse": "vendor/bin/phpstan analyse",
"quality": [
"@analyse",
"@test"
]
}
}
Now composer analyse runs PHPStan, and composer quality runs both static analysis and tests in one command.
Code Style with Laravel Pint
Consistent code style isn't about tabs vs. spaces debates. It's about removing friction for contributors and keeping your codebase readable. When every file follows the same style, code reviews can focus on logic instead of formatting.
Laravel Pint is a zero-configuration code style fixer built on top of PHP CS Fixer. It's simpler to set up than PHP CS Fixer directly, and comes with sensible defaults that match the Laravel ecosystem's style conventions.
Installing Pint
composer require --dev laravel/pint
Configuration
Pint works out of the box with zero configuration if you're happy with the Laravel style preset. But if you want to customize it, create a pint.json file:
{
"preset": "laravel",
"rules": {
"concat_space": {
"spacing": "one"
},
"ordered_imports": {
"sort_algorithm": "alpha"
},
"single_trait_insert_per_statement": true
}
}
The three presets available are:
- laravel - follows Laravel's coding style (default)
- psr12 - follows PSR-12 standard strictly
- symfony - follows Symfony's coding style
For most PHP packages, the laravel preset is a solid choice. It's a superset of PSR-12 with some additional conventions that make code cleaner.
Running Pint
To check for style issues without fixing them (good for CI):
vendor/bin/pint --test
To automatically fix everything:
vendor/bin/pint
Pint will show you exactly what it changed:
FIXED src/StringTransformer.php
- concat_space
- ordered_imports
FIXED src/TextAnalyzer.php
- trailing_comma_in_multiline
2 files fixed out of 5 analyzed.
Add it to your Composer scripts:
{
"scripts": {
"test": "vendor/bin/pest",
"analyse": "vendor/bin/phpstan analyse",
"format": "vendor/bin/pint",
"format:check": "vendor/bin/pint --test",
"quality": [
"@analyse",
"@format:check",
"@test"
]
}
}
Now your composer quality command runs all three checks: static analysis, code style, and tests.
What About PHP CS Fixer Directly?
If you're not in the Laravel ecosystem, you can use PHP CS Fixer directly. It's more configurable but requires more setup. Pint is essentially a wrapper around it with better defaults. For most packages, Pint gives you everything you need with less configuration.
GitHub Actions CI Pipeline
Now let's wire everything together with GitHub Actions. A CI pipeline runs your tests, static analysis, and code style checks on every push and pull request. If anything fails, you'll know immediately - before bad code gets merged.
Create the file .github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
name: PHP ${{ matrix.php }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run tests
run: vendor/bin/pest
static-analysis:
runs-on: ubuntu-latest
name: PHPStan
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run PHPStan
run: vendor/bin/phpstan analyse
code-style:
runs-on: ubuntu-latest
name: Code Style
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run Pint
run: vendor/bin/pint --test
Let's walk through what this does.
Three Separate Jobs
We run three independent jobs that execute in parallel:
- Tests - runs the test suite across PHP 8.1, 8.2, 8.3, and 8.4. This is a matrix strategy - GitHub Actions spins up a separate runner for each PHP version and runs them simultaneously.
- Static Analysis - runs PHPStan on a single PHP version. You don't need to run static analysis on multiple versions because PHPStan analyzes code structure, not runtime behavior.
- Code Style - runs Pint in test mode to check for formatting issues. Again, only needs one PHP version.
The Matrix Strategy
The test job uses a matrix to test against multiple PHP versions. The fail-fast: true setting means if tests fail on any PHP version, all other running jobs are cancelled immediately. This saves CI minutes. If you'd rather see which specific versions fail, set it to false.
Notice we test against PHP 8.1 through 8.4. Your composer.json says "php": "^8.1", so you should test every version your package claims to support. When PHP 8.5 comes out, add it to the matrix.
The shivammathur/setup-php Action
This is the standard action for setting up PHP in GitHub Actions. It lets you specify the PHP version, extensions to install, and coverage driver. We set coverage: none because we're not collecting code coverage in this workflow (we'll cover that in a future post).
Dependency Caching
You might notice we're not explicitly caching Composer dependencies. The shivammathur/setup-php action actually sets up Composer caching automatically. If you want to be explicit about it, you can add a caching step:
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-${{ matrix.php }}-
This caches the vendor directory based on your composer.lock file. If the lock file hasn't changed, it skips the install step entirely. This can cut your CI time significantly on larger packages.
Adding Badges to Your README
Now that your CI pipeline is set up, let's make those README badges actually work. Here are the badge URLs you'll want:
GitHub Actions Status Badge
[](https://github.com/YOUR-USERNAME/text-toolkit/actions/workflows/tests.yml)
You can also get this from GitHub directly. Go to your repository, click "Actions", select the workflow, then click the "..." menu and select "Create status badge."
Packagist Badges
<!-- Latest version -->
[](https://packagist.org/packages/jakovic/text-toolkit)
<!-- Total downloads -->
[](https://packagist.org/packages/jakovic/text-toolkit)
<!-- License -->
[](https://packagist.org/packages/jakovic/text-toolkit)
<!-- Required PHP version -->
[](https://packagist.org/packages/jakovic/text-toolkit)
These badges are powered by Shields.io and update automatically when you publish new versions to Packagist.
Custom PHPStan Level Badge
There's no automatic PHPStan badge, but you can create a static one using Shields.io:
[](https://phpstan.org/)
Just remember to update this badge manually if you change your PHPStan level.
Putting It All Together
Let's look at the complete file structure of our package with all quality essentials in place:
text-toolkit/
├── .github/
│ └── workflows/
│ └── tests.yml
├── src/
│ ├── StringTransformer.php
│ └── TextAnalyzer.php
├── tests/
│ ├── StringTransformerTest.php
│ └── TextAnalyzerTest.php
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── composer.json
├── phpstan.neon
├── phpunit.xml (or pest config)
└── pint.json
And your final composer.json should look something like this:
{
"name": "jakovic/text-toolkit",
"description": "A collection of text manipulation utilities for PHP",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.1"
},
"require-dev": {
"pestphp/pest": "^2.0",
"phpstan/phpstan": "^1.0",
"laravel/pint": "^1.0"
},
"autoload": {
"psr-4": {
"Jakovic\\TextToolkit\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Jakovic\\TextToolkit\\Tests\\": "tests/"
}
},
"scripts": {
"test": "vendor/bin/pest",
"analyse": "vendor/bin/phpstan analyse",
"format": "vendor/bin/pint",
"format:check": "vendor/bin/pint --test",
"quality": [
"@analyse",
"@format:check",
"@test"
]
},
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Don't forget to update your .gitignore:
/vendor/
/build/
composer.lock
.phpunit.cache
We include composer.lock in the gitignore because this is a library, not an application. Libraries should let the consuming application resolve dependency versions. If you shipped your lock file, it would be ignored by Composer anyway when someone installs your package.
Your Quality Checklist
Before you consider your package ready for public use, make sure you have:
- A README with badges, install instructions, usage examples, and API reference
- A LICENSE file (MIT recommended)
- A CHANGELOG following the Keep a Changelog format
- PHPStan configured at level 8+ with zero errors
- Laravel Pint (or PHP CS Fixer) for consistent code style
- A GitHub Actions pipeline that tests across all supported PHP versions
- Composer scripts for running everything locally
These aren't optional nice-to-haves. They're the baseline that the PHP community expects from any serious package. When someone evaluates your package, they're looking for these signals. A package with passing CI, clean static analysis, and thorough documentation says "I care about this code, and I'll maintain it."
What's Next
Your package now has the full quality treatment - documentation, licensing, a changelog, static analysis, code style enforcement, and automated CI. In the next post, we'll cover how to actually publish your package to Packagist and set up proper versioning with Git tags. Your code is ready - it's time to share it with the world.