Part 7a of 13 in the PHP Package Development series

Your package works. It has tests, it's on Packagist, and it follows quality standards. But right now, it does exactly what you built it to do - nothing more, nothing less. What happens when a user needs a transformer you didn't anticipate? Or wants to swap out your slugification logic for their own? Or needs to hook into the transformation pipeline?

This is where advanced patterns come in. The difference between a package that gets used and a package that gets loved is extensibility. In this post, we'll take our jakovic/text-toolkit package and make it seriously flexible using proven design patterns. We'll cover the Strategy pattern, the Driver/Adapter pattern, configuration objects, middleware pipelines, and event-driven hooks - all practical, all with real code.

Why Patterns Matter for Packages

When you build an application, you control every line of code. If something needs to change, you change it. But a package is different. You ship it, and other developers use it in ways you never imagined. You can't anticipate every use case, so instead you build extension points - places where users can plug in their own logic without modifying your source code.

Design patterns give you a shared vocabulary for these extension points. When a developer sees your package uses the Strategy pattern, they immediately now how to extend it. No guessing, no reading through source code trying to figure out where to hook in.

Let's start with the most fundamental pattern for packages.

The Strategy Pattern - Swappable Algorithms

The Strategy pattern lets you define a family of algorithms, encapsulate each one, and make them interchangeable. For our text toolkit, think about slugification. There are multiple valid ways to generate a slug - you might transliterate Unicode characters, strip them entirely, or use a language-specific mapping.

First, define the contract:

<?php

namespace Jakovic\TextToolkit\Contracts;

interface SlugStrategy
{
 /**
 * Convert a string into a URL-friendly slug.
 */
 public function slugify(string $text, string $separator = '-'): string;
}

Now create multiple implementations:

<?php

namespace Jakovic\TextToolkit\Slug;

use Jakovic\TextToolkit\Contracts\SlugStrategy;

class AsciiSlugStrategy implements SlugStrategy
{
 public function slugify(string $text, string $separator = '-'): string
 {
 // Transliterate Unicode to ASCII
 $text = transliterator_transliterate(
 'Any-Latin; Latin-ASCII; Lower()',
 $text
 );
 $text = preg_replace('/[^a-z0-9\s' . preg_quote($separator) . ']/', '', $text);
 $text = preg_replace('/[\s]+/', $separator, trim($text));

 return $text;
 }
}

class UnicodeSlugStrategy implements SlugStrategy
{
 public function slugify(string $text, string $separator = '-'): string
 {
 // Keep Unicode characters, just normalize whitespace and lowercase
 $text = mb_strtolower(trim($text));
 $text = preg_replace('/[\s]+/u', $separator, $text);
 $text = preg_replace('/[^\p{L}\p{N}' . preg_quote($separator) . ']+/u', '', $text);

 return $text;
 }
}

Now the Slugifier class accepts any strategy:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\SlugStrategy;
use Jakovic\TextToolkit\Slug\AsciiSlugStrategy;

class Slugifier
{
 private SlugStrategy $strategy;

 public function __construct(?SlugStrategy $strategy = null)
 {
 $this->strategy = $strategy ?? new AsciiSlugStrategy();
 }

 public function setStrategy(SlugStrategy $strategy): self
 {
 $this->strategy = $strategy;

 return $this;
 }

 public function slugify(string $text, string $separator = '-'): string
 {
 return $this->strategy->slugify($text, $separator);
 }
}

Usage is clean and intuitive:

use Jakovic\TextToolkit\Slugifier;
use Jakovic\TextToolkit\Slug\UnicodeSlugStrategy;

// Default ASCII strategy
$slugifier = new Slugifier();
$slugifier->slugify('Cafe Resume'); // "cafe-resume"

// Unicode strategy - preserves characters like e with accent
$slugifier = new Slugifier(new UnicodeSlugStrategy());
$slugifier->slugify('Cafe Resume'); // keeps unicode chars intact

// Users can create their own strategy
class GermanSlugStrategy implements \Jakovic\TextToolkit\Contracts\SlugStrategy
{
 public function slugify(string $text, string $separator = '-'): string
 {
 $replacements = ['ae' => 'ae', 'oe' => 'oe', 'ue' => 'ue', 'ss' => 'ss'];
 // ... German-specific transliteration
 }
}

The key takeaway: provide a sensible default, but let users swap the algorithm without touching your code. This is the foundation of extensible package design.

The Driver/Adapter Pattern - Multiple Backends

The Driver pattern (sometimes called the Adapter pattern) is the Strategy pattern's bigger sibling. While Strategy swaps algorithms, the Driver pattern manages multiple named implementations and lets you switch between them dynamically. You've seen this in Laravel's filesystem (local, s3, ftp), cache (file, redis, memcached), and mail (smtp, mailgun, ses) systems.

Let's add a Driver-based text transformation system to our package. The idea: users can register different "transformers" and resolve them by name.

First, the transformer interface:

<?php

namespace Jakovic\TextToolkit\Contracts;

interface TextTransformer
{
 /**
 * Transform the given text.
 */
 public function transform(string $text): string;

 /**
 * Get the unique name of this transformer.
 */
 public function getName(): string;
}

Some built-in transformers:

<?php

namespace Jakovic\TextToolkit\Transformers;

use Jakovic\TextToolkit\Contracts\TextTransformer;

class SlugTransformer implements TextTransformer
{
 public function transform(string $text): string
 {
 $text = mb_strtolower(trim($text));
 $text = preg_replace('/[^a-z0-9\s-]/', '', $text);

 return preg_replace('/[\s-]+/', '-', $text);
 }

 public function getName(): string
 {
 return 'slug';
 }
}

class ExcerptTransformer implements TextTransformer
{
 public function __construct(
 private int $length = 160,
 private string $suffix = '...'
 ) {}

 public function transform(string $text): string
 {
 $text = strip_tags($text);
 if (mb_strlen($text) <= $this->length) {
 return $text;
 }

 $truncated = mb_substr($text, 0, $this->length);
 $lastSpace = mb_strrpos($truncated, ' ');

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

 return $truncated . $this->suffix;
 }

 public function getName(): string
 {
 return 'excerpt';
 }
}

class MarkdownStripTransformer implements TextTransformer
{
 public function transform(string $text): string
 {
 // Remove headings
 $text = preg_replace('/^#{1,6}\s+/m', '', $text);
 // Remove bold/italic
 $text = preg_replace('/\*{1,3}(.+?)\*{1,3}/', '$1', $text);
 // Remove links, keep text
 $text = preg_replace('/\[(.+?)\]\(.+?\)/', '$1', $text);
 // Remove images
 $text = preg_replace('/!\[.*?\]\(.+?\)/', '', $text);
 // Remove code blocks
 $text = preg_replace('/```[\s\S]*?```/', '', $text);
 // Remove inline code
 $text = preg_replace('/`(.+?)`/', '$1', $text);

 return trim($text);
 }

 public function getName(): string
 {
 return 'markdown-strip';
 }
}

Now the TransformerManager - the heart of the driver pattern:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\TextTransformer;
use InvalidArgumentException;

class TransformerManager
{
 /** @var array<string, TextTransformer> */
 private array $transformers = [];

 /** @var array<string, callable> */
 private array $factories = [];

 /**
 * Register a transformer instance.
 */
 public function register(TextTransformer $transformer): self
 {
 $this->transformers[$transformer->getName()] = $transformer;

 return $this;
 }

 /**
 * Register a factory that creates a transformer on demand.
 * Useful for transformers with heavy dependencies.
 */
 public function registerFactory(string $name, callable $factory): self
 {
 $this->factories[$name] = $factory;

 return $this;
 }

 /**
 * Get a transformer by name.
 *
 * @throws InvalidArgumentException
 */
 public function get(string $name): TextTransformer
 {
 // Check already-instantiated transformers first
 if (isset($this->transformers[$name])) {
 return $this->transformers[$name];
 }

 // Try the factory
 if (isset($this->factories[$name])) {
 $transformer = ($this->factories[$name])();

 if (!$transformer instanceof TextTransformer) {
 throw new InvalidArgumentException(
 "Factory for '{$name}' must return a TextTransformer instance."
 );
 }

 // Cache it for next time
 $this->transformers[$name] = $transformer;

 return $transformer;
 }

 throw new InvalidArgumentException(
 "No transformer registered with name '{$name}'. "
 . "Available: " . implode(', ', $this->getAvailableNames())
 );
 }

 /**
 * Transform text using a named transformer.
 */
 public function transform(string $name, string $text): string
 {
 return $this->get($name)->transform($text);
 }

 /**
 * Check if a transformer is registered.
 */
 public function has(string $name): bool
 {
 return isset($this->transformers[$name]) || isset($this->factories[$name]);
 }

 /**
 * Get all available transformer names.
 *
 * @return string[]
 */
 public function getAvailableNames(): array
 {
 return array_unique(
 array_merge(
 array_keys($this->transformers),
 array_keys($this->factories)
 )
 );
 }
}

Here's the beauty of this pattern in action:

use Jakovic\TextToolkit\TransformerManager;
use Jakovic\TextToolkit\Transformers\SlugTransformer;
use Jakovic\TextToolkit\Transformers\ExcerptTransformer;
use Jakovic\TextToolkit\Transformers\MarkdownStripTransformer;

$manager = new TransformerManager();

// Register built-in transformers
$manager->register(new SlugTransformer());
$manager->register(new ExcerptTransformer(length: 200));
$manager->register(new MarkdownStripTransformer());

// Use them by name
$slug = $manager->transform('slug', 'Hello World!');
$excerpt = $manager->transform('excerpt', $longArticle);
$plain = $manager->transform('markdown-strip', $markdownContent);

// Register a lazy factory for expensive transformers
$manager->registerFactory('ai-summarize', function () {
 // This only runs when actually needed
 return new AiSummarizeTransformer(
 apiKey: getenv('OPENAI_API_KEY')
 );
});

// Users register their own custom transformers
$manager->register(new MyCustomTransformer());

The factory registration is especialy important. Some transformers might need expensive setup - database connections, API clients, or heavy file parsing. By using factories, you only pay for what you use. The transformer isn't created until someone actually calls $manager->get('ai-summarize').

Configuration Objects vs Arrays

One pattern you see in a lot of PHP packages is passing configuration as arrays:

// This works, but it's fragile
$toolkit = new TextToolkit([
 'default_transformer' => 'slug',
 'slug_separator' => '-',
 'excerpt_length' => 160,
 'excerpt_suffix' => '...',
 'strip_html' => true,
 // Typo? No error. Missing key? Runtime crash.
]);

This approach has problems. There's no autocomplete in your IDE. Typos in keys are silent bugs. You have no type safety - someone could pass 'excerpt_length' => 'banana' and nothing would complain until runtime.

Configuration objects solve all of this:

<?php

namespace Jakovic\TextToolkit;

class TextToolkitConfig
{
 public function __construct(
 private string $defaultTransformer = 'slug',
 private string $slugSeparator = '-',
 private int $excerptLength = 160,
 private string $excerptSuffix = '...',
 private bool $stripHtml = true,
 ) {}

 public function getDefaultTransformer(): string
 {
 return $this->defaultTransformer;
 }

 public function getSlugSeparator(): string
 {
 return $this->slugSeparator;
 }

 public function getExcerptLength(): int
 {
 return $this->excerptLength;
 }

 public function getExcerptSuffix(): string
 {
 return $this->excerptSuffix;
 }

 public function shouldStripHtml(): bool
 {
 return $this->stripHtml;
 }

 /**
 * Create a new config with overridden values.
 * Immutable - returns a new instance.
 */
 public function with(
 ?string $defaultTransformer = null,
 ?string $slugSeparator = null,
 ?int $excerptLength = null,
 ?string $excerptSuffix = null,
 ?bool $stripHtml = null,
 ): self {
 return new self(
 defaultTransformer: $defaultTransformer ?? $this->defaultTransformer,
 slugSeparator: $slugSeparator ?? $this->slugSeparator,
 excerptLength: $excerptLength ?? $this->excerptLength,
 excerptSuffix: $excerptSuffix ?? $this->excerptSuffix,
 stripHtml: $stripHtml ?? $this->stripHtml,
 );
 }

 /**
 * Create from an array (for backward compatibility or framework integration).
 */
 public static function fromArray(array $config): self
 {
 return new self(
 defaultTransformer: $config['default_transformer'] ?? 'slug',
 slugSeparator: $config['slug_separator'] ?? '-',
 excerptLength: $config['excerpt_length'] ?? 160,
 excerptSuffix: $config['excerpt_suffix'] ?? '...',
 stripHtml: $config['strip_html'] ?? true,
 );
 }
}

Now users get IDE autocomplete, type safety, and immutability:

// Clean, type-safe configuration
$config = new TextToolkitConfig(
 excerptLength: 200,
 slugSeparator: '_',
);

// Need to tweak one setting? Immutable override:
$altConfig = $config->with(excerptLength: 300);

// The original is unchanged
echo $config->getExcerptLength(); // 200
echo $altConfig->getExcerptLength(); // 300

// Still supports array format for framework configs
$config = TextToolkitConfig::fromArray($frameworkConfig);

The with() method is a particularly nice touch. It follows the immutable object pattern - instead of mutating the config, it returns a new instance with the changed values. This prevents weird bugs where changing config in one part of the app accidentally affects another.

A good rule of thumb: if your package has more than 3 configuration values, use a config object. Below 3, constructor parameters are fine.

Making Packages Extensible via Interfaces

We've already used interfaces with the Strategy and Driver patterns, but let's talk about interface design philosophy for packages. The key principle: depend on abstractions, not concretions.

Here's a good set of interfaces for a text toolkit package:

<?php

namespace Jakovic\TextToolkit\Contracts;

/**
 * Transforms text from one form to another.
 */
interface TextTransformer
{
 public function transform(string $text): string;
 public function getName(): string;
}

/**
 * Validates or checks text against rules.
 */
interface TextValidator
{
 public function validate(string $text): bool;
 public function getErrorMessage(): string;
}

/**
 * Analyzes text and returns metrics.
 */
interface TextAnalyzer
{
 /**
 * @return array<string, mixed>
 */
 public function analyze(string $text): array;
}

/**
 * Formats text for a specific output context.
 */
interface TextFormatter
{
 public function format(string $text): string;
 public function supports(string $outputFormat): bool;
}

Notice a few design decisions here:

  • Small interfaces - Each interface has 1-2 methods. This follows the Interface Segregation Principle. A class that only needs to transform text shouldn't be forced to implement validation methods too.
  • Clear naming - TextTransformer, not TransformerInterface. The "I" prefix and "Interface" suffix are a matter of taste, but the PHP ecosystem generally prefers clean names without suffixes.
  • Contracts namespace - All interfaces live in Contracts. This is a Laravel convention that's become widely adopted. It makes it obvious where to find your package's extension points.
  • Return type clarity - Every method has a return type. Users implementing these interfaces know exactly what to return.

When your internal code depends on these interfaces rather than concrete classes, users can swap in their own implementations anywhere:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\TextTransformer;
use Jakovic\TextToolkit\Contracts\TextAnalyzer;

class TextPipeline
{
 /** @var TextTransformer[] */
 private array $transformers = [];

 private ?TextAnalyzer $analyzer;

 public function __construct(?TextAnalyzer $analyzer = null)
 {
 $this->analyzer = $analyzer;
 }

 public function addTransformer(TextTransformer $transformer): self
 {
 $this->transformers[] = $transformer;

 return $this;
 }

 public function process(string $text): string
 {
 foreach ($this->transformers as $transformer) {
 $text = $transformer->transform($text);
 }

 return $text;
 }

 /**
 * @return array<string, mixed>|null
 */
 public function processAndAnalyze(string $text): ?array
 {
 $result = $this->process($text);

 return $this->analyzer?->analyze($result);
 }
}

The TextPipeline class doesn't know or care about concrete implementations. It just works with any class that fulfills the contract. Users can plug in custom transformers, custom analyzers, or any combination - and your code never needs to change.

The Builder Pattern - Complex Object Construction

When a class has many optional parameters or requires multi-step configuration, the Builder pattern shines. Instead of a constructor with 10 parameters or a config array, you provide a fluent API that guides users through setup.

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\TextTransformer;
use Jakovic\TextToolkit\Transformers\SlugTransformer;
use Jakovic\TextToolkit\Transformers\ExcerptTransformer;
use Jakovic\TextToolkit\Transformers\MarkdownStripTransformer;

class TextToolkitBuilder
{
 private TextToolkitConfig $config;

 /** @var TextTransformer[] */
 private array $transformers = [];

 /** @var callable[] */
 private array $middleware = [];

 private bool $withDefaults = true;

 public function __construct()
 {
 $this->config = new TextToolkitConfig();
 }

 /**
 * Configure the toolkit.
 */
 public function withConfig(TextToolkitConfig $config): self
 {
 $this->config = $config;

 return $this;
 }

 /**
 * Set the excerpt length.
 */
 public function excerptLength(int $length): self
 {
 $this->config = $this->config->with(excerptLength: $length);

 return $this;
 }

 /**
 * Set the slug separator.
 */
 public function slugSeparator(string $separator): self
 {
 $this->config = $this->config->with(slugSeparator: $separator);

 return $this;
 }

 /**
 * Add a custom transformer.
 */
 public function addTransformer(TextTransformer $transformer): self
 {
 $this->transformers[] = $transformer;

 return $this;
 }

 /**
 * Add processing middleware.
 */
 public function addMiddleware(callable $middleware): self
 {
 $this->middleware[] = $middleware;

 return $this;
 }

 /**
 * Skip registering default transformers.
 */
 public function withoutDefaults(): self
 {
 $this->withDefaults = false;

 return $this;
 }

 /**
 * Build the TextToolkit instance.
 */
 public function build(): TextToolkit
 {
 $manager = new TransformerManager();

 if ($this->withDefaults) {
 $manager->register(new SlugTransformer());
 $manager->register(new ExcerptTransformer(
 length: $this->config->getExcerptLength(),
 suffix: $this->config->getExcerptSuffix(),
 ));
 $manager->register(new MarkdownStripTransformer());
 }

 foreach ($this->transformers as $transformer) {
 $manager->register($transformer);
 }

 return new TextToolkit($this->config, $manager, $this->middleware);
 }
}

Now building a toolkit instance is clean and discoverable:

$toolkit = (new TextToolkitBuilder())
 ->excerptLength(200)
 ->slugSeparator('_')
 ->addTransformer(new MyCustomTransformer())
 ->addMiddleware(function (string $text): string {
 return trim($text);
 })
 ->build();

// Or for simple cases with all defaults:
$toolkit = (new TextToolkitBuilder())->build();

The builder pattern is particularly useful when you have a static factory method as the main entry point:

class TextToolkit
{
 public static function create(): TextToolkitBuilder
 {
 return new TextToolkitBuilder();
 }
}

// Clean entry point
$toolkit = TextToolkit::create()
 ->excerptLength(200)
 ->build();

What's Next

We've covered five foundational patterns - Strategy for swappable algorithms, Driver/Adapter for multiple backends, Configuration objects for clean APIs, interfaces for extensibility, and the Builder pattern for complex construction. These give your package a solid architecture that users can extend without modifying your code.

In Part 7b, we'll go further with middleware pipelines, event-driven hooks, service container integration, and a complete example that ties everything together. If Part 7a was about structure, Part 7b is about letting users plug into your package at runtime.