Backend

Laravel Packages: Creating Your Own

Mayur Dabhi
Mayur Dabhi
May 16, 2026
15 min read

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.

When to Extract a Package

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:

This guide focuses on the second type: packages that provide a first-class Laravel experience.

composer.json defines package Service Provider boots package IoC Container binds services Facade / Helper exposes API App Code uses it Laravel Package Lifecycle Composer autoloads the class, Laravel discovers and boots the provider at startup

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:

Package directory layout
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.

1

Create the package directory

Outside your main Laravel app, create a folder for your package, e.g. ~/packages/acme/notifier.

2

Initialize composer.json

Run composer init inside the package directory and fill in the prompts, or write the file directly.

composer.json
{
    "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.

3

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.

App composer.json — local development setup
{
    "repositories": [
        {
            "type": "path",
            "url": "../packages/acme/notifier"
        }
    ],
    "require": {
        "acme/notifier": "@dev"
    }
}
Terminal
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.).

src/NotifierServiceProvider.php
<?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() vs boot()

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.

src/Facades/Notifier.php
<?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

src/Notifier.php
<?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.

src/Console/SendNotificationCommand.php
<?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.

1

Install Orchestra Testbench

Add it to require-dev in your package's composer.json, then run composer install inside the package folder.

tests/TestCase.php — base class for all tests
<?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,
        ]);
    }
}
tests/Feature/NotifierTest.php
<?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
Don't Skip Tests

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.

1

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.

2

Register on Packagist

Log in to packagist.org, click "Submit", paste your GitHub URL, and click "Check". Packagist will validate your composer.json.

3

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.

Terminal — tagging a release
# 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"
4

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

CommandWhat it does
php artisan vendor:publish --tag=notifier-configCopies config/notifier.php to the app's config dir
php artisan vendor:publish --tag=notifier-migrationsCopies migrations so users can customise them
php artisan vendor:publish --tag=notifier-viewsCopies views to resources/views/vendor/notifier/
php artisan vendor:publish --provider="Acme\Notifier\NotifierServiceProvider"Publishes everything registered by this provider
php artisan vendor:publish --forceOverwrites already-published files
Write Code src/ + tests/ Run Tests phpunit / pest Test in App path: repository Tag Release git tag v1.0.0 Packagist live! iterate if tests fail Package Development Workflow

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.laravel key in composer.json means 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.

Laravel Packages Development PHP Composer Service Provider
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.