Part 7b of 13 in the PHP Package Development series

In Part 7a, we covered the Strategy pattern, Driver/Adapter pattern, configuration objects, interface-based extensibility, and the Builder pattern. These give your package solid architecture. Now let's make it truly pluggable with runtime extension points - middleware pipelines, event hooks, service container concepts, and a complete example that ties everything together.

Middleware/Pipeline Architecture

Middleware pipelines let users inject processing steps before and after your core logic. Think of HTTP middleware in Laravel or Symfony - same concept, applied to text processing.

Here's a pipeline implementation for our text toolkit:

<?php

namespace Jakovic\TextToolkit;

use Closure;

class Pipeline
{
 /** @var array<callable> */
 private array $stages = [];

 /**
 * Add a stage to the pipeline.
 *
 * Each stage receives the text and a $next callable.
 * It must call $next($text) to continue the pipeline,
 * or return early to short-circuit.
 */
 public function pipe(callable $stage): self
 {
 $this->stages[] = $stage;

 return $this;
 }

 /**
 * Process text through the pipeline.
 */
 public function process(string $text): string
 {
 $pipeline = array_reduce(
 array_reverse($this->stages),
 function (Closure $next, callable $stage): Closure {
 return function (string $text) use ($stage, $next): string {
 return $stage($text, $next);
 };
 },
 function (string $text): string {
 return $text; // Final handler - just return the text
 }
 );

 return $pipeline($text);
 }
}

The power of middleware is that each stage can modify the text before and after passing it along, or even skip the rest of the pipeline entirely:

$pipeline = new Pipeline();

// Stage 1: Normalize whitespace before processing
$pipeline->pipe(function (string $text, callable $next): string {
 $text = preg_replace('/\s+/', ' ', trim($text));
 return $next($text);
});

// Stage 2: Cache check - skip processing if cached
$pipeline->pipe(function (string $text, callable $next): string {
 $cacheKey = md5($text);
 if ($cached = Cache::get($cacheKey)) {
 return $cached; // Short-circuit! Don't call $next
 }
 $result = $next($text);
 Cache::set($cacheKey, $result);
 return $result;
});

// Stage 3: Log the transformation
$pipeline->pipe(function (string $text, callable $next): string {
 $before = $text;
 $after = $next($text);
 Logger::debug('Text transformed', [
 'input_length' => strlen($before),
 'output_length' => strlen($after),
 ]);
 return $after;
});

// Stage 4: The actual transformation
$pipeline->pipe(function (string $text, callable $next): string {
 $text = mb_strtolower($text);
 return $next($text);
});

$result = $pipeline->process(' Hello WORLD ');
// Normalized, cached, logged, and lowercased

For a cleaner API, you can also support middleware classes:

<?php

namespace Jakovic\TextToolkit\Contracts;

interface TextMiddleware
{
 public function handle(string $text, callable $next): string;
}

// Implementation example
namespace Jakovic\TextToolkit\Middleware;

use Jakovic\TextToolkit\Contracts\TextMiddleware;

class TrimWhitespace implements TextMiddleware
{
 public function handle(string $text, callable $next): string
 {
 $text = preg_replace('/\s+/', ' ', trim($text));

 return $next($text);
 }
}

class StripHtmlTags implements TextMiddleware
{
 /** @var string[] */
 private array $allowedTags;

 public function __construct(array $allowedTags = [])
 {
 $this->allowedTags = $allowedTags;
 }

 public function handle(string $text, callable $next): string
 {
 $allowed = implode('', array_map(
 fn (string $tag) => "<{$tag}>",
 $this->allowedTags
 ));
 $text = strip_tags($text, $allowed);

 return $next($text);
 }
}

Update the Pipeline to accept both callables and middleware objects:

public function pipe(callable|TextMiddleware $stage): self
{
 if ($stage instanceof TextMiddleware) {
 $this->stages[] = [$stage, 'handle'];
 } else {
 $this->stages[] = $stage;
 }

 return $this;
}

// Usage
$pipeline = new Pipeline();
$pipeline->pipe(new TrimWhitespace());
$pipeline->pipe(new StripHtmlTags(['p', 'br']));
$pipeline->pipe(function (string $text, callable $next): string {
 return $next(mb_strtolower($text));
});

Event-Driven Extensibility

Events let users react to things happening inside your package without modifying your code. Unlike middleware (which wraps behavior), events are fire-and-forget notifications. Users subscribe to events they care about, and your package fires them at key moments.

Here's a lightweight event dispatcher for a standalone package (no framework dependency):

<?php

namespace Jakovic\TextToolkit\Events;

class EventDispatcher
{
 /** @var array<string, array<callable>> */
 private array $listeners = [];

 /**
 * Register a listener for an event.
 */
 public function listen(string $event, callable $listener): void
 {
 $this->listeners[$event][] = $listener;
 }

 /**
 * Fire an event and notify all listeners.
 */
 public function dispatch(string $event, array $payload = []): void
 {
 foreach ($this->listeners[$event] ?? [] as $listener) {
 $listener(...$payload);
 }
 }

 /**
 * Check if an event has listeners.
 */
 public function hasListeners(string $event): bool
 {
 return !empty($this->listeners[$event]);
 }
}

Define event names as constants so users don't have to guess:

<?php

namespace Jakovic\TextToolkit\Events;

final class TextToolkitEvents
{
 /**
 * Fired before a transformation starts.
 * Receives: string $transformerName, string $inputText
 */
 public const BEFORE_TRANSFORM = 'text_toolkit.before_transform';

 /**
 * Fired after a transformation completes.
 * Receives: string $transformerName, string $inputText, string $outputText
 */
 public const AFTER_TRANSFORM = 'text_toolkit.after_transform';

 /**
 * Fired when a transformer is registered.
 * Receives: TextTransformer $transformer
 */
 public const TRANSFORMER_REGISTERED = 'text_toolkit.transformer_registered';

 /**
 * Fired when a transformation fails.
 * Receives: string $transformerName, string $inputText, \Throwable $exception
 */
 public const TRANSFORM_FAILED = 'text_toolkit.transform_failed';
}

Now integrate events into the TransformerManager:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\TextTransformer;
use Jakovic\TextToolkit\Events\EventDispatcher;
use Jakovic\TextToolkit\Events\TextToolkitEvents;
use Throwable;

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

 private EventDispatcher $events;

 public function __construct(?EventDispatcher $events = null)
 {
 $this->events = $events ?? new EventDispatcher();
 }

 public function getEventDispatcher(): EventDispatcher
 {
 return $this->events;
 }

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

 $this->events->dispatch(
 TextToolkitEvents::TRANSFORMER_REGISTERED,
 [$transformer]
 );

 return $this;
 }

 public function transform(string $name, string $text): string
 {
 $transformer = $this->get($name);

 $this->events->dispatch(
 TextToolkitEvents::BEFORE_TRANSFORM,
 [$name, $text]
 );

 try {
 $result = $transformer->transform($text);
 } catch (Throwable $e) {
 $this->events->dispatch(
 TextToolkitEvents::TRANSFORM_FAILED,
 [$name, $text, $e]
 );
 throw $e;
 }

 $this->events->dispatch(
 TextToolkitEvents::AFTER_TRANSFORM,
 [$name, $text, $result]
 );

 return $result;
 }

 // ... rest of the class
}

Users can now hook into any of these moments:

use Jakovic\TextToolkit\Events\TextToolkitEvents;

$manager = new TransformerManager();
$events = $manager->getEventDispatcher();

// Log every transformation
$events->listen(
 TextToolkitEvents::AFTER_TRANSFORM,
 function (string $name, string $input, string $output) {
 error_log("Transformed via '{$name}': " . strlen($input) . " -> " . strlen($output) . " chars");
 }
);

// Track errors
$events->listen(
 TextToolkitEvents::TRANSFORM_FAILED,
 function (string $name, string $input, Throwable $e) {
 Sentry::captureException($e, ['transformer' => $name]);
 }
);

// Monitor which transformers are used
$events->listen(
 TextToolkitEvents::BEFORE_TRANSFORM,
 function (string $name) {
 Metrics::increment("text_toolkit.transform.{$name}");
 }
);

If you want to play nice with framework event systems (like Laravel's event dispatcher or Symfony's EventDispatcher component), you can use PSR-14:

{
 "require": {
 "psr/event-dispatcher": "^1.0"
 }
}
<?php

namespace Jakovic\TextToolkit;

use Psr\EventDispatcher\EventDispatcherInterface;

class TransformerManager
{
 private ?EventDispatcherInterface $events;

 public function __construct(?EventDispatcherInterface $events = null)
 {
 $this->events = $events;
 }

 // Now it works with any PSR-14 dispatcher -
 // Laravel's, Symfony's, or your own
}

This is a judgment call. Adding psr/event-dispatcher as a dependency is lightweight, but it's still a dependency. If your package is small and standalone, the custom dispatcher we built earlier is perfectly fine. If you expect framework integration, PSR-14 compatibility pays off.

Service Container Integration Concepts

A service container (also called a dependency injection container or IoC container) manages object creation and dependency wiring. Laravel, Symfony, and most PHP frameworks have one. Your package doesn't need to include a container, but designing your code to be container-friendly is important.

The principles are simple:

  • Accept dependencies through the constructor - Don't create them internally with new
  • Type-hint interfaces, not concrete classes - Let the container decide which implementation to inject
  • Provide sensible defaults - So the package works without a container too

Here's what container-friendly design looks like in practice:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\TextTransformer;
use Jakovic\TextToolkit\Events\EventDispatcher;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

class TextToolkit
{
 private TransformerManager $manager;
 private TextToolkitConfig $config;
 private LoggerInterface $logger;

 public function __construct(
 ?TextToolkitConfig $config = null,
 ?TransformerManager $manager = null,
 ?LoggerInterface $logger = null,
 ) {
 $this->config = $config ?? new TextToolkitConfig();
 $this->manager = $manager ?? new TransformerManager();
 $this->logger = $logger ?? new NullLogger();
 }

 public function transform(string $name, string $text): string
 {
 $this->logger->debug("Transforming text with '{$name}'", [
 'input_length' => mb_strlen($text),
 ]);

 return $this->manager->transform($name, $text);
 }

 public function registerTransformer(TextTransformer $transformer): self
 {
 $this->manager->register($transformer);

 return $this;
 }

 public function getConfig(): TextToolkitConfig
 {
 return $this->config;
 }

 public function getManager(): TransformerManager
 {
 return $this->manager;
 }
}

This design works in three scenarios:

// 1. No container - everything uses defaults
$toolkit = new TextToolkit();

// 2. Manual dependency injection
$toolkit = new TextToolkit(
 config: new TextToolkitConfig(excerptLength: 200),
 logger: new MonologLogger('text-toolkit'),
);

// 3. Framework container (Laravel example)
// In a service provider:
$this->app->singleton(TextToolkit::class, function ($app) {
 return new TextToolkit(
 config: TextToolkitConfig::fromArray(config('text-toolkit')),
 logger: $app->make(LoggerInterface::class),
 );
});

// 3b. Framework container (Symfony example)
// In services.yaml:
// Jakovic\TextToolkit\TextToolkit:
// arguments:
// $config: '@Jakovic\TextToolkit\TextToolkitConfig'
// $logger: '@logger'

Notice the use of PSR-3 LoggerInterface instead of a specific logger. This is a common PSR interface that both Monolog and Symfony's logger implement. By depending on the PSR interface, your package works with any logger. And the NullLogger default means logging is a zero-cost no-op when the user doesn't need it.

Putting It All Together - A Complete Example

Let's see what the full jakovic/text-toolkit package structure looks like with all these patterns applied:

text-toolkit/
├── src/
├── Contracts/
 ├── TextTransformer.php
 ├── TextAnalyzer.php
 ├── TextFormatter.php
 ├── TextMiddleware.php
 ├── TextValidator.php
 └── SlugStrategy.php
├── Events/
 ├── EventDispatcher.php
 └── TextToolkitEvents.php
├── Middleware/
 ├── TrimWhitespace.php
 └── StripHtmlTags.php
├── Slug/
 ├── AsciiSlugStrategy.php
 └── UnicodeSlugStrategy.php
├── Transformers/
 ├── SlugTransformer.php
 ├── ExcerptTransformer.php
 └── MarkdownStripTransformer.php
├── Pipeline.php
├── Slugifier.php
├── TextToolkit.php
├── TextToolkitBuilder.php
├── TextToolkitConfig.php
└── TransformerManager.php
├── tests/
├── composer.json
├── README.md
└── LICENSE

Now let's walk through a realistic usage scenario. Imagine a user building a CMS who needs custom text processing:

<?php

use Jakovic\TextToolkit\TextToolkit;
use Jakovic\TextToolkit\TextToolkitBuilder;
use Jakovic\TextToolkit\TextToolkitConfig;
use Jakovic\TextToolkit\Contracts\TextTransformer;
use Jakovic\TextToolkit\Contracts\TextMiddleware;
use Jakovic\TextToolkit\Events\TextToolkitEvents;

// Step 1: Create a custom transformer
class SeoTitleTransformer implements TextTransformer
{
 public function __construct(
 private int $maxLength = 60,
 private string $siteName = ''
 ) {}

 public function transform(string $text): string
 {
 $text = trim($text);

 if ($this->siteName) {
 $suffix = " | {$this->siteName}";
 $available = $this->maxLength - mb_strlen($suffix);

 if (mb_strlen($text) > $available) {
 $text = mb_substr($text, 0, $available - 3) . '...';
 }

 return $text . $suffix;
 }

 if (mb_strlen($text) > $this->maxLength) {
 $text = mb_substr($text, 0, $this->maxLength - 3) . '...';
 }

 return $text;
 }

 public function getName(): string
 {
 return 'seo-title';
 }
}

// Step 2: Create a custom middleware
class ProfanityFilter implements TextMiddleware
{
 private array $blockedWords = ['badword1', 'badword2'];

 public function handle(string $text, callable $next): string
 {
 foreach ($this->blockedWords as $word) {
 $text = str_ireplace($word, str_repeat('*', strlen($word)), $text);
 }

 return $next($text);
 }
}

// Step 3: Build the toolkit with everything
$toolkit = TextToolkit::create()
 ->excerptLength(200)
 ->addTransformer(new SeoTitleTransformer(
 maxLength: 60,
 siteName: 'My Blog'
 ))
 ->addMiddleware(new ProfanityFilter())
 ->build();

// Step 4: Hook into events
$events = $toolkit->getManager()->getEventDispatcher();

$events->listen(TextToolkitEvents::AFTER_TRANSFORM, function (
 string $name,
 string $input,
 string $output,
) {
 // Track transformer usage for analytics
 DB::table('transformer_usage')->insert([
 'transformer' => $name,
 'input_length' => mb_strlen($input),
 'output_length' => mb_strlen($output),
 'created_at' => now(),
 ]);
});

// Step 5: Use it
$title = $toolkit->transform('seo-title', 'A Very Long Blog Post Title That Needs Trimming');
// "A Very Long Blog Post Title That Needs Tri... | My Blog"

$excerpt = $toolkit->transform('excerpt', $articleContent);
$slug = $toolkit->transform('slug', 'My New Blog Post!');

The user was able to add a custom transformer, plug in middleware, hook into events, and configure the toolkit - all without modifying a single line of package source code. That's the power of good extensibility patterns.

Guidelines for Choosing Patterns

Not every package needs every pattern. Here's a practical guide for when to use what:

  • Strategy - Use when you have one behavior that can be implemented multiple ways. Example: different slugification algorithms, different hashing strategies.
  • Driver/Adapter - Use when you need to manage multiple named implementations and resolve them dynamically. Example: multiple cache backends, multiple notification channels.
  • Builder - Use when object construction is complex with many optional settings. Example: query builders, configuration-heavy services.
  • Middleware/Pipeline - Use when you have a processing chain where steps can be added, removed, or reordered. Example: request/response processing, data transformation chains.
  • Events - Use when you want to notify external code about things happening inside your package, without requiring a response. Example: logging, metrics, side effects.
  • Config objects - Use when you have more than 3 configuration values. Below 3, constructor parameters are simpler.

A word of caution: don't over-engineer. A simple utility package that converts dates doesn't need events, middleware, and a driver system. Start with interfaces and the Strategy pattern. Add more patterns when users actually need them, not because they might need them someday.

Common Mistakes to Avoid

A few pitfalls I see in package design that are worth calling out:

  • God interfaces - An interface with 10 methods that every implementor has to satisfy. Split it into smaller, focused interfaces.
  • Hidden dependencies - Using new SomeDependency() inside your class instead of accepting it through the constructor. This makes testing harder and prevents users from swapping implementations.
  • Breaking the open/closed principle - Requiring users to extend your concrete classes instead of implementing interfaces. Inheritance creates tight coupling.
  • No sensible defaults - Making every dependency required. Users shouldn't need to configure 5 things just to use your package for a basic use case.
  • Sealed classes - Using final everywhere without providing interfaces. If you mark a class as final (which can be a good practice for safety), make sure there's an interface users can implement instead.

What's Next

We've covered the design patterns that make packages truly extensible - Strategy, Driver, Builder, Middleware, and Events. These patterns aren't just theory. They're the exact same approaches used by packages like Flysystem, Laravel's Mail system, Monolog, and hundreds of other widely-used PHP libraries.

In Part 8: Building Laravel Packages - The Basics, we'll take our jakovic/text-toolkit and wrap it in a Laravel package. You'll learn about service providers, config publishing, package auto-discovery, and how to give your users a beautiful Laravel-native experience with facades and Blade directives. All those extension points we built in Parts 7a and 7b? They'll plug perfectly into Laravel's container and event system.

If you have questions about any of these patterns or want to see how they apply to your own package, drop a comment below. See you in Part 8!