Part 8 of 13 in the PHP Package Development series
For the first seven posts in this series, we built jakovic/text-toolkit - a framework-agnostic PHP package. It works everywhere: plain PHP scripts, Laravel apps, Symfony projects, wherever. And that's the right approach for core logic. But if your users are mostly Laravel developers, they expect certain things. They want a Facade. They want to publish a config file. They want Blade directives. They want it to just work after composer require.
That's what a Laravel package wrapper gives you. In this post, we'll build jakovic/laravel-text-toolkit - a Laravel-specific package that wraps our existing text-toolkit library and adds all the Laravel niceties on top.
Why Build a Separate Laravel Package?
You might be wondering - why not just add Laravel support directly to jakovic/text-toolkit? Some packages do that. They detect if Laravel is installed and conditionally register a service provider. But there are good reasons to keep them separate.
First, separation of concerns. Your core package shouldn't depend on Laravel. Developers using Slim, Symfony, or plain PHP shouldn't have to pull in illuminate/support just because you bundled Laravel features alongside the core logic.
Second, versioning flexibility. Your Laravel wrapper might need a new major version when Laravel 12 drops, but the core library stays the same. Keeping them separate means you can version each independently.
Third, cleaner dependency tree. The Laravel wrapper requires illuminate/support and your base package. The base package requires nothing but PHP. Each package carries only the dependencies it actually needs.
This is exactly how the most popular packages in the ecosystem work. Think of league/flysystem (core) and illuminate/filesystem (Laravel integration). Or spatie/image (core) and spatie/laravel-image (wrapper). It's a proven pattern.
Package Directory Structure
Let's start by creating our Laravel package from scratch. Create a new directory alongside your original text-toolkit package - not inside it.
mkdir laravel-text-toolkit
cd laravel-text-toolkit
Here's the directory structure we're building toward:
laravel-text-toolkit/
├── config/
│ └── text-toolkit.php
├── resources/
│ └── views/
│ └── text-stats.blade.php
├── routes/
│ └── web.php
├── src/
│ ├── Facades/
│ │ └── TextToolkit.php
│ └── TextToolkitServiceProvider.php
├── tests/
├── composer.json
├── LICENSE
└── README.md
A few things to notice. The config/ directory holds configuration files that users can publish to their app. The resources/views/ directory contains Blade templates. The routes/ directory has route definitions. And src/ contains your PHP classes - the service provider, facades, and any other code.
This structure isn't enforced by Laravel, but it's the convention used by virtually every Laravel package. Stick with it so developers know where to find things.
Setting Up composer.json
The composer.json for a Laravel package looks a bit different from a plain PHP package. Let's build it up.
{
"name": "jakovic/laravel-text-toolkit",
"description": "Laravel integration for the Text Toolkit package",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.1",
"jakovic/text-toolkit": "^1.0",
"illuminate/support": "^10.0|^11.0|^12.0"
},
"require-dev": {
"orchestra/testbench": "^8.0|^9.0|^10.0",
"phpunit/phpunit": "^10.0|^11.0"
},
"autoload": {
"psr-4": {
"Jakovic\\LaravelTextToolkit\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Jakovic\\LaravelTextToolkit\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"Jakovic\\LaravelTextToolkit\\TextToolkitServiceProvider"
],
"aliases": {
"TextToolkit": "Jakovic\\LaravelTextToolkit\\Facades\\TextToolkit"
}
}
}
}
Let's break down the important parts.
The require section lists two key dependencies. First, jakovic/text-toolkit - our base package with all the actual logic. Second, illuminate/support - the Laravel component that gives us the ServiceProvider base class, the Facade class, and other framework utilities. We support multiple Laravel versions using the pipe (|) syntax.
The require-dev section includes orchestra/testbench, which is the standard tool for testing Laravel packages outside a full Laravel app. We'll cover that in detail in Post 10.
The extra.laravel section is where the magic of package auto-discovery happens. We'll talk about that more in a moment.
The Service Provider - The Heart of Your Laravel Package
If there's one thing you need to understand about Laravel packages, it's the service provider. It's the entry point - the place where your package introduces itself to Laravel and says "here's what I bring to the table." Every Laravel package has at least one.
A service provider has two key methods: register() and boot(). Understanding the difference between them is crucial.
register()- Bind things into the service container. This runs before any other providers have booted. Don't try to use other services here - they might not be available yet.boot()- Do everything else: load routes, views, publish config files, register Blade directives. All providers have been registered at this point, so you can safely use any service.
Let's create our service provider at src/TextToolkitServiceProvider.php:
<?php
namespace Jakovic\LaravelTextToolkit;
use Jakovic\TextToolkit\TextToolkit;
use Illuminate\Support\ServiceProvider;
class TextToolkitServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(
__DIR__ . '/../config/text-toolkit.php',
'text-toolkit'
);
$this->app->singleton(TextToolkit::class, function ($app) {
$config = $app['config']->get('text-toolkit');
$toolkit = new TextToolkit();
if ($config['default_separator'] ?? null) {
$toolkit->setSeparator($config['default_separator']);
}
return $toolkit;
});
$this->app->alias(TextToolkit::class, 'text-toolkit');
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/text-toolkit.php' => config_path('text-toolkit.php'),
], 'text-toolkit-config');
}
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'text-toolkit');
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../resources/views' => resource_path('views/vendor/text-toolkit'),
], 'text-toolkit-views');
}
}
}
That's a lot happening, so let's walk through it piece by piece.
The register() Method
mergeConfigFrom() loads your package's default config file and merges it with whatever the user has published. This way, your package always has sensible defaults even if the user never publishes the config.
The singleton() binding tells Laravel's service container: "When someone asks for TextToolkit::class, create one instance using this closure and reuse it everywhere." Inside the closure, we read the config values and use them to set up the TextToolkit object.
The alias() call lets people resolve the class using the string 'text-toolkit' instead of the full class name. This is mostly used internally by the Facade.
The boot() Method
publishes() registers files that users can publish to their own app using php artisan vendor:publish. The second argument is a tag - it lets users publish specific groups of files instead of everything at once. We wrap it in runningInConsole() because there's no point registering publishable assets when handling an HTTP request.
loadRoutesFrom() registers your package's routes with the Laravel router.
loadViewsFrom() tells Laravel where to find your Blade templates. The second argument ('text-toolkit') is a namespace, so views are accessed as text-toolkit::view-name.
The Configuration File
Create the config file at config/text-toolkit.php:
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Slug Separator
|--------------------------------------------------------------------------
|
| The character used to separate words in slugs. The most common options
| are hyphens (-) and underscores (_).
|
*/
'default_separator' => '-',
/*
|--------------------------------------------------------------------------
| Truncation Suffix
|--------------------------------------------------------------------------
|
| The string appended when text is truncated. Common options include
| '...' and '…' for HTML contexts.
|
*/
'truncation_suffix' => '...',
/*
|--------------------------------------------------------------------------
| Enable Routes
|--------------------------------------------------------------------------
|
| Set this to false to disable the package's built-in routes.
| You might want to do this if you define your own routes that use
| the package's controllers.
|
*/
'enable_routes' => true,
];
Laravel convention is to use that block comment style with the line of dashes. It's not required, but it looks clean and matches what users see in Laravel's own config files. Users feel right at home.
After installing your package, users can publish this config by running:
php artisan vendor:publish --tag=text-toolkit-config
This copies the file to config/text-toolkit.php in their Laravel app. From there, they can change any value. Because we used mergeConfigFrom() in the service provider, any keys they don't override will fall back to your defaults.
Package Auto-Discovery
Before Laravel 5.5, installing a package meant manually adding the service provider to the providers array in config/app.php. Every single README had a section saying "add this line to your config." It was tedious and error-prone.
Package auto-discovery changed all of that. Remember this section in our composer.json?
"extra": {
"laravel": {
"providers": [
"Jakovic\\LaravelTextToolkit\\TextToolkitServiceProvider"
],
"aliases": {
"TextToolkit": "Jakovic\\LaravelTextToolkit\\Facades\\TextToolkit"
}
}
}
When a user runs composer require jakovic/laravel-text-toolkit, Laravel's auto-discovery reads this section and automatically registers your service provider and facade alias. Zero manual configuration. The package just works.
Under the hood, Laravel scans the extra.laravel key in every installed package's composer.json after each composer install or composer update. It writes the discovered providers and aliases to bootstrap/cache/packages.php.
If a user wants to opt out of auto-discovery for your package (maybe they need to control the registration order), they can add this to their app's composer.json:
"extra": {
"laravel": {
"dont-discover": [
"jakovic/laravel-text-toolkit"
]
}
}
But most users will never need to do that. Auto-discovery just works, and your README can focus on usage instead of installation boilerplate.
Creating a Facade
Facades are one of Laravel's most recognizable features. They give you a static-like syntax for calling methods on objects resolved from the service container. Love them or hate them, your package users will expect one.
Create 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 $limit = 200)
* @method static string stripMarkdown(string $markdown)
*
* @see \Jakovic\TextToolkit\TextToolkit
*/
class TextToolkit extends Facade
{
protected static function getFacadeAccessor(): string
{
return \Jakovic\TextToolkit\TextToolkit::class;
}
}
That's it. A Facade is just a class with one method: getFacadeAccessor(). It returns the key used to resolve the underlying object from the service container. Since we bound TextToolkit::class in our service provider, the facade knows exactly what to proxy to.
The @method docblock annotations are not strictly required, but they're important for developer experience. They give IDEs autocomplete support so developers can see all available methods when they type TextToolkit::. Without them, your IDE will complain about calling static methods that don't exist on the class.
Now users can do this anywhere in their Laravel app:
use Jakovic\LaravelTextToolkit\Facades\TextToolkit;
$slug = TextToolkit::slugify('Hello World');
// "hello-world"
$excerpt = TextToolkit::excerpt($longArticle, 150);
// First 150 characters with clean word break...
Adding Routes to Your Package
Not every package needs routes, but some do. Maybe you're building an admin panel, an API endpoint, or a dashboard widget. Our text-toolkit doesn't really need routes, but let's add a simple one for demonstration purposes - a route that shows text statistics.
Create routes/web.php:
<?php
use Illuminate\Support\Facades\Route;
Route::middleware('web')
->prefix('text-toolkit')
->group(function () {
Route::get('/stats', function () {
return view('text-toolkit::text-stats');
})->name('text-toolkit.stats');
});
A few things to note here. We apply the web middleware group so sessions, CSRF protection, and cookies work correctly. We use a prefix to avoid route name collisions with the user's app. And we namespace the view with text-toolkit:: to reference our package's views.
But wait - what if the user doesn't want your routes at all? They might conflict with existing routes or the user might want to define their own. That's where the config file comes in handy. Let's update the service provider to make routes conditional:
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/text-toolkit.php' => config_path('text-toolkit.php'),
], 'text-toolkit-config');
}
if (config('text-toolkit.enable_routes', true)) {
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
}
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'text-toolkit');
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../resources/views' => resource_path('views/vendor/text-toolkit'),
], 'text-toolkit-views');
}
}
Now users can set 'enable_routes' => false in their published config to disable the package routes entirely. This kind of configurability is what separates a good package from a great one.
Including Views and Blade Templates
Let's create the view that our route references. Create resources/views/text-stats.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Text Toolkit Stats</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; }
.stat { padding: 10px; margin: 5px 0; background: #f3f4f6; border-radius: 4px; }
.label { font-weight: bold; color: #374151; }
</style>
</head>
<body>
<h1>Text Toolkit</h1>
<form method="POST" action="{{ route('text-toolkit.stats') }}">
@csrf
<textarea name="text" rows="5" style="width:100%">{{ old('text', $text ?? '') }}</textarea>
<button type="submit">Analyze</button>
</form>
@if(isset($stats))
<div class="stat">
<span class="label">Words:</span> {{ $stats['words'] }}
</div>
<div class="stat">
<span class="label">Characters:</span> {{ $stats['characters'] }}
</div>
<div class="stat">
<span class="label">Slug:</span> {{ $stats['slug'] }}
</div>
@endif
</body>
</html>
Users can render any package view using the namespace syntax:
// In any controller or route
return view('text-toolkit::text-stats', ['text' => '', 'stats' => null]);
And if a user wants to customize the view, they publish it:
php artisan vendor:publish --tag=text-toolkit-views
This copies the views to resources/views/vendor/text-toolkit/ in the user's app. Laravel automatically checks the published location first, falling back to your package's views if no override is found. The user can modify the published views without touching your package at all.
Putting It All Together
Let's look at the complete file listing for the package. Here's every file we've created:
config/text-toolkit.php - Default configuration values.
src/TextToolkitServiceProvider.php - Registers bindings, loads config/routes/views, handles publishing.
src/Facades/TextToolkit.php - Static proxy to the TextToolkit class.
routes/web.php - Package routes with proper middleware and prefixing.
resources/views/text-stats.blade.php - A Blade template with namespace support.
composer.json - Dependencies, autoloading, and auto-discovery configuration.
Testing in a Fresh Laravel App
Before you publish your package, you should test it in an actual Laravel app. The easiest way is to use Composer's path repository feature, which lets you install a local package without publishing it to Packagist first.
First, create a fresh Laravel app:
composer create-project laravel/laravel test-app
cd test-app
Now, add your local package as a path repository. Edit the Laravel app's composer.json and add this repositories section:
{
"repositories": [
{
"type": "path",
"url": "../laravel-text-toolkit"
},
{
"type": "path",
"url": "../text-toolkit"
}
]
}
You need to add both packages as path repositories because laravel-text-toolkit depends on text-toolkit. Without the second entry, Composer won't know where to find the base package.
Now install the Laravel package:
composer require jakovic/laravel-text-toolkit:@dev
The @dev stability flag is needed because your local packages don't have tagged releases yet. Composer treats path repositories as dev stability by default.
If auto-discovery is working, you should see a message like:
Discovered Package: jakovic/laravel-text-toolkit
Let's verify everything is wired up. Open php artisan tinker:
// Test the facade
use Jakovic\LaravelTextToolkit\Facades\TextToolkit;
TextToolkit::slugify('Hello World');
// "hello-world"
// Test container resolution
app('text-toolkit')->slugify('Testing Container');
// "testing-container"
// Test config
config('text-toolkit.default_separator');
// "-"
Try publishing the config file:
php artisan vendor:publish --tag=text-toolkit-config
Check that config/text-toolkit.php appeared in your test app. Open it, change the default_separator to '_', and run tinker again to confirm it picks up the new value.
If your package has routes, start the dev server and visit them:
php artisan serve
# Visit http://localhost:8000/text-toolkit/stats
You should see your text stats page rendered using the package's Blade template.
Common Mistakes to Avoid
Before we wrap up, here are some pitfalls I see developers run into when building their first Laravel package.
Using config() in register(). The register() method runs before other providers have booted. If you call config() here, you might get unexpected values. Read config in closures passed to singleton() or bind() - those closures execute lazily when the service is first resolved.
Hardcoding paths. Always use __DIR__ relative paths in your service provider. Never assume the package lives in a specific vendor directory. __DIR__ . '/../config/text-toolkit.php' works regardless of where the package is installed.
Forgetting the web middleware on routes. If your routes use sessions, CSRF tokens, or cookies, they need the web middleware group. Without it, @csrf in your Blade templates will generate tokens that don't validate, and you'll get confusing 419 errors.
Not supporting multiple Laravel versions. Use the pipe syntax (^10.0|^11.0|^12.0) in your dependency constraints. Test against each supported version in CI. Your users might not be on the latest Laravel, and you want them to be able to use your package too.
Publishing everything with a single tag. Use separate tags for config, views, and migrations. Let users publish only what they need. Nobody wants their vendor:publish to dump 20 files they didn't ask for.
What's Next
We now have a working Laravel package with a service provider, configuration, routes, views, a facade, and auto-discovery. That's a solid foundation, but there's more to cover.
In Post 9, we'll dive deeper into Laravel package features: Blade components, database migrations, Artisan commands, middleware, and more. The real power of a Laravel package comes from how deeply it integrates with the framework, and we've only scratched the surface.
For now, try building a wrapper for one of your own packages - or even a simple package you use regularly. The best way to learn this is by doing it.