Laravel Packages: Creating Your Own
Laravel's incredible ecosystem exists because developers share reusable code as packages. Everything from payment processing to image manipulation to PDF generation is available as a Composer package you can drop into any Laravel application. But what happens when you need something that doesn't exist yet — or when you find yourself copy-pasting the same code across multiple projects? That's the moment to build your own package.
Creating a Laravel package teaches you how the framework itself works from the inside. You'll learn about service providers, facades, configuration publishing, and the Composer autoloading system — all concepts that deepen your understanding of Laravel beyond everyday application development.
A good rule of thumb: if you've copied the same code into three or more projects, or if a feature has zero business logic (it's purely technical infrastructure), it belongs in a package. Typical candidates are API wrappers, notification channels, admin tools, and helpers.
Understanding How Laravel Packages Work
Laravel packages are standard Composer packages that hook into the framework via Service Providers. A service provider registers bindings in the IoC container, registers routes, loads views, publishes configuration files, and bootstraps any other resources your package needs.
There are two kinds of packages:
- Framework-agnostic packages — pure PHP libraries with no Laravel dependency (e.g., a math utility). Laravel can use them, but so can any PHP project.
- Laravel-specific packages — packages that depend on
illuminate/supportor the full framework. They ship service providers, facades, Artisan commands, Blade components, and more.
This guide focuses on the second type: packages that provide a first-class Laravel experience.
How a Laravel package integrates into an application
Package Directory Structure
A well-structured package is easy to navigate and maintain. Here is the standard layout for a Laravel package named acme/notifier:
acme/notifier/
├── config/
│ └── notifier.php # Default configuration
├── database/
│ └── migrations/
│ └── create_notifications_table.php
├── resources/
│ └── views/
│ └── email.blade.php # Package views
├── routes/
│ └── web.php # Optional package routes
├── src/
│ ├── Contracts/
│ │ └── NotifierInterface.php
│ ├── Facades/
│ │ └── Notifier.php # Facade class
│ ├── Notifier.php # Main service class
│ └── NotifierServiceProvider.php
├── tests/
│ ├── Feature/
│ └── Unit/
├── .gitignore
├── composer.json
├── phpunit.xml
└── README.md
Setting Up the Package
The fastest way to scaffold a new package is to create a separate Git repository and develop it locally via Composer's path repository type. This avoids publishing to Packagist while you iterate.
Create the package directory
Outside your main Laravel app, create a folder for your package, e.g. ~/packages/acme/notifier.
Initialize composer.json
Run composer init inside the package directory and fill in the prompts, or write the file directly.
{
"name": "acme/notifier",
"description": "A flexible notification package for Laravel",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Your Name",
"email": "you@example.com"
}
],
"require": {
"php": "^8.1",
"illuminate/support": "^10.0|^11.0"
},
"require-dev": {
"orchestra/testbench": "^8.0|^9.0",
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"Acme\\Notifier\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Acme\\Notifier\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"Acme\\Notifier\\NotifierServiceProvider"
],
"aliases": {
"Notifier": "Acme\\Notifier\\Facades\\Notifier"
}
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
The extra.laravel section is read by Laravel's package auto-discovery mechanism (introduced in Laravel 5.5). When users install your package with Composer, Laravel automatically registers the service provider and facade — no manual step required.
Link it to your Laravel app
In your main app's composer.json, add a path repository pointing to your local package folder, then require it.
{
"repositories": [
{
"type": "path",
"url": "../packages/acme/notifier"
}
],
"require": {
"acme/notifier": "@dev"
}
}
composer update acme/notifier
Composer will symlink the package directory into vendor/acme/notifier so every change you make in the package source is immediately reflected in the app without re-running Composer.
Writing the Service Provider
The service provider is the entry point of your package. Laravel calls its register() method before the application boots (bind things into the container here) and boot() after all providers are registered (use other services, register routes, views, etc.).
<?php
namespace Acme\Notifier;
use Illuminate\Support\ServiceProvider;
class NotifierServiceProvider extends ServiceProvider
{
/**
* Register bindings in the container.
* Called before boot() — do NOT use other services here.
*/
public function register(): void
{
// Merge package config with any published version
$this->mergeConfigFrom(
__DIR__ . '/../config/notifier.php',
'notifier'
);
// Bind the main service as a singleton
$this->app->singleton(Notifier::class, function ($app) {
return new Notifier(
$app['config']->get('notifier'),
$app['log']
);
});
// Bind the interface to the implementation
$this->app->bind(
Contracts\NotifierInterface::class,
Notifier::class
);
}
/**
* Bootstrap package services.
* All providers are registered by this point.
*/
public function boot(): void
{
// Publish configuration file
$this->publishes([
__DIR__ . '/../config/notifier.php' => config_path('notifier.php'),
], 'notifier-config');
// Publish and load migrations
$this->publishes([
__DIR__ . '/../database/migrations' => database_path('migrations'),
], 'notifier-migrations');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Load package views
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'notifier');
// Publish views so users can override them
$this->publishes([
__DIR__ . '/../resources/views' => resource_path('views/vendor/notifier'),
], 'notifier-views');
// Register package routes
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
// Register Artisan commands
if ($this->app->runningInConsole()) {
$this->commands([
Console\SendNotificationCommand::class,
]);
}
}
}
register() should only bind things into the service container. Never attempt to use a service that might not have been registered yet. boot() is called after ALL providers are registered, so it's safe to resolve other services, call Route::middleware(), or listen to events.
Creating a Facade
A facade gives your package a clean, static-style API: Notifier::send($message) instead of app(Notifier::class)->send($message). Under the hood, facades resolve the binding from the container on every call — they're not truly static, so they remain mockable in tests.
<?php
namespace Acme\Notifier\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @method static void send(string $channel, string $message, array $options = [])
* @method static array channels()
* @method static \Acme\Notifier\Notifier channel(string $name)
*
* @see \Acme\Notifier\Notifier
*/
class Notifier extends Facade
{
protected static function getFacadeAccessor(): string
{
// Must match the key used in app->singleton() or app->bind()
return \Acme\Notifier\Notifier::class;
}
}
The @method PHPDoc annotations give IDE autocompletion even though the methods appear static. The real class is resolved dynamically each time a static call is made.
The Main Service Class
<?php
namespace Acme\Notifier;
use Acme\Notifier\Contracts\NotifierInterface;
use Illuminate\Contracts\Logging\Log;
class Notifier implements NotifierInterface
{
protected array $config;
protected Log $logger;
protected array $resolvedChannels = [];
public function __construct(array $config, Log $logger)
{
$this->config = $config;
$this->logger = $logger;
}
public function send(string $channel, string $message, array $options = []): void
{
$driver = $this->channel($channel);
$driver->deliver($message, array_merge($this->config['defaults'] ?? [], $options));
$this->logger->info("Notifier: sent via {$channel}", ['message' => $message]);
}
public function channel(string $name): ChannelDriver
{
if (!isset($this->resolvedChannels[$name])) {
$driverClass = $this->config['channels'][$name]['driver']
?? throw new \InvalidArgumentException("Channel [{$name}] not configured.");
$this->resolvedChannels[$name] = new $driverClass(
$this->config['channels'][$name]
);
}
return $this->resolvedChannels[$name];
}
public function channels(): array
{
return array_keys($this->config['channels'] ?? []);
}
}
Configuration, Migrations & Views
Publishing allows users to customize your package defaults without editing vendor files. There are two complementary mechanisms: publishes() copies files on demand, and mergeConfigFrom() ensures your defaults always apply even if the user never publishes.
Place defaults in config/notifier.php:
<?php
return [
/*
* Default channel used when none is specified.
*/
'default' => env('NOTIFIER_CHANNEL', 'slack'),
/*
* Default options merged into every send() call.
*/
'defaults' => [
'timeout' => 5,
'retry' => 2,
],
/*
* Channel driver configurations.
*/
'channels' => [
'slack' => [
'driver' => \Acme\Notifier\Drivers\SlackDriver::class,
'webhook' => env('SLACK_WEBHOOK_URL'),
],
'discord' => [
'driver' => \Acme\Notifier\Drivers\DiscordDriver::class,
'webhook' => env('DISCORD_WEBHOOK_URL'),
],
],
];
// Users publish this file and override values:
// php artisan vendor:publish --tag=notifier-config
Migrations live in database/migrations/. Use a timestamp-based name so they don't collide with app migrations:
<?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('notifier_logs', function (Blueprint $table) {
$table->id();
$table->string('channel');
$table->text('message');
$table->json('options')->nullable();
$table->string('status')->default('sent');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notifier_logs');
}
};
// Load without publishing (runs automatically):
// $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Publish to let users inspect/edit:
// php artisan vendor:publish --tag=notifier-migrations
Views use a namespace so they don't clash with the app's view files:
{{-- resources/views/email.blade.php --}}
<!DOCTYPE html>
<html>
<body>
<h1>{{ $subject }}</h1>
<p>{{ $message }}</p>
<p>Sent via Notifier by <a href="{{ $senderUrl }}">{{ $senderName }}</a></p>
</body>
</html>
<!-- Referenced with the package namespace: notifier:: -->
<?php
// In PHP code:
return view('notifier::email', [
'subject' => 'Hello',
'message' => 'This is a test.',
'senderName' => config('app.name'),
'senderUrl' => config('app.url'),
]);
// Users can override by publishing:
// php artisan vendor:publish --tag=notifier-views
// Then edit: resources/views/vendor/notifier/email.blade.php
Route files are loaded with loadRoutesFrom(). Prefix them to avoid conflicts:
<?php
// routes/web.php
use Illuminate\Support\Facades\Route;
use Acme\Notifier\Http\Controllers\WebhookController;
Route::prefix('notifier')
->middleware(['web'])
->name('notifier.')
->group(function () {
// Receive incoming webhook callbacks
Route::post('/webhook/{channel}', WebhookController::class)
->name('webhook');
// Status dashboard (guarded by auth middleware)
Route::get('/dashboard', function () {
return view('notifier::dashboard');
})->middleware('auth')->name('dashboard');
});
Adding Artisan Commands
Artisan commands shipped in a package are registered in the service provider's boot() method inside an if ($this->app->runningInConsole()) guard so they don't load during every web request.
<?php
namespace Acme\Notifier\Console;
use Acme\Notifier\Facades\Notifier;
use Illuminate\Console\Command;
class SendNotificationCommand extends Command
{
protected $signature = 'notifier:send
{message : The message to send}
{--channel= : Override the default channel}';
protected $description = 'Send a notification via the configured channel';
public function handle(): int
{
$channel = $this->option('channel') ?? config('notifier.default');
$message = $this->argument('message');
$this->info("Sending to [{$channel}]...");
try {
Notifier::send($channel, $message);
$this->info('Notification sent successfully.');
} catch (\Exception $e) {
$this->error('Failed: ' . $e->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
}
// Usage:
// php artisan notifier:send "Deploy complete" --channel=discord
Testing Your Package
Testing packages requires a real Laravel application environment, which is what Orchestra Testbench provides. It's essentially a lightweight Laravel app you control entirely within your test setup.
Install Orchestra Testbench
Add it to require-dev in your package's composer.json, then run composer install inside the package folder.
<?php
namespace Acme\Notifier\Tests;
use Acme\Notifier\NotifierServiceProvider;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
abstract class TestCase extends OrchestraTestCase
{
/**
* Register the package service provider(s) for the test app.
*/
protected function getPackageProviders($app): array
{
return [
NotifierServiceProvider::class,
];
}
/**
* Register facade aliases.
*/
protected function getPackageAliases($app): array
{
return [
'Notifier' => \Acme\Notifier\Facades\Notifier::class,
];
}
/**
* Seed the test app configuration.
*/
protected function defineEnvironment($app): void
{
$app['config']->set('notifier.default', 'fake');
$app['config']->set('notifier.channels.fake', [
'driver' => \Acme\Notifier\Tests\Fakes\FakeDriver::class,
]);
}
}
<?php
namespace Acme\Notifier\Tests\Feature;
use Acme\Notifier\Facades\Notifier;
use Acme\Notifier\Tests\TestCase;
class NotifierTest extends TestCase
{
/** @test */
public function it_resolves_from_the_container(): void
{
$notifier = $this->app->make(\Acme\Notifier\Notifier::class);
$this->assertInstanceOf(\Acme\Notifier\Notifier::class, $notifier);
}
/** @test */
public function it_sends_via_the_default_channel(): void
{
// Act
Notifier::send('fake', 'Hello from tests');
// Assert — the FakeDriver records all messages
$messages = Notifier::channel('fake')->sent();
$this->assertCount(1, $messages);
$this->assertSame('Hello from tests', $messages[0]);
}
/** @test */
public function it_throws_for_unconfigured_channel(): void
{
$this->expectException(\InvalidArgumentException::class);
Notifier::send('nonexistent', 'Will fail');
}
/** @test */
public function it_lists_available_channels(): void
{
$channels = Notifier::channels();
$this->assertContains('fake', $channels);
}
}
// Run tests from the package directory:
// ./vendor/bin/phpunit
Unlike application code where manual testing catches regressions quickly, package bugs often only surface in specific app configurations. A solid test suite with Orchestra Testbench is the best protection against breaking users' apps when you release updates.
Publishing to Packagist
Once your package is stable and tested, you can share it with the world via Packagist — the default Composer package repository.
Push to GitHub
Create a public GitHub repository and push your package. Make sure composer.json is at the root and your autoload namespaces are correct.
Register on Packagist
Log in to packagist.org, click "Submit", paste your GitHub URL, and click "Check". Packagist will validate your composer.json.
Tag a stable release
Packagist tracks your Git tags. Create a semantic version tag (v1.0.0) and Packagist will index it as a stable release within minutes.
# Create and push a version tag
git tag v1.0.0
git push origin v1.0.0
# Users can now require the package:
# composer require acme/notifier
# Or specify a version constraint:
# composer require acme/notifier:"^1.0"
Set up a GitHub webhook
In your Packagist profile, copy your API token. In GitHub, add a webhook pointing to https://packagist.org/api/github?username=YOUR_USERNAME with your token so Packagist auto-updates on every push.
Package Development Best Practices
| Practice | Why It Matters |
|---|---|
| Use interfaces for the core service | Users can swap implementations; tests can mock easily |
| Tag publishes with specific group names | Users can publish only what they need (--tag=notifier-config) |
Guard commands with runningInConsole() |
Avoids loading heavy CLI dependencies on web requests |
Use mergeConfigFrom() in register() |
Defaults always work even if users never publish the config |
Require only illuminate/support, not laravel/framework |
Keeps the package installable in Lumen and non-standard setups |
| Keep a CHANGELOG.md | Users know what changed between versions before upgrading |
| Set up GitHub Actions CI | Run tests against multiple PHP and Laravel versions on every PR |
Artisan vendor:publish Tags Cheatsheet
| Command | What it does |
|---|---|
php artisan vendor:publish --tag=notifier-config | Copies config/notifier.php to the app's config dir |
php artisan vendor:publish --tag=notifier-migrations | Copies migrations so users can customise them |
php artisan vendor:publish --tag=notifier-views | Copies views to resources/views/vendor/notifier/ |
php artisan vendor:publish --provider="Acme\Notifier\NotifierServiceProvider" | Publishes everything registered by this provider |
php artisan vendor:publish --force | Overwrites already-published files |
Iterative package development: write, test, integrate, release
Conclusion
Building a Laravel package is one of the most rewarding ways to deepen your framework expertise. You've now seen the complete picture: scaffold a Composer package, wire it into Laravel via a service provider, expose a clean API through a facade, publish configuration and migrations for user customization, write tests with Orchestra Testbench, and finally share the package on Packagist.
Key Takeaways
- Service providers are the glue between your package and the Laravel application —
register()for bindings,boot()for everything else. - Auto-discovery via the
extra.laravelkey incomposer.jsonmeans users get zero-config installation. - mergeConfigFrom() ensures your default config always works, even without publishing.
- Orchestra Testbench gives you a real Laravel environment in tests without shipping a full app alongside your package.
- Semantic versioning and a CHANGELOG protect your users when you introduce breaking changes.
"The best packages do one thing well, ship sensible defaults, and get out of the developer's way."
Start small. Extract that utility class you've copied between three projects, wrap it in a service provider, push it to GitHub, and tag a release. The Laravel ecosystem grows every time a developer decides to share rather than copy-paste.