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

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

3 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 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.
Q02 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.
Q03 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.

Continue reading

More Articles

View all