Skip to content

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

Build a production-ready Laravel package from scratch — covering service provider design, auto-discovery via composer.json, config merging, and the subtle traps that break downstream apps.

Published · Updated · 3 min read

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

Why Package Architecture Matters

Dropping reusable code into a packages/ directory is easy. Shipping something that installs cleanly, respects host-app configuration, and doesn't pollute the container is hard. This article walks through the decisions that separate a throwaway internal package from one you'd confidently open-source.


Service Provider Anatomy

Every package starts with a service provider. Keep it thin — registration logic only, no business logic.

namespace Acme\Auditor;

use Illuminate\Support\ServiceProvider;

class AuditorServiceProvider extends ServiceProvider
{
    // Defer binding until the service is actually resolved
    public bool $defer = false;

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

        $this->app->singleton(AuditLogger::class, function ($app) {
            return new AuditLogger(
                $app['config']->get('auditor'),
                $app['db']
            );
        });
    }

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

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

        $this->loadRoutesFrom(__DIR__ . '/../routes/auditor.php');
    }
}

register() vs boot()

  • register() — bind things into the container. No facades, no other services. Other providers may not be loaded yet.
  • boot() — everything else: routes, views, migrations, event listeners. All providers have been registered by this point.

Violating this order is the single most common cause of "service not found" errors in packages.


Auto-Discovery via composer.json

Laravel reads the extra.laravel key to register providers and aliases automatically — no manual config/app.php edits needed.

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

Host apps can opt out per-package in their own composer.json:

{
  "extra": {
    "laravel": {
      "dont-discover": ["acme/auditor"]
    }
  }
}

This is important for packages that should be explicitly configured before loading.


Config Merging Done Right

mergeConfigFrom performs a shallow merge. Nested arrays in the host app's published config will be completely replaced by the package default if the key exists at the top level. This surprises most developers.

// Package default
[
  'driver' => 'database',
  'channels' => ['slack', 'log'],
]

// Host app publishes and sets only:
[
  'driver' => 'redis',
]

// Result after mergeConfigFrom — 'channels' is MISSING
// because the host key 'auditor' exists, so no merge happens

For deep merges, do it manually in register():

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

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

Deferred Providers for Heavy Bindings

If your package registers a service that isn't needed on every request, defer it:

use Illuminate\Contracts\Support\DeferrableProvider;

class AuditorServiceProvider extends ServiceProvider implements DeferrableProvider
{
    public function provides(): array
    {
        return [AuditLogger::class];
    }

    public function register(): void
    {
        $this->app->singleton(AuditLogger::class, ...);
    }
}

Laravel caches the provides() list and only boots this provider when AuditLogger is actually resolved. Don't defer providers that register routes or listeners — those must run on every request.


Testing Your Package in Isolation

Use orchestra/testbench to bootstrap a minimal Laravel app inside your test suite:

use Orchestra\Testbench\TestCase;

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

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

    public function test_logger_resolves_from_container(): void
    {
        $logger = $this->app->make(AuditLogger::class);
        $this->assertInstanceOf(AuditLogger::class, $logger);
    }
}

Key Takeaways

  • Keep register() for container bindings only; use boot() for everything that touches other services.
  • mergeConfigFrom is shallow — implement array_replace_recursive for nested config safety.
  • Auto-discovery via extra.laravel removes friction but always document the opt-out path.
  • Implement DeferrableProvider for heavy services not needed on every request.
  • Use orchestra/testbench to test your provider lifecycle without a full app install.

Frequently asked questions

Why does my published config override get ignored after mergeConfigFrom?

mergeConfigFrom only fills in missing top-level keys. If the host app has already set the top-level key (even partially), the package defaults for nested keys are dropped. Use array_replace_recursive in register() for a true deep merge.

Should I always use auto-discovery for my package?

Auto-discovery is convenient for general-purpose packages. If your package requires explicit configuration before the provider boots — such as credentials or a driver choice — consider documenting manual registration so developers aren't surprised by a misconfigured singleton on first install.

When should a provider be deferred?

Defer a provider when it only registers container bindings that aren't needed on every request — background services, report generators, or third-party API clients. Never defer providers that register routes, middleware, or event listeners, as those must be available from the first request.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project