Building a Laravel Package: Service Providers, Auto-Discovery, and Config Merging
#laravel #packages #service-providers #php

Building a Laravel Package: Service Providers, Auto-Discovery, and Config Merging

3 min read Mohamed Said Mohamed Said

Why Package Architecture Matters

Dropping reusable code into app/ is fine for a single project. The moment that code needs to live in three codebases, or you want to open-source it, you need a real package. Laravel's package primitives — service providers, auto-discovery, and the config/view/migration publishing pipeline — are mature and opinionated. Understanding them at the seams saves hours of debugging.


Structuring the Package Root

A minimal layout that scales:

my-vendor/my-package/
├── src/
│   ├── MyPackageServiceProvider.php
│   ├── MyPackageManager.php
│   └── Console/
│       └── InstallCommand.php
├── config/
│   └── my-package.php
├── database/migrations/
├── resources/views/
├── tests/
├── composer.json
└── README.md

Keep src/ clean. Never put config or views inside src/ — the publishing pipeline expects them at the package root.


The Service Provider in Detail

namespace MyVendor\MyPackage;

use Illuminate\Support\ServiceProvider;

class MyPackageServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Merge package defaults under the host app's config.
        // Host values WIN — this is the correct merge direction.
        $this->mergeConfigFrom(
            __DIR__.'/../config/my-package.php',
            'my-package'
        );

        $this->app->singleton(MyPackageManager::class, function ($app) {
            return new MyPackageManager(
                $app['config']['my-package']
            );
        });
    }

    public function boot(): void
    {
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'my-package');

        if ($this->app->runningInConsole()) {
            $this->publishes([
                __DIR__.'/../config/my-package.php' => config_path('my-package.php'),
            ], 'my-package-config');

            $this->publishes([
                __DIR__.'/../database/migrations' => database_path('migrations'),
            ], 'my-package-migrations');

            $this->commands([
                Console\InstallCommand::class,
            ]);
        }
    }
}

register() vs boot()

register() is for binding — nothing else. Calling config() or resolving services here is a common mistake; other providers haven't run yet. boot() fires after all providers are registered, so it's safe to resolve bindings, load routes, and publish assets.


Auto-Discovery

Add the extra block to composer.json so Laravel registers your provider without any manual step in config/app.php:

{
    "extra": {
        "laravel": {
            "providers": [
                "MyVendor\\MyPackage\\MyPackageServiceProvider"
            ],
            "aliases": {
                "MyPackage": "MyVendor\\MyPackage\\Facades\\MyPackage"
            }
        }
    }
}

Laravel reads this during composer install/update and writes to bootstrap/providers.php (Laravel 11+) or bootstrap/cache/packages.php (Laravel 10). Users can opt out per-package via their own composer.json extra.laravel.dont-discover array.


Config Merging — The Subtle Trap

mergeConfigFrom does a shallow merge. Nested arrays in the host config do not deep-merge with package defaults:

// Package default
'options' => ['timeout' => 30, 'retries' => 3]

// Host config publishes only:
'options' => ['timeout' => 60]

// Result after mergeConfigFrom — retries is GONE
'options' => ['timeout' => 60]

For nested config, provide a helper or document that users must publish the full config. Alternatively, implement your own deep merge in register():

$this->app->afterResolving('config', function ($config) {
    $package = require __DIR__.'/../config/my-package.php';
    $host = $config->get('my-package', []);
    $config->set('my-package', array_replace_recursive($package, $host));
});

Testing the Package in Isolation

Use orchestra/testbench — it boots a minimal Laravel application around your package:

use Orchestra\Testbench\TestCase;

class MyPackageTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [MyPackageServiceProvider::class];
    }

    public function test_manager_resolves(): void
    {
        $manager = $this->app->make(MyPackageManager::class);
        $this->assertInstanceOf(MyPackageManager::class, $manager);
    }
}

Never test a package by symlinking it into a real app during CI — testbench gives you a reproducible, isolated environment.


Takeaways

  • Put bindings in register(), everything else in boot() — this order is not optional.
  • mergeConfigFrom is shallow; document it or implement array_replace_recursive for nested defaults.
  • Auto-discovery via composer.json extra.laravel removes the manual provider registration step for consumers.
  • Tag your publishes() groups so users can cherry-pick config, migrations, or views independently.
  • Use orchestra/testbench for all package tests; it's the only reliable way to assert provider wiring in CI.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use mergeConfigFrom versus requiring users to publish the config?
Use mergeConfigFrom for simple, flat configs where sensible defaults cover most use cases. If your config has nested arrays or users are likely to customise deeply, require them to publish the full config file and document that clearly — shallow merging will silently drop nested keys otherwise.
Q02 Does auto-discovery work with Laravel 11's bootstrap/providers.php?
Yes. Laravel 11 moved from the cached packages.php approach to a first-class bootstrap/providers.php file, but the composer.json extra.laravel.providers array is still the correct way to declare your provider. Composer's post-autoload-dump scripts handle writing it into the bootstrap file automatically.
Q03 How do I prevent a package's migrations from running automatically in the host app?
Call loadMigrationsFrom in your service provider for convenience, but also publish them with a tag. Users who want full control can remove the auto-load by overriding the provider or simply not calling php artisan migrate until they've reviewed the published files.

Continue reading

More Articles

View all