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

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

3 min read Mohamed Said Mohamed Said

Why Most Package Tutorials Stop Too Early

Most guides show you how to create a ServiceProvider, register a binding, and call it done. Production packages need more: deferred loading, safe config merging that doesn't clobber user values, reliable auto-discovery, and a test harness that bootstraps the package in isolation. Let's work through each layer.


Auto-Discovery: What Composer Actually Does

Laravel reads the extra.laravel key in your composer.json during composer install and writes discovered providers/aliases into bootstrap/cache/packages.php.

{
  "extra": {
    "laravel": {
      "providers": [
        "Acme\\Auditor\\AuditorServiceProvider"
      ],
      "aliases": {
        "Auditor": "Acme\\Auditor\\Facades\\Auditor"
      }
    }
  }
}

If you want users to opt in rather than auto-load, omit the extra.laravel block and document the manual registration step. Never assume auto-discovery is always desirable — heavy providers that touch the database or filesystem should be opt-in.


Deferred Providers: Load Only When Needed

A provider that registers a single binding doesn't need to boot on every request. Implement DeferrableProvider and declare provides():

use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;

class AuditorServiceProvider extends ServiceProvider implements DeferrableProvider
{
    public function register(): void
    {
        $this->app->singleton(AuditorManager::class, function ($app) {
            return new AuditorManager($app['config']['auditor']);
        });
    }

    public function provides(): array
    {
        return [AuditorManager::class];
    }
}

Laravel caches the provides() map. The provider's register() is only called the first time AuditorManager::class is resolved. This matters in Octane environments where the container is reused across requests.


Config Merging Without Clobbering User Values

The naive approach overwrites user config:

// BAD — overwrites published config
$this->app['config']->set('auditor', require __DIR__.'/../config/auditor.php');

Use mergeConfigFrom() instead. It only fills keys that don't already exist:

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

Caveat: mergeConfigFrom is a shallow merge. Nested arrays are replaced wholesale if the user has published and partially customised them. For deep merging, do it explicitly:

public function register(): void
{
    $packageConfig = require __DIR__.'/../config/auditor.php';
    $userConfig    = $this->app['config']->get('auditor', []);

    $this->app['config']->set(
        'auditor',
        array_replace_recursive($packageConfig, $userConfig)
    );
}

This ensures user overrides win at every nesting level.


Publishing Assets Cleanly

Group publishable assets with tags so users can publish selectively:

public function boot(): void
{
    $this->publishes([
        __DIR__.'/../config/auditor.php' => config_path('auditor.php'),
    ], 'auditor-config');

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

    $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}

Use loadMigrationsFrom() so tests and fresh installs run migrations automatically, but still allow users to publish and customise them when needed.


Testing the Package in Isolation with Orchestra Testbench

Orchestra Testbench gives you a minimal Laravel application without a full project:

use Orchestra\Testbench\TestCase;

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

    protected function defineEnvironment($app): void
    {
        $app['config']->set('auditor.driver', 'database');
    }

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

This pattern lets CI validate the full provider lifecycle — registration, booting, config merging — without any host application.


Key Takeaways

  • Use DeferrableProvider for bindings that aren't needed on every request; declare provides() accurately.
  • mergeConfigFrom() is shallow — use array_replace_recursive when your config has nested arrays users might partially override.
  • Tag publishable assets so users can selectively publish config, migrations, or views.
  • loadMigrationsFrom() keeps tests and fresh installs working without requiring a publish step.
  • Orchestra Testbench is non-negotiable for package CI; test the full provider lifecycle, not just unit logic.
  • Omit extra.laravel for heavy providers that should be opt-in rather than auto-discovered.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use a deferred service provider?
Whenever your provider registers bindings that aren't needed on every request — database loggers, report drivers, optional integrations. Deferring them avoids unnecessary instantiation and is especially valuable under Octane where the container persists across requests.
Q02 Why does mergeConfigFrom not work for nested config keys?
mergeConfigFrom performs a single-level array_merge. If a user publishes your config and changes a nested key, the entire nested array from the package default replaces their version. Use array_replace_recursive with user config taking priority to handle deep structures safely.
Q03 Do I need to publish migrations, or is loadMigrationsFrom enough?
loadMigrationsFrom is sufficient for most packages — it runs migrations automatically in tests and on fresh installs. Offer a publishable tag as well so users who need to customise the schema (adding columns, changing indexes) can do so without forking your package.

Continue reading

More Articles

View all