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

[![Latest Version on Packagist](https://img.shields.io/packagist/v/jakovic/text-toolkit.svg)](https://packagist.org/packages/jakovic/text-toolkit)
[![Tests](https://github.com/sjakovic/text-toolkit/actions/workflows/tests.yml/badge.svg)](https://github.com/sjakovic/text-toolkit/actions/workflows/tests.yml)
[![PHPStan](https://github.com/sjakovic/text-toolkit/actions/workflows/tests.yml/badge.svg)](https://github.com/sjakovic/text-toolkit/actions/workflows/tests.yml)
[![Total Downloads](https://img.shields.io/packagist/dt/jakovic/text-toolkit.svg)](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 src for sure. You can optionally add tests as 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 mixed type, 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:

  1. 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.
  2. 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.
  3. 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

[![Tests](https://github.com/YOUR-USERNAME/text-toolkit/actions/workflows/tests.yml/badge.svg)](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 -->
[![Latest Version on Packagist](https://img.shields.io/packagist/v/jakovic/text-toolkit.svg)](https://packagist.org/packages/jakovic/text-toolkit)

<!-- Total downloads -->
[![Total Downloads](https://img.shields.io/packagist/dt/jakovic/text-toolkit.svg)](https://packagist.org/packages/jakovic/text-toolkit)

<!-- License -->
[![License](https://img.shields.io/packagist/l/jakovic/text-toolkit.svg)](https://packagist.org/packages/jakovic/text-toolkit)

<!-- Required PHP version -->
[![PHP Version](https://img.shields.io/packagist/php-v/jakovic/text-toolkit.svg)](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:

[![PHPStan Level](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg)](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.