Part 9 of 13 in the PHP Package Development series

In Part 8, we covered the fundamentals of Laravel packages - service providers, config publishing, package discovery, views, and routes. That's enough to get a package working, but Laravel offers so much more. Facades, Blade components, custom Artisan commands, migrations, middleware - these are the features that make a Laravel package feel truly native.

In this post, we're going to take our jakovic/laravel-text-toolkit package and add all of these features. By the end, your users will be able to use your package through a Facade, render Blade components in their templates, run Artisan commands, and even apply middleware to their routes - all with zero configuration beyond installing the package.

Quick Recap: Where We Left Off

From Part 8, our package has a service provider, a config file, and basic package discovery. The directory structure looks something like this:

laravel-text-toolkit/
├── config/
└── text-toolkit.php
├── src/
└── TextToolkitServiceProvider.php
├── composer.json
└── README.md

We're about to expand this significantly. Let's dive in.

Building a Facade

Facades are one of Laravel's most recognizable features. They give you a clean, static-like syntax for accessing services from teh container. Instead of resolving the service manually every time, your users can just write TextToolkit::slugify('Hello World').

A Facade is surprisingly simple - it's just a class that extends Illuminate\Support\Facades\Facade and tells Laravel which container binding to proxy to.

Create the file at src/Facades/TextToolkit.php:

<?php

namespace Jakovic\LaravelTextToolkit\Facades;

use Illuminate\Support\Facades\Facade;

/**
 * @method static string slugify(string $text, string $separator = '-')
 * @method static string truncate(string $text, int $length = 100, string $suffix = '...')
 * @method static string excerpt(string $text, int $length = 200)
 * @method static string markdownToText(string $markdown)
 *
 * @see \Jakovic\TextToolkit\TextToolkit
 */
class TextToolkit extends Facade
{
 protected static function getFacadeAccessor(): string
 {
 return 'text-toolkit';
 }
}

The getFacadeAccessor() method returns the key that this Facade resolves from Laravel's service container. That means we need to make sure our service provider binds something to that key. Update your service provider's register() method:

public function register(): void
{
 $this->mergeConfigFrom(
 __DIR__ . '/../config/text-toolkit.php', 'text-toolkit'
 );

 $this->app->singleton('text-toolkit', function ($app) {
 return new \Jakovic\TextToolkit\TextToolkit();
 });
}

Notice we're binding to the string 'text-toolkit' - the exact same string returned by getFacadeAccessor(). That's the connection.

The @method docblock annotations on the Facade class aren't just decoration. They're what makes IDE autocompletion work. Without them, your IDE would have no idea what methods are available since the actual method calls are proxied through __callStatic(). Always add these - your users will thank you.

Now let's register the Facade alias so users don't have to import the full namespace. In your service provider's boot() method, you don't actually need to do anything extra - Laravel's package discovery handles it. Just add the alias to your composer.json:

{
 "extra": {
 "laravel": {
 "providers": [
 "Jakovic\\LaravelTextToolkit\\TextToolkitServiceProvider"
 ],
 "aliases": {
 "TextToolkit": "Jakovic\\LaravelTextToolkit\\Facades\\TextToolkit"
 }
 }
 }
}

With this in place, users can use the Facade anywhere in their app:

use Jakovic\LaravelTextToolkit\Facades\TextToolkit;

$slug = TextToolkit::slugify('Hello World'); // "hello-world"
$short = TextToolkit::truncate('A very long string...', 10); // "A very lon..."

Or, thanks to the alias we registered in composer.json, they can skip the import entirely and just use \TextToolkit::slugify('Hello World').

Building Blade Components

Blade components let your users drop package functionality right into their templates with a clean, HTML-like syntax. This is where packages start to feel really polished.

Laravel supports two types of Blade components: class-based and anonymous (view-only). We'll build both.

Anonymous Blade Components

Anonymous components are the simplest - they're just Blade view files with no backing PHP class. Perfect for presentational components.

Create the directory resources/views/components/ in your package and add a truncate.blade.php file:

@props([
 'text' => '',
 'length' => 100,
 'suffix' => '...',
])

<span {{ $attributes }}>{{ \Jakovic\TextToolkit\TextToolkit::truncate($text, (int) $length, $suffix) }}</span>

Now users can use this in their Blade templates:

<x-text-toolkit::truncate text="This is a very long paragraph that should be shortened" :length="30" />

The prefix text-toolkit:: comes from how we register our views in the service provider. The part after :: maps to the component file name. Let's make sure the service provider loads these views properly.

Class-Based Blade Components

For more complex components that need logic, use a class-based component. Let's create a Slugify component that renders a slug and optionally wraps it in an anchor tag.

Create src/View/Components/Slugify.php:

<?php

namespace Jakovic\LaravelTextToolkit\View\Components;

use Jakovic\TextToolkit\TextToolkit;
use Illuminate\View\Component;

class Slugify extends Component
{
 public string $slug;

 public function __construct(
 public string $text,
 public string $separator = '-',
 public bool $asLink = false,
 public string $baseUrl = '/',
 ) {
 $toolkit = new TextToolkit();
 $this->slug = $toolkit->slugify($text, $separator);
 }

 public function render()
 {
 return view('text-toolkit::components.slugify');
 }
}

And the corresponding view at resources/views/components/slugify.blade.php:

@if($asLink)
 <a href="{{ $baseUrl }}{{ $slug }}" {{ $attributes }}>{{ $slug }}</a>
@else
 <span {{ $attributes }}>{{ $slug }}</span>
@endif

Usage in templates:

<!-- Simple slug output -->
<x-text-toolkit::slugify text="Hello World" />
<!-- Output: <span>hello-world</span> -->

<!-- As a link -->
<x-text-toolkit::slugify text="Hello World" :as-link="true" base-url="/posts/" class="text-blue-500" />
<!-- Output: <a href="/posts/hello-world" -->

Registering Blade Components in the Service Provider

Both types of components need to be registered. Update your service provider's boot() method:

use Jakovic\LaravelTextToolkit\View\Components\Slugify;
use Illuminate\Support\Facades\Blade;

public function boot(): void
{
 // Load views (required for both anonymous and class-based components)
 $this->loadViewsFrom(__DIR__ . '/../resources/views', 'text-toolkit');

 // Register class-based components with a prefix
 Blade::componentNamespace(
 'Jakovic\\LaravelTextToolkit\\View\\Components',
 'text-toolkit'
 );

 // Publish views so users can customize them
 $this->publishes([
 __DIR__ . '/../resources/views' => resource_path('views/vendor/text-toolkit'),
 ], 'text-toolkit-views');
}

The componentNamespace() call tells Laravel that any <x-text-toolkit::*> component should look for its backing class in the Jakovic\LaravelTextToolkit\View\Components namespace. If no class is found, Laravel falls back to looking for an anonymous Blade view in the registered views directory. This is why both our class-based Slugify component and anonymous truncate component work with the same prefix.

Blade Directives

Blade components are great for templates, but sometimes you want something even simpler - a quick inline directive like @slugify('Hello World') that outputs a value without any HTML wrapper. That's what custom Blade directives are for.

Add these to your service provider's boot() method:

public function boot(): void
{
 // ... existing boot code ...

 Blade::directive('slugify', function (string $expression) {
 return "<?php echo e(app('text-toolkit')->slugify($expression)); ?>";
 });

 Blade::directive('truncate', function (string $expression) {
 return "<?php echo e(app('text-toolkit')->truncate($expression)); ?>";
 });
}

A critical thing to understand: the $expression parameter is a raw string of PHP code, not an evaluated value. When someone writes @slugify('Hello World'), the $expression is literally the string 'Hello World' (with the quotes). That's why we embed it directly into the PHP output string - it gets evaluated at render time, not at compile time.

The e() helper is Laravel's HTML entity encoder. Always use it to prevent XSS when outputting user-provided content.

Usage in Blade templates:

<!-- Simple usage -->
<p>Slug: @slugify('Hello World')</p>
<!-- Output: Slug: hello-world -->

<!-- With variables -->
<p>Slug: @slugify($post->title)</p>

<!-- Truncate with multiple arguments -->
<p>@truncate($post->body, 150, '... read more')</p>

One gotcha with directives that accept multiple arguments: since $expression is a raw string, if someone passes @truncate($text, 50, '...'), the entire $text, 50, '...' is the expression. This works fine because it maps directly to the method parameters. But if you need to parse individual arguments from the expression, you'd need to split the string yourself. For simple pass-through cases like ours, it just works.

Package Migrations

Some packages need database tables. Maybe you're logging text transformations, caching results, or storing user preferences. Laravel makes it straightforward for packages to ship their own migrations.

Let's add a text_transformation_logs table that records every transformation performed through the package. This is useful for auditing, analytics, or debugging.

Create database/migrations/2024_01_01_000000_create_text_transformation_logs_table.php:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
 public function up(): void
 {
 Schema::create('text_transformation_logs', function (Blueprint $table) {
 $table->id();
 $table->string('transformation_type'); // slugify, truncate, excerpt, etc.
 $table->text('input');
 $table->text('output');
 $table->json('parameters')->nullable(); // any extra params (length, separator, etc.)
 $table->nullableMorphs('transformable'); // optional polymorphic relation
 $table->timestamps();

 $table->index('transformation_type');
 });
 }

 public function down(): void
 {
 Schema::dropIfExists('text_transformation_logs');
 }
};

Now register the migration in your service provider. You have two options, and the choice matters.

Option 1: Auto-load migrations (always run)

public function boot(): void
{
 $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
}

With this approach, your migrations run automatically when the user runs php artisan migrate. Simple, but the user has no choice in the matter - the table gets created whether they want it or not.

Option 2: Publishable migrations (user opts in)

public function boot(): void
{
 if (app()->runningInConsole()) {
 $this->publishesMigrations([
 __DIR__ . '/../database/migrations' => database_path('migrations'),
 ], 'text-toolkit-migrations');
 }
}

The publishesMigrations() method (available since Laravel 10) is the recommended approach. Users publish the migrations to their own database/migrations directory, giving them full control to modify columns, add indexes or skip migrations entirely. This is the respectful approach - your package is a guest in their application.

Users publish and run the migrations like this:

php artisan vendor:publish --tag=text-toolkit-migrations
php artisan migrate

You'll also want a model to work with the table. Create src/Models/TransformationLog.php:

<?php

namespace Jakovic\LaravelTextToolkit\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class TransformationLog extends Model
{
 protected $table = 'text_transformation_logs';

 protected $fillable = [
 'transformation_type',
 'input',
 'output',
 'parameters',
 ];

 protected $casts = [
 'parameters' => 'array',
 ];

 public function transformable(): MorphTo
 {
 return $this->morphTo();
 }
}

Now you can optionally log transformations. A nice pattern is to make this configurable in your config file:

// config/text-toolkit.php
return [
 'log_transformations' => env('TEXT_TOOLKIT_LOG', false),
];

Artisan Commands

Custom Artisan commands are one of the most useful things a package can offer. They give developers CLI access to your package's functionality - great for quick tasks, debugging, or automation scripts.

Let's build two commands: one for quick text transformations and one for managing the transformation logs.

The Slugify Command

Create src/Console/Commands/SlugifyCommand.php:

<?php

namespace Jakovic\LaravelTextToolkit\Console\Commands;

use Jakovic\TextToolkit\TextToolkit;
use Illuminate\Console\Command;

class SlugifyCommand extends Command
{
 protected $signature = 'text:slugify
 {text : The text to slugify}
 {--separator=- : The separator character to use}';

 protected $description = 'Convert a text string into a URL-friendly slug';

 public function handle(TextToolkit $toolkit): int
 {
 $text = $this->argument('text');
 $separator = $this->option('separator');

 $slug = $toolkit->slugify($text, $separator);

 $this->info("Input: {$text}");
 $this->info("Output: {$slug}");

 // Copy to clipboard hint
 $this->newLine();
 $this->line("<comment>Tip:</comment> Pipe to clipboard with: php artisan text:slugify \"{$text}\" | tail -1 | pbcopy");

 return Command::SUCCESS;
 }
}

Notice we're type-hinting TextToolkit in the handle() method. Laravel automatically resolves this from the container - dependency injection works in Artisan commands just like in controllers.

The Transform Command

Let's build a more interactive command that supports multiple transformation types:

<?php

namespace Jakovic\LaravelTextToolkit\Console\Commands;

use Jakovic\TextToolkit\TextToolkit;
use Illuminate\Console\Command;

class TransformCommand extends Command
{
 protected $signature = 'text:transform
 {text : The text to transform}
 {--type=slugify : The transformation type (slugify, truncate, excerpt, markdown-to-text)}
 {--length=100 : Length for truncate/excerpt}
 {--separator=- : Separator for slugify}';

 protected $description = 'Apply a text transformation';

 public function handle(TextToolkit $toolkit): int
 {
 $text = $this->argument('text');
 $type = $this->option('type');

 $result = match ($type) {
 'slugify' => $toolkit->slugify($text, $this->option('separator')),
 'truncate' => $toolkit->truncate($text, (int) $this->option('length')),
 'excerpt' => $toolkit->excerpt($text, (int) $this->option('length')),
 'markdown-to-text' => $toolkit->markdownToText($text),
 default => null,
 };

 if ($result === null) {
 $this->error("Unknown transformation type: {$type}");
 $this->line('Available types: slugify, truncate, excerpt, markdown-to-text');
 return Command::FAILURE;
 }

 $this->table(
 ['Property', 'Value'],
 [
 ['Type', $type],
 ['Input', $text],
 ['Output', $result],
 ]
 );

 return Command::SUCCESS;
 }
}

Usage examples:

# Slugify a string
php artisan text:slugify "Hello World"

# Slugify with custom separator
php artisan text:slugify "Hello World" --separator=_

# Truncate text
php artisan text:transform "A very long piece of text" --type=truncate --length=15

# Convert markdown to plain text
php artisan text:transform "# Hello **World**" --type=markdown-to-text

Registering Commands

Register your commands in the service provider. The convention is to only load commands when running in the console - there's no reason to register them during HTTP requests:

use Jakovic\LaravelTextToolkit\Console\Commands\SlugifyCommand;
use Jakovic\LaravelTextToolkit\Console\Commands\TransformCommand;

public function boot(): void
{
 // ... existing boot code ...

 if ($this->app->runningInConsole()) {
 $this->commands([
 SlugifyCommand::class,
 TransformCommand::class,
 ]);
 }
}

After installation, users can run php artisan list text to see all your commands grouped under the text: namespace. Clean and discoverable.

Middleware

Middleware lets your package hook into the HTTP request/response lifecycle. For a text toolkit, a practical middleware would be to automatically slugify route parameters - so users can pass "Hello World" as a URL segment and have it automatically converted to "hello-world" before the controller receives it.

Create src/Http/Middleware/SlugifyRouteParameters.php:

<?php

namespace Jakovic\LaravelTextToolkit\Http\Middleware;

use Jakovic\TextToolkit\TextToolkit;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SlugifyRouteParameters extends Middleware
{
 public function __construct(
 protected TextToolkit $toolkit
 ) {}

 public function handle(Request $request, Closure $next, string ...$parameters): Response
 {
 $route = $request->route();

 if ($route) {
 foreach ($parameters as $parameter) {
 $value = $route->parameter($parameter);

 if (is_string($value)) {
 $route->setParameter(
 $parameter,
 $this->toolkit->slugify($value)
 );
 }
 }
 }

 return $next($request);
 }
}

Wait - we need to fix that. The middleware should extend the base class properly:

<?php

namespace Jakovic\LaravelTextToolkit\Http\Middleware;

use Jakovic\TextToolkit\TextToolkit;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SlugifyRouteParameters
{
 public function __construct(
 protected TextToolkit $toolkit
 ) {}

 public function handle(Request $request, Closure $next, string ...$parameters): Response
 {
 $route = $request->route();

 if ($route) {
 // If no specific parameters are listed, slugify all string parameters
 $params = empty($parameters)
 ? array_keys($route->parameters())
 : $parameters;

 foreach ($params as $parameter) {
 $value = $route->parameter($parameter);

 if (is_string($value)) {
 $route->setParameter(
 $parameter,
 $this->toolkit->slugify($value)
 );
 }
 }
 }

 return $next($request);
 }
}

Laravel middleware doesn't need to extend any base class - it just needs a handle() method with the right signature. The variadic ...$parameters argument lets users specify which route parameters to slugify.

To register the middleware, add an alias in your service provider so users can reference it by a short name:

use Illuminate\Routing\Router;
use Jakovic\LaravelTextToolkit\Http\Middleware\SlugifyRouteParameters;

public function boot(): void
{
 // ... existing boot code ...

 $router = $this->app->make(Router::class);
 $router->aliasMiddleware('slugify', SlugifyRouteParameters::class);
}

Now users can apply it to routes:

// Slugify the 'title' parameter only
Route::get('/posts/{title}', [PostController::class, 'show'])
 ->middleware('slugify:title');

// Slugify all route parameters
Route::get('/posts/{category}/{title}', [PostController::class, 'show'])
 ->middleware('slugify');

// Or apply it to a route group
Route::middleware(['slugify:slug'])->group(function () {
 Route::get('/articles/{slug}', [ArticleController::class, 'show']);
 Route::get('/tags/{slug}', [TagController::class, 'show']);
});

This is a small thing, but it's the kind of convenience that makes developers love a package. Instead of manually slugifying in every controller method, they just slap a middleware on the route and forget about it.

Publishing Assets and Views

We've been registering publishable assets throughout this post. Let's bring it all together and look at the full picture of what users can publish and customize.

The key principle: everything should work out of the box, but everything should be customizable. Users should never need to publish anything for the package to function, but they should be able to override any part of it when they want to.

Here's the complete service provider with all publish groups:

<?php

namespace Jakovic\LaravelTextToolkit;

use Jakovic\LaravelTextToolkit\Console\Commands\SlugifyCommand;
use Jakovic\LaravelTextToolkit\Console\Commands\TransformCommand;
use Jakovic\LaravelTextToolkit\Http\Middleware\SlugifyRouteParameters;
use Jakovic\TextToolkit\TextToolkit;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;

class TextToolkitServiceProvider extends ServiceProvider
{
 public function register(): void
 {
 $this->mergeConfigFrom(
 __DIR__ . '/../config/text-toolkit.php', 'text-toolkit'
 );

 $this->app->singleton('text-toolkit', function ($app) {
 return new TextToolkit();
 });
 }

 public function boot(): void
 {
 // Views and components
 $this->loadViewsFrom(__DIR__ . '/../resources/views', 'text-toolkit');

 Blade::componentNamespace(
 'Jakovic\\LaravelTextToolkit\\View\\Components',
 'text-toolkit'
 );

 // Blade directives
 Blade::directive('slugify', function (string $expression) {
 return "<?php echo e(app('text-toolkit')->slugify($expression)); ?>";
 });

 Blade::directive('truncate', function (string $expression) {
 return "<?php echo e(app('text-toolkit')->truncate($expression)); ?>";
 });

 // Middleware
 $router = $this->app->make(Router::class);
 $router->aliasMiddleware('slugify', SlugifyRouteParameters::class);

 // Console-only registrations
 if ($this->app->runningInConsole()) {
 $this->commands([
 SlugifyCommand::class,
 TransformCommand::class,
 ]);

 // Publishable assets
 $this->publishes([
 __DIR__ . '/../config/text-toolkit.php' => config_path('text-toolkit.php'),
 ], 'text-toolkit-config');

 $this->publishes([
 __DIR__ . '/../resources/views' => resource_path('views/vendor/text-toolkit'),
 ], 'text-toolkit-views');

 $this->publishesMigrations([
 __DIR__ . '/../database/migrations' => database_path('migrations'),
 ], 'text-toolkit-migrations');
 }
 }
}

Users can publish exactly what they need:

# Publish everything
php artisan vendor:publish --provider="Jakovic\LaravelTextToolkit\TextToolkitServiceProvider"

# Publish only the config
php artisan vendor:publish --tag=text-toolkit-config

# Publish only the views (to customize Blade components)
php artisan vendor:publish --tag=text-toolkit-views

# Publish only the migrations
php artisan vendor:publish --tag=text-toolkit-migrations

A few best practices for publishable assets:

  • Use descriptive tag names - prefix with your package name to avoid collisions with other packages
  • Keep publish groups granular - users should be able to publish config without migrations, or views without config
  • Always wrap publishing in runningInConsole() - there's no reason to register publishable assets during HTTP requests
  • Document the publish commands - list them in your README so users know what's available

The Final Directory Structure

After adding everything in this post, your package directory should look like this:

laravel-text-toolkit/
├── config/
│ └── text-toolkit.php
├── database/
│ └── migrations/
│ └── 2024_01_01_000000_create_text_transformation_logs_table.php
├── resources/
│ └── views/
│ └── components/
│ ├── slugify.blade.php
│ └── truncate.blade.php
├── src/
│ ├── Console/
│ │ └── Commands/
│ │ ├── SlugifyCommand.php
│ │ └── TransformCommand.php
│ ├── Facades/
│ │ └── TextToolkit.php
│ ├── Http/
│ │ └── Middleware/
│ │ └── SlugifyRouteParameters.php
│ ├── Models/
│ │ └── TransformationLog.php
│ ├── View/
│ │ └── Components/
│ │ └── Slugify.php
│ └── TextToolkitServiceProvider.php
├── composer.json
└── README.md

Each directory has a clear purpose, and the structure follows Laravel's own conventions. Anyone familiar with Laravel will instantly know where to find things.

Tying It All Together

Let's see how an end user would actually use all these features in a real application. After installing the package with composer require jakovic/laravel-text-toolkit, they can:

// In a controller - using the Facade
use Jakovic\LaravelTextToolkit\Facades\TextToolkit;

class PostController extends Controller
{
 public function store(Request $request)
 {
 $post = Post::create([
 'title' => $request->title,
 'slug' => TextToolkit::slugify($request->title),
 'excerpt' => TextToolkit::excerpt($request->body, 200),
 'body' => $request->body,
 ]);

 return redirect()->route('posts.show', $post);
 }
}

<!-- In a Blade template - using components and directives -->
@foreach($posts as $post)
 <article>
 <h2>
 <x-text-toolkit::slugify text="{{ $post->title }}" :as-link="true" base-url="/posts/" />
 </h2>
 <p>
 <x-text-toolkit::truncate :text="$post->body" :length="150" />
 </p>
 <span>
 </article>
@endforeach

# From the terminal - using Artisan commands
php artisan text:slugify "My New Blog Post"
php artisan text:transform "Long text here..." --type=truncate --length=50

// In routes - using middleware
Route::get('/posts/{slug}', [PostController::class, 'show'])
 ->middleware('slugify:slug');

That's the power of a well-built Laravel package. Multiple ways to access the same functionality, each suited to a different context. The Facade works great in PHP code, Blade components and directives shine in templates, Artisan commands handle CLI workflows, and middleware automates HTTP-level concerns.

Common Pitfalls to Avoid

Before we wrap up, here are some mistakes I've seen (and made) when building Laravel packages with these features:

  • Forgetting to escape Blade directive output - always use e() in your directive's PHP string to prevent XSS vulnerabilities
  • Auto-loading migrations without a config flag - let users opt in. Not every user wants your database table
  • Hardcoding table names in models - always set $table explicitly. If you rely on Laravel's convention and a user changes the table name in the published migration, things break
  • Not wrapping console registrations in runningInConsole() - this wastes resources on every HTTP request
  • Naming middleware aliases too generically - using 'slugify' might conflict with another package. Consider prefixing with your package name for larger packages, like 'text-toolkit.slugify'
  • Missing @method docblocks on Facades - your users' IDEs will treat the Facade as a black box without them

What's Next

We've built a fully-featured Laravel package with a Facade, Blade components, Blade directives, database migrations, Artisan commands, and middleware. That's a lot of Laravel surface area covered in one post.

But here's the thing - none of this is trustworthy without tests. In Part 10, we'll cover testing Laravel packages using Orchestra Testbench. We'll write feature tests for our Artisan commands, test Blade components and directives render correctly, verify middleware behavior, and make sure everything works in a real Laravel environment without actually needing a full Laravel app.

See you there.