Part 3 of 12 in the PHP Package Development series

In Part 2, we set up our jakovic/text-toolkit package with a proper directory structure, PSR-4 autoloading, and two working classes - Slugifier and Truncator. We even built a Str facade to tie them together. That's a good start, but our package still feels thin.

Today we're going to flesh it out into something genuinely useful. We'll add an excerpt generator, a Markdown-to-text converter, introduce interfaces for extensibility, and apply clean API design principles that separate amateur packages from professional ones. By the end, you'll have a package that feels polished and thoughtful - the kind of tool developers actually want to to install.

Where We Left Off

Quick refresher. Our package directory looks like this:

text-toolkit/
├── composer.json
├── src/
├── Slugifier.php
├── Truncator.php
└── Str.php
└── vendor/

We have Slugifier::make() for URL slugs, Truncator::truncate() for character-based truncation, and Truncator::words() for word-based truncation. The Str class wraps them all with static methods. Let's keep building.

Building the Excerpt Generator

Excerpts are everywhere - blog post previews, search result snippets, meta descriptions. The built-in PHP approach of substr() plus strip_tags() always feels hacky. Let's build something better.

Create src/Excerpt.php:

<?php

namespace Jakovic\TextToolkit;

class Excerpt
{
 /**
 * Generate a plain-text excerpt from HTML or plain text content.
 */
 public static function make(string $text, int $limit = 160, string $end = '...'): string
 {
 // Strip HTML tags first
 $text = strip_tags($text);

 // Normalize whitespace - collapse multiple spaces, newlines, tabs
 $text = preg_replace('/\s+/', ' ', $text);
 $text = trim($text);

 if ($text === '') {
 return '';
 }

 // If the text is already short enough, return it as-is
 if (mb_strlen($text) <= $limit) {
 return $text;
 }

 // Cut at the limit, then step back to the last word boundary
 $truncated = mb_substr($text, 0, $limit);
 $lastSpace = mb_strrpos($truncated, ' ');

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

 // Remove trailing punctuation that looks awkward before "..."
 $truncated = rtrim($truncated, '.,;:!?-');

 return $truncated . $end;
 }

 /**
 * Generate an excerpt based on sentence boundaries.
 */
 public static function sentences(string $text, int $count = 2, string $end = ''): string
 {
 $text = strip_tags($text);
 $text = preg_replace('/\s+/', ' ', $text);
 $text = trim($text);

 if ($text === '') {
 return '';
 }

 // Match sentences ending with . ! or ?
 preg_match_all('/[^.!?]*[.!?]+/u', $text, $matches);

 if (empty($matches[0])) {
 // No sentence boundaries found - return the whole text
 return $text . $end;
 }

 $selected = array_slice($matches[0], 0, $count);
 $result = trim(implode(' ', $selected));

 return $result . $end;
 }

 /**
 * Generate an excerpt from HTML content, preserving the first paragraph.
 */
 public static function firstParagraph(string $html): string
 {
 // Match the content of the first <p> tag
 if (preg_match('/<p[^>]*>(.*?)<\/p>/is', $html, $match)) {
 $content = strip_tags($match[1]);
 return trim(preg_replace('/\s+/', ' ', $content));
 }

 // No paragraph found - fall back to plain text excerpt
 return self::make($html);
 }
}

Let's walk through the design decisions here.

The make() method does what most people need: strip HTML, normalize whitespace, cut at a word boundary, and append an ellipsis. The default limit of 160 characters isn't random - it's the typical meta description length for search engines. Sensible defaults like this mean most users never need to pass extra arguments.

The sentences() method takes a different approach. Instead of cutting at a character limit, it extracts complete sentences. This produces more natural-sounding excerpts. Two sentences is usually enough for a preview, but the user can adjust.

And firstParagraph() handles a common CMS scenario - you have HTML content and just want the first paragraph as a summary. WordPress, Ghost, and most blogging platforms store content as HTML, so this is immediately useful.

Notice how every method handles edge cases: empty strings, text shorter than the limit, missing HTML tags. These are the details that prevent bug reports.

Testing the Excerpt Class

Let's quickly verify it works. Create a test script at the project root called test-excerpt.php:

<?php

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

use Jakovic\TextToolkit\Excerpt;

$html = '<p>PHP package development is a skill every developer should learn.
It forces you to think about clean APIs, proper documentation, and reusable code.</p>
<p>In this series, we are building a text manipulation toolkit from scratch.</p>';

// Character-limited excerpt
echo Excerpt::make($html, 80) . "\n";
// "PHP package development is a skill every developer should learn. It forces..."

// Sentence-based excerpt
echo Excerpt::sentences($html, 1) . "\n";
// "PHP package development is a skill every developer should learn."

// First paragraph
echo Excerpt::firstParagraph($html) . "\n";
// "PHP package development is a skill every developer should learn..."
php test-excerpt.php

Run it and make sure the output looks right. Then delete the test script - we'll write proper tests in Part 4.

Building the Markdown-to-Text Converter

Markdown shows up everywhere - README files, documentation, CMS content, API responses. Sometimes you need the plain text version. Maybe for email notifications, search indexing or generating excerpts from Markdown content. Let's build a converter that strips formatting while keeping the text readable.

Create src/MarkdownConverter.php:

<?php

namespace Jakovic\TextToolkit;

class MarkdownConverter
{
 /**
 * Convert Markdown text to plain text.
 */
 public static function toText(string $markdown): string
 {
 if (trim($markdown) === '') {
 return '';
 }

 $text = $markdown;

 // Remove code blocks (fenced) before other processing
 $text = preg_replace('/```[\s\S]*?```/', '', $text);
 $text = preg_replace('/`([^`]+)`/', '$1', $text);

 // Remove images: ![alt](url) -> alt
 $text = preg_replace('/!\[([^\]]*)\]\([^\)]+\)/', '$1', $text);

 // Convert links: [text](url) -> text
 $text = preg_replace('/\[([^\]]+)\]\([^\)]+\)/', '$1', $text);

 // Remove headings markers (# ## ### etc.)
 $text = preg_replace('/^#{1,6}\s+/m', '', $text);

 // Remove bold and italic markers
 $text = preg_replace('/\*{1,3}(.*?)\*{1,3}/', '$1', $text);
 $text = preg_replace('/_{1,3}(.*?)_{1,3}/', '$1', $text);

 // Remove strikethrough
 $text = preg_replace('/~~(.*?)~~/', '$1', $text);

 // Remove blockquote markers
 $text = preg_replace('/^>\s?/m', '', $text);

 // Remove horizontal rules
 $text = preg_replace('/^[-*_]{3,}\s*$/m', '', $text);

 // Remove unordered list markers (-, *, +)
 $text = preg_replace('/^[\s]*[-*+]\s+/m', '', $text);

 // Remove ordered list markers (1., 2., etc.)
 $text = preg_replace('/^[\s]*\d+\.\s+/m', '', $text);

 // Remove HTML tags that might be embedded
 $text = strip_tags($text);

 // Normalize whitespace
 $text = preg_replace('/\n{3,}/', "\n\n", $text);
 $text = trim($text);

 return $text;
 }

 /**
 * Convert Markdown to a single line of plain text.
 * Useful for meta descriptions, previews, etc.
 */
 public static function toSingleLine(string $markdown): string
 {
 $text = self::toText($markdown);

 // Collapse all whitespace including newlines into single spaces
 $text = preg_replace('/\s+/', ' ', $text);

 return trim($text);
 }
}

The toText() method preserves paragraph breaks (double newlines) while stripping all Markdown formatting. The order of operations matters here - we remove code blocks first so their contents don't get mangled by the bold/italic regex patterns.

The toSingleLine() method goes further by collapsing everything into a single line. This is exactly what you need for meta descriptions, notification previews, or search index entries.

Is this a full Markdown parser? No. A complete parser would handle nested lists, reference-style links, tables, and more. But for 90% of use cases - stripping formatting to get readable text - this works perfectly. And that's an important lesson in package design: solve the common case well instead of trying to handle every edge case poorly.

Testing the Markdown Converter

Create test-markdown.php:

<?php

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

use Jakovic\TextToolkit\MarkdownConverter;

$markdown = <<<MD
# Getting Started

This is a **bold** statement with *italic* emphasis.

## Installation

Install via [Composer](https://getcomposer.org):

```bash
composer require jakovic/text-toolkit
```

- First item
- Second item
- Third item

> This is a blockquote with some wisdom.
MD;

echo "=== Multi-line ===\n";
echo MarkdownConverter::toText($markdown);
echo "\n\n=== Single line ===\n";
echo MarkdownConverter::toSingleLine($markdown);
echo "\n";
php test-markdown.php

You should see clean, readable text with all the Markdown syntax stripped away. The multi-line version keeps paragraph structure, and the single-line version collapses everything.

Introducing Interfaces for Extensibility

So far, our classes work well but they're rigid. What if someone wants to use a different slugification algorithm? Or a different Markdown parser? Right now they'd have to fork the entire class. That's bad package design.

The solution is interfaces (also called contracts). An interface defines what something does without specifying how. This lets users swap implementations without changing the code that depends on them.

Create a src/Contracts directory:

mkdir src/Contracts

Now let's define our contracts. Create src/Contracts/SlugifierInterface.php:

<?php

namespace Jakovic\TextToolkit\Contracts;

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

Create src/Contracts/TruncatorInterface.php:

<?php

namespace Jakovic\TextToolkit\Contracts;

interface TruncatorInterface
{
 /**
 * Truncate text to a character limit.
 */
 public function truncate(string $text, int $limit = 100, string $end = '...'): string;

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

Create src/Contracts/ExcerptInterface.php:

<?php

namespace Jakovic\TextToolkit\Contracts;

interface ExcerptInterface
{
 /**
 * Generate a plain-text excerpt.
 */
 public function make(string $text, int $limit = 160, string $end = '...'): string;

 /**
 * Generate an excerpt based on sentence boundaries.
 */
 public function sentences(string $text, int $count = 2, string $end = ''): string;
}

And src/Contracts/MarkdownConverterInterface.php:

<?php

namespace Jakovic\TextToolkit\Contracts;

interface MarkdownConverterInterface
{
 /**
 * Convert Markdown to plain text.
 */
 public function toText(string $markdown): string;

 /**
 * Convert Markdown to a single line of plain text.
 */
 public function toSingleLine(string $markdown): string;
}

Implementing the Interfaces

Now we need to update our existing classes to implement these interfaces. This is where things get interesting. Our current classes use only static methods, which can't satisfy an interface (interfaces define instance methods). We have two options:

  • Rewrite everything as instance methods and lose the static API
  • Support both - instance methods for the interface, static methods for convenience

Let's go with option two. Here's the updated Slugifier class:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\SlugifierInterface;

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

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

 // Replace non-alphanumeric characters with the separator
 $text = preg_replace('/[^a-z0-9\s-]/', '', $text);

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

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

The pattern is simple: the instance method (required by the interface) delegates to the static method (kept for convenience). Users who just want quick one-liners can keep using Slugifier::make(). Users who need dependency injection or want to swap implementations can type-hint against SlugifierInterface.

Apply the same pattern to Truncator:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\TruncatorInterface;

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

 if ($text === '' || mb_strlen($text) <= $limit) {
 return $text;
 }

 $truncated = mb_substr($text, 0, $limit);
 $lastSpace = mb_strrpos($truncated, ' ');

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

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

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

 if ($text === '') {
 return '';
 }

 $allWords = preg_split('/\s+/', $text);

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

 $selected = array_slice($allWords, 0, $wordCount);

 return implode(' ', $selected) . $end;
 }
}

Since TruncatorInterface defines truncate() and words() as instance methods, and our existing static methods have matching signatures (just with the static keyword), PHP allows a static method to satisfy an interface's instance method contract. You can call Truncator::truncate() statically or through an instance - both work.

Update Excerpt the same way:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\ExcerptInterface;

class Excerpt implements ExcerptInterface
{
 // ... all the same code, just add "implements ExcerptInterface" to the class
}

And MarkdownConverter:

<?php

namespace Jakovic\TextToolkit;

use Jakovic\TextToolkit\Contracts\MarkdownConverterInterface;

class MarkdownConverter implements MarkdownConverterInterface
{
 // ... all the same code, just add "implements MarkdownConverterInterface"
}

Why Interfaces Matter for Packages

You might be thinking: "This is a lot of extra files for something that already works." Fair point. But here's why interfaces are essential for packages specifically:

Users can swap implementations. Imagine someone using your package wants a different slug algorithm that handles Unicode transliteration (turning "Uber" into "ueber" for German URLs). With interfaces, they can create their own class that implements SlugifierInterface and inject it wherever your package is used.

<?php

use Jakovic\TextToolkit\Contracts\SlugifierInterface;

class GermanSlugifier implements SlugifierInterface
{
 private array $transliterations = [
 'a' => 'ae', 'o' => 'oe', 'u' => 'ue',
 'A' => 'Ae', 'O' => 'Oe', 'U' => 'Ue',
 'ss' => 'ss',
 ];

 public function slugify(string $text, string $separator = '-'): string
 {
 // Apply German transliterations first
 $text = strtr($text, $this->transliterations);

 // Then use the standard slug logic
 $text = mb_strtolower($text, 'UTF-8');
 $text = preg_replace('/[^a-z0-9\s-]/', '', $text);
 $text = preg_replace('/[\s-]+/', $separator, $text);

 return trim($text, $separator);
 }
}

Frameworks love interfaces. When we build the Laravel integration in Part 8, we'll bind our interfaces to the service container. This lets users resolve SlugifierInterface from the container and swap implementations through config - no code changes needed.

Testing becomes easier. You can mock any interface in your tests. Instead of testing against a real Markdown parser, you can create a fake that returns predictable output.

Updating the Str Facade

Now let's update our Str class to include the new methods. This is the unified entry point that most users will interact with.

<?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);
 }

 /**
 * Generate a plain-text excerpt.
 */
 public static function excerpt(string $text, int $limit = 160, string $end = '...'): string
 {
 return Excerpt::make($text, $limit, $end);
 }

 /**
 * Generate a sentence-based excerpt.
 */
 public static function excerptSentences(string $text, int $count = 2, string $end = ''): string
 {
 return Excerpt::sentences($text, $count, $end);
 }

 /**
 * Strip Markdown formatting and return plain text.
 */
 public static function stripMarkdown(string $markdown): string
 {
 return MarkdownConverter::toText($markdown);
 }

 /**
 * Strip Markdown formatting and return a single line of text.
 */
 public static function stripMarkdownSingleLine(string $markdown): string
 {
 return MarkdownConverter::toSingleLine($markdown);
 }
}

Notice the method naming choices. We use excerpt() instead of makeExcerpt() because the context is clear - you're calling it on the Str class. We use stripMarkdown() instead of markdownToText() because it reads more naturally: Str::stripMarkdown($content) tells you exactly what's happening.

Good method names on a facade should read like English when you include the class name. Str::slugify(), Str::excerpt(), Str::truncate() - these all make sense at a glance.

Clean API Design Principles

Let's step back and talk about what makes a package API actually good. These principles apply to any package you'll ever build.

1. Sensible Defaults

Every method in our package works with zero or minimal configuration. Look at this:

// All of these work with just the required input
Str::slugify('Hello World');
Str::truncate($text);
Str::excerpt($html);
Str::stripMarkdown($markdown);

No one should need to read documentation to use basic functionality. The defaults should work for 80% of cases. Our truncation defaults to 100 characters with "..." as the ending. Our excerpt defaults to 160 characters (SEO-friendly meta description length). These aren't arbitrary - they're based on real-world usage.

The rule is: required parameters first, optional parameters with defaults after. If a method needs more than three parameters, something is wrong with the design.

2. Multiple Entry Points

Our package offers three ways to do things:

// 1. The facade (most common)
Str::slugify('Hello World');

// 2. Direct static call on the class
Slugifier::make('Hello World');

// 3. Instance-based (for dependency injection)
$slugifier = new Slugifier();
$slugifier->slugify('Hello World');

Quick scripts and simple projects use the facade. Developers who need fine control use the classes directly. Framework integrations use the instance-based approach with interfaces. Everyone gets what they need.

3. Predictable Return Types

Every method in our package returns a string. There are no surprises, no nullable returns, no mixed types. If you pass an empty string in, you get an empty string back. If you pass text shorter than the limit, you get the same text back. This predictability makes the package easy to use without defensive coding.

// No need for null checks or type assertions
$slug = Str::slugify($title); // Always a string
$excerpt = Str::excerpt($content); // Always a string
$plain = Str::stripMarkdown($md); // Always a string

Compare this with packages that return null on empty input, throw exceptions for edge cases, or return different types depending on the input. Those packages force users to write wrapper code, which defeats the purpose of using a package.

4. Use mb_ String Functions

If your package handles text, use the multibyte string functions (mb_strlen, mb_substr, mb_strtolower). Standard PHP string functions count bytes, not characters. The string "cafe" is 5 bytes in UTF-8 but 4 characters. Using substr() instead of mb_substr() on multibyte strings will cut characters in half, producing corrupted output.

We've been doing this throughout our package, but it's worth calling out explicitly. If you're building a text package and not using mb_ functions, you're building a broken package.

5. Don't Over-Engineer

Our MarkdownConverter uses regex patterns. It doesn't parse Markdown into an AST, build a token tree, or handle every edge case in the CommonMark spec. For converting Markdown to plain text, regex is the right tool. It's fast, readable, and handles the common cases.

If someone needs full Markdown parsing, they should use league/commonmark. Our package solves a specific problem well. Resist the urge to handle every possible input - it leads to bloated, hard-to-maintain code.

Adding a Fluent Interface

Static methods are great for one-off operations, but sometimes you want to chain multiple operations together. A fluent interface lets you do this elegantly. Let's add a TextPipeline class that lets users compose text transformations.

Create src/TextPipeline.php:

<?php

namespace Jakovic\TextToolkit;

class TextPipeline
{
 private string $text;

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

 /**
 * Create a new pipeline starting with the given text.
 */
 public static function make(string $text): self
 {
 return new self($text);
 }

 /**
 * Strip Markdown formatting.
 */
 public function stripMarkdown(): self
 {
 $this->text = MarkdownConverter::toText($this->text);

 return $this;
 }

 /**
 * Strip HTML tags.
 */
 public function stripTags(): self
 {
 $this->text = strip_tags($this->text);

 return $this;
 }

 /**
 * Normalize whitespace to single spaces.
 */
 public function normalizeWhitespace(): self
 {
 $this->text = preg_replace('/\s+/', ' ', $this->text);
 $this->text = trim($this->text);

 return $this;
 }

 /**
 * Truncate to a character limit.
 */
 public function truncate(int $limit = 100, string $end = '...'): self
 {
 $this->text = Truncator::truncate($this->text, $limit, $end);

 return $this;
 }

 /**
 * Truncate to a word count.
 */
 public function words(int $wordCount = 20, string $end = '...'): self
 {
 $this->text = Truncator::words($this->text, $wordCount, $end);

 return $this;
 }

 /**
 * Convert to a slug.
 */
 public function slugify(string $separator = '-'): self
 {
 $this->text = Slugifier::make($this->text, $separator);

 return $this;
 }

 /**
 * Apply a custom transformation.
 */
 public function pipe(callable $callback): self
 {
 $this->text = $callback($this->text);

 return $this;
 }

 /**
 * Get the final result.
 */
 public function get(): string
 {
 return $this->text;
 }

 /**
 * Get the final result (alias for get).
 */
 public function __toString(): string
 {
 return $this->text;
 }
}

Now users can compose transformations:

use Jakovic\TextToolkit\TextPipeline;

// Convert Markdown blog post to a meta description
$meta = TextPipeline::make($markdownContent)
 ->stripMarkdown()
 ->normalizeWhitespace()
 ->truncate(160)
 ->get();

// Generate a slug from Markdown heading
$slug = TextPipeline::make('## My **Bold** Heading!')
 ->stripMarkdown()
 ->slugify()
 ->get();
// "my-bold-heading"

// Custom transformation in the pipeline
$result = TextPipeline::make($userInput)
 ->stripTags()
 ->pipe(fn(string $text) => str_replace('foo', 'bar', $text))
 ->truncate(200)
 ->get();

The pipe() method is especially powerful - it lets users insert any custom transformation into the chain without us having to anticipate every possible use case. And the __toString() magic method means you can use a pipeline directly in string contexts without calling get():

echo TextPipeline::make($content)->stripMarkdown()->truncate(100);
// Works directly - no ->get() needed

The Complete Directory Structure

After all our additions, the package now looks like this:

text-toolkit/
├── composer.json
├── src/
├── Contracts/
 ├── ExcerptInterface.php
 ├── MarkdownConverterInterface.php
 ├── SlugifierInterface.php
 └── TruncatorInterface.php
├── Excerpt.php
├── MarkdownConverter.php
├── Slugifier.php
├── Str.php
├── TextPipeline.php
└── Truncator.php
└── vendor/

Six classes and four interfaces. Small enough to understand in an afternoon, powerful enough to be genuinely useful. This is the sweet spot for utility packages.

Putting It All Together - Manual Testing

Before we wrap up, let's write a comprehensive test script that exercises everything. Create test.php in the package root:

<?php

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

use Jakovic\TextToolkit\Str;
use Jakovic\TextToolkit\TextPipeline;
use Jakovic\TextToolkit\Excerpt;
use Jakovic\TextToolkit\MarkdownConverter;

echo "=== Slugifier ===\n";
echo Str::slugify('Hello World!') . "\n";
// hello-world
echo Str::slugify('PHP Package Development - Part 3') . "\n";
// php-package-development-part-3
echo Str::slugify('What is CLEAN code?') . "\n";
// what-is-clean-code

echo "\n=== Truncator ===\n";
$text = 'The quick brown fox jumps over the lazy dog and then runs away into the forest';
echo Str::truncate($text, 30) . "\n";
// The quick brown fox jumps...
echo Str::words($text, 5) . "\n";
// The quick brown fox jumps...

echo "\n=== Excerpt ===\n";
$html = '<p>PHP is a general-purpose scripting language. It is especially suited
to web development. It was originally created by Rasmus Lerdorf in 1994.</p>
<p>PHP has evolved significantly over the years.</p>';
echo Str::excerpt($html, 80) . "\n";
echo Str::excerptSentences($html, 1) . "\n";
echo Excerpt::firstParagraph($html) . "\n";

echo "\n=== Markdown Converter ===\n";
$md = "# Hello\n\nThis is **bold** and *italic*.\n\n- Item one\n- Item two";
echo Str::stripMarkdown($md) . "\n";
echo "---\n";
echo Str::stripMarkdownSingleLine($md) . "\n";

echo "\n=== TextPipeline ===\n";
$blogPost = "## My **Awesome** Blog Post\n\nThis is the *first* paragraph with [a link](https://example.com).";
$meta = TextPipeline::make($blogPost)
 ->stripMarkdown()
 ->normalizeWhitespace()
 ->truncate(60)
 ->get();
echo "Meta: {$meta}\n";

$slug = TextPipeline::make('## My **Bold** Heading!')
 ->stripMarkdown()
 ->slugify()
 ->get();
echo "Slug: {$slug}\n";

echo "\n=== Interface Check ===\n";
$slugifier = new \Jakovic\TextToolkit\Slugifier();
echo ($slugifier instanceof \Jakovic\TextToolkit\Contracts\SlugifierInterface)
 ? "Slugifier implements SlugifierInterface - OK\n"
 : "FAIL\n";

echo "\nAll checks passed!\n";
php test.php

Run this and verify the output makes sense. This is manual testing - quick, dirty, and useful for development. In Part 4, we'll replace this with proper automated tests using Pest that run on every code change.

Once you're satisfied, clean up the test files:

rm test.php test-excerpt.php test-markdown.php

Common Mistakes to Avoid

Before we move on, here are the most common mistakes I see in early-stage packages:

Not handling empty input. Every public method should handle empty strings gracefully. Don't assume users will always pass valid data.

Using strlen instead of mb_strlen. If your package touches text, use multibyte functions. Period. It's 2026 - UTF-8 is not optional.

Too many required parameters. If a method needs five arguments, create a config object or use named parameters. Nobody wants to remember the order of five positional arguments.

Inconsistent naming. Pick a convention and stick with it. If one method uses make() as the factory, don't use create() on another. If one method says $limit for the character count, don't use $length somewhere else.

No return type declarations. Always declare return types. It makes your API self-documenting and catches bugs early. PHP 8.1+ makes this almost free with union types and intersection types.

Commit Your Progress

Let's save our work:

git add .
git commit -m "Add Excerpt, MarkdownConverter, interfaces, and TextPipeline"

What's Next

In Part 4: Testing Your Package with PHPUnit/Pest, we'll set up automated testing for everything we've built. We'll write unit tests for every method, handle edge cases, check code coverage, and set up a basic CI pipeline. Manual test scripts are fine for development, but automated tests are what make a package trustworthy.

Your homework: try extending the MarkdownConverter to handle tables, or add a titleCase() method to the Str facade. The best way to internalize these patterns is to write more code using them.

See you in Part 4!