Part 2 of 12 in the PHP Package Development series

In Part 1, we covered why building packages matters and what makes a good one. Now it's time to get our hands dirty. By the end of this post, you'll have a real, installable PHP package with proper autoloading, a clean directory structure, and your first working class.

We're building jakovic/text-toolkit - a text manipulation library. Nothing fancy, but it'll teach you every concept you need to build any package.

Creating the Project

Create a new directory for your package. Don't create it inside an existing project - packages live on their own.

mkdir text-toolkit
cd text-toolkit

Now initialize it with Composer. You can answer the interactive prompts, but I prefer using composer init with flags to skip the Q&A:

composer init \
 --name="jakovic/text-toolkit" \
 --description="A collection of text manipulation utilities for PHP" \
 --type="library" \
 --license="MIT" \
 --require="php:^8.1" \
 --autoload="src/" \
 --no-interaction

Replace jakovic with your own vendor name - usually your GitHub username or company name. This must be unique on Packagist, so choose wisely.

This generates a composer.json file. Open it and make sure it looks like this:

{
 "name": "jakovic/text-toolkit",
 "description": "A collection of text manipulation utilities for PHP",
 "type": "library",
 "license": "MIT",
 "require": {
 "php": "^8.1"
 },
 "autoload": {
 "psr-4": {
 "Jakovic\\TextToolkit\\": "src/"
 }
 },
 "authors": [
 {
 "name": "Your Name",
 "email": "you@example.com"
 }
 ]
}

The autoload section is the most important part. It tells Composer: "Any class in the Jakovic\TextToolkit namespace lives in the src/ directory." This is PSR-4 autoloading - the standard that virtually every modern PHP package uses.

Directory Structure

Let's create the full directory structure. Every well-organized package follows this layout:

mkdir -p src tests

Your project should now look like this:

text-toolkit/
 src/ # Your package code goes here
 tests/ # Your tests go here
 composer.json # Package metadata

That's it. No app/ folder, no config/, no public/. A package is not a Laravel project. Keep it minimal - you can always add more directories later as needed.

Understanding PSR-4 Autoloading

Before we write any code, let's make sure you really understand PSR-4 autoloading. This is the mechanism that lets PHP find your classes automatically without manual require statements.

The rule is simple: the namespace maps to the directory, and the class name maps to the file name.

Namespace: Jakovic\TextToolkit\
Directory: src/

So:
 Jakovic\TextToolkit\Slugifier -> src/Slugifier.php
 Jakovic\TextToolkit\Truncator -> src/Truncator.php
 Jakovic\TextToolkit\Utils\Helper -> src/Utils/Helper.php

Notice the pattern: everything after Jakovic\TextToolkit\ maps directly to the file path inside src/. Sub-namespaces become subdirectories. The class name becomes the file name with .php appended.

One important rule: the class name must exactly match the file name, including case. class Slugifier must be in Slugifier.php, not slugifier.php. This matters on case-sensitive filesystems like Linux.

Writing Your First Class

Let's build our first feature - a slugifier that converts text into URL-friendly slugs. Create src/Slugifier.php:

<?php

namespace Jakovic\TextToolkit;

class Slugifier
{
 private string $separator;

 public function __construct(string $separator = '-')
 {
 $this->separator = $separator;
 }

 /**
 * Convert a string to a URL-friendly slug.
 */
 public function slugify(string $text): string
 {
 // Convert to lowercase
 $text = mb_strtolower($text, 'UTF-8');

 // Replace non-alphanumeric characters with the separator
 $text = preg_replace('/[^\w\s' . preg_quote($this->separator, '/') . ']/u', '', $text);

 // Replace whitespace and repeated separators
 $text = preg_replace('/[\s' . preg_quote($this->separator, '/') . ']+/', $this->separator, $text);

 // Trim separators from the edges
 return trim($text, $this->separator);
 }
}

A few things to notice about this code:

  • The namespace matches our composer.json autoload config: Jakovic\TextToolkit
  • We use constructor injection for the separator, making the class configurable
  • We use mb_strtolower instead of strtolower for Unicode support
  • The method has a return type and a docblock - good package hygiene

Adding a Static Factory Method

Many popular packages offer both object-oriented and static APIs. Let's add a convenient static method so users can choose their preferred style:

<?php

namespace Jakovic\TextToolkit;

class Slugifier
{
 private string $separator;

 public function __construct(string $separator = '-')
 {
 $this->separator = $separator;
 }

 /**
 * Convert a string to a URL-friendly slug.
 */
 public function slugify(string $text): string
 {
 $text = mb_strtolower($text, 'UTF-8');
 $text = preg_replace('/[^\w\s' . preg_quote($this->separator, '/') . ']/u', '', $text);
 $text = preg_replace('/[\s' . preg_quote($this->separator, '/') . ']+/', $this->separator, $text);

 return trim($text, $this->separator);
 }

 /**
 * Static convenience method.
 */
 public static function make(string $text, string $separator = '-'): string
 {
 return (new self($separator))->slugify($text);
 }
}

Now users have two ways to use it:

use Jakovic\TextToolkit\Slugifier;

// Option 1: Static call (quick one-off)
$slug = Slugifier::make('Hello World!');
// "hello-world"

// Option 2: Instance (when you need to reuse config)
$slugifier = new Slugifier('_');
$slug = $slugifier->slugify('Hello World!');
// "hello_world"

This dual API pattern is used by Laravel's Str class, Carbon, and many other popular packages. It gives users flexibility without adding complexity.

Adding a Second Class

A package with one class isn't very useful. Let's add a truncator that intelligently shortens text without cutting words in half. Create src/Truncator.php:

<?php

namespace Jakovic\TextToolkit;

class Truncator
{
 /**
 * Truncate text to a given length without cutting words.
 */
 public static function truncate(string $text, int $limit = 100, string $end = '...'): string
 {
 if (mb_strlen($text) <= $limit) {
 return $text;
 }

 // Cut at the limit
 $truncated = mb_substr($text, 0, $limit);

 // Find the last space to avoid cutting a word
 $lastSpace = mb_strrpos($truncated, ' ');

 if ($lastSpace !== false) {
 $truncated = mb_substr($truncated, 0, $lastSpace);
 }

 return rtrim($truncated, '.,;:!? ') . $end;
 }

 /**
 * Truncate text to a given number of words.
 */
 public static function words(string $text, int $wordCount = 20, string $end = '...'): string
 {
 $words = preg_split('/\s+/', trim($text));

 if (count($words) <= $wordCount) {
 return $text;
 }

 return implode(' ', array_slice($words, 0, $wordCount)) . $end;
 }
}

Let's test it mentally:

use Jakovic\TextToolkit\Truncator;

$text = 'The quick brown fox jumps over the lazy dog and then goes home';

Truncator::truncate($text, 30);
// "The quick brown fox jumps..."

Truncator::words($text, 5);
// "The quick brown fox jumps..."

Notice that truncate() doesn't cut "jumps" in half at position 30 - it steps back to the last space. This is the kind of thoughtful detail that makes people choose your package over writing their own.

Creating a Facade Class

When your package has multiple classes, it's nice to offer a single entry point. Let's create a Str class that acts as a facade for all text operations. Create src/Str.php:

<?php

namespace Jakovic\TextToolkit;

class Str
{
 /**
 * Convert text to a URL-friendly slug.
 */
 public static function slugify(string $text, string $separator = '-'): string
 {
 return Slugifier::make($text, $separator);
 }

 /**
 * Truncate text to a character limit without cutting words.
 */
 public static function truncate(string $text, int $limit = 100, string $end = '...'): string
 {
 return Truncator::truncate($text, $limit, $end);
 }

 /**
 * Truncate text to a word count.
 */
 public static function words(string $text, int $wordCount = 20, string $end = '...'): string
 {
 return Truncator::words($text, $wordCount, $end);
 }
}

Now users can access everything through one clean import:

use Jakovic\TextToolkit\Str;

Str::slugify('Hello World!'); // "hello-world"
Str::truncate($longText, 50); // "The quick brown fox jumps over..."
Str::words($longText, 10); // First 10 words...

This is a common pattern in PHP packages. Laravel's Illuminate\Support\Str does exactly this. Users don't need to know about Slugifier or Truncator unless they want the full object-oriented API.

Essential Files

Every package needs a few non-PHP files. Let's create them.

.gitignore

This keeps dependencies and IDE files out of your repository:

/vendor/
composer.lock
.phpunit.result.cache
.DS_Store
.idea/
.vscode/

Notice that composer.lock is in .gitignore. This is intentional for libraries. Applications should commit their lock file, but packages should not - each project that uses your package will have its own lock file with the exact versions it resolved.

LICENSE

For open-source packages, MIT is the most common choice. It's permissive - anyone can use, modify, and distribute your code with minimal restrictions. Create a LICENSE file with the MIT license text and your name/year at the top.

You can generate one from GitHub when creating the repository, or grab the text from opensource.org/licenses/MIT.

README.md

We'll create a proper README later in the series, but for now create a basic one:

# Text Toolkit

A collection of text manipulation utilities for PHP.

## Installation

composer require jakovic/text-toolkit

## Usage

use Jakovic\TextToolkit\Str;

// Slugify text
Str::slugify('Hello World!'); // "hello-world"

// Truncate to character limit
Str::truncate('Long text here...', 50);

// Truncate to word count
Str::words('Long text here...', 10);

## License

MIT

Installing the Autoloader

Before we can test anything, Composer needs to generate the autoloader. Run:

composer dump-autoload

This creates a vendor/ directory with the autoloader files. Now let's verify everything works by creating a quick test script. Create test.php in the root of your project (we'll delete it later):

<?php

require __DIR__ . '/vendor/autoload.php';

use Jakovic\TextToolkit\Str;

// Test slugify
echo Str::slugify('Hello World! This is a Test') . PHP_EOL;
// Output: hello-world-this-is-a-test

// Test with custom separator
echo Str::slugify('Hello World!', '_') . PHP_EOL;
// Output: hello_world

// Test truncate
$text = 'The quick brown fox jumps over the lazy dog and then runs away';
echo Str::truncate($text, 30) . PHP_EOL;
// Output: The quick brown fox jumps...

// Test word truncation
echo Str::words($text, 6) . PHP_EOL;
// Output: The quick brown fox jumps over...

echo 'All tests passed!' . PHP_EOL;

Run it:

php test.php

If you see the output without errors, your autoloading is working correctly. Delete test.php when you're done - we'll set up proper testing in Part 4.

Testing with a Real Project

The quick test script is fine for checking autoloading, but let's see how your package actually feels when installed in a real project. This is something many package tutorials skip, but it's crucial - you want to experience your package the way your users will.

Create a separate test project somewhere else on your machine:

mkdir /tmp/test-project
cd /tmp/test-project
composer init --name="test/project" --no-interaction

Now add your package as a local dependency using a path repository. Edit the composer.json of your test project:

{
 "name": "test/project",
 "repositories": [
 {
 "type": "path",
 "url": "/path/to/your/text-toolkit"
 }
 ],
 "require": {
 "jakovic/text-toolkit": "@dev"
 }
}

Replace /path/to/your/text-toolkit with the actual path to your package directory. Then install:

composer install

Composer creates a symlink from vendor/jakovic/text-toolkit to your local package directory. Any changes you make in your package are immediately reflected in the test project - no need to reinstall.

This path repository technique is how you develop and test packages locally before publishing them. You'll use it throughout the development cycle.

Adding require-dev Dependencies

Your package will need development dependencies - tools that are only needed during development, not when someone installs your package. The most important one is a testing framework.

# Option 1: PHPUnit (the classic)
composer require --dev phpunit/phpunit

# Option 2: Pest (modern, expressive - built on PHPUnit)
composer require --dev pestphp/pest

We'll also add a code formatter to keep our style consistent:

composer require --dev laravel/pint

Your composer.json should now have a require-dev section:

{
 "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": "^3.0",
 "laravel/pint": "^1.0"
 },
 "autoload": {
 "psr-4": {
 "Jakovic\\TextToolkit\\": "src/"
 }
 },
 "autoload-dev": {
 "psr-4": {
 "Jakovic\\TextToolkit\\Tests\\": "tests/"
 }
 }
}

Notice we also added autoload-dev - this tells Composer how to find our test classes. The dev autoloader is only loaded during development, not when someone installs your package in their project.

The key difference: require dependencies get installed when someone runs composer require jakovic/text-toolkit. require-dev dependencies do not. Keep your require section as lean as possible - every dependency you add is a dependency your users must also install.

Adding Composer Scripts

Composer scripts are shortcuts for common commands. Add these to your composer.json:

{
 "scripts": {
 "test": "pest",
 "format": "pint",
 "check": [
 "@test",
 "@format"
 ]
 }
}

Now you can run:

composer test # Run tests
composer format # Format code
composer check # Run both

This keeps your workflow consistent and makes it easy for contributors to run the same commands you do.

Final Directory Structure

Here's what your package should look like now:

text-toolkit/
 src/
 Slugifier.php
 Truncator.php
 Str.php
 tests/
 vendor/ (git-ignored)
 .gitignore
 composer.json
 LICENSE
 README.md

Three classes, clean autoloading, development tools configured, and a dual API (static + OOP). That's a solid foundation for any PHP package.

Initialize Git

Before we wrap up, let's put this under version control:

git init
git add .
git commit -m "Initial package setup with Slugifier and Truncator"

Don't push to GitHub yet - we'll do that in Part 5 when we publish to Packagist. For now, local Git is enough.

What's Next

In Part 3: Writing Your First Package Code, we'll add more features to our text toolkit - an excerpt generator and a Markdown-to-text converter. We'll focus on writing clean, well-documented code with proper error handling and edge case coverage. The kind of code that makes people trust your package.

Your homework: try building the Slugifier and Truncator classes from this post. Change the separator logic, add Unicode handling, or extend the truncation to handle HTML tags. Getting your hands on the code is the fastest way to learn.

See you in Part 3!