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

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

3 min read Mohamed Said Mohamed Said

Why Package Architecture Still Trips Senior Devs

Writing a Laravel package feels straightforward until you hit subtle ordering issues, config collisions, or auto-discovery that silently fails in certain deployment pipelines. This article walks through the mechanics that matter — not the boilerplate generators, but the decisions underneath them.


Service Provider Anatomy

Every package's entry point is a service provider. The two methods you'll always implement are register (bind things into the container) and boot (act on the fully-booted application).

namespace Acme\Auditor;

use Illuminate\Support\ServiceProvider;

class AuditorServiceProvider extends ServiceProvider
{
    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[\Psr\Log\LoggerInterface::class]
            );
        });
    }

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

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

Key rule: never call config() inside register(). The config repository exists, but other providers haven't merged their values yet. Read config in boot() or lazily inside closures.


Auto-Discovery via composer.json

Laravel's auto-discovery reads the extra.laravel key so users don't need to add your provider manually.

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

Auto-discovery runs during composer install/update and writes to bootstrap/cache/packages.php. In CI pipelines that cache the vendor directory without re-running composer dump-autoload, this file can be stale. Always invalidate the bootstrap cache when the vendor hash changes.

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

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

Config Merging Done Right

mergeConfigFrom performs a shallow merge. If your config has nested arrays and the user publishes a partial override, nested keys the user omits will still come from your package defaults — but only one level deep.

// Package default
return [
    'driver' => 'database',
    'channels' => ['slack', 'log'],
    'options' => ['retry' => 3, 'timeout' => 30],
];

If the user's published config only sets 'driver' => 'redis', the options array is preserved from your defaults. However, if they set 'options' => ['retry' => 5], the timeout key disappears — shallow merge, not recursive.

For recursive merging, do it yourself in register():

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)
    );
}

Deferred Providers for Performance

If your package only needs to resolve its bindings on demand, implement \Illuminate\Contracts\Support\DeferrableProvider:

use Illuminate\Contracts\Support\DeferrableProvider;

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

Laravel will skip booting this provider entirely until something resolves AuditLogger::class from the container. For packages that add CLI commands or event listeners, deferral is wrong — those need to register during every request cycle.


Publishable Groups and Selective Publishing

Tag your publishable assets so users can cherry-pick:

$this->publishes([
    __DIR__.'/../config/auditor.php' => config_path('auditor.php'),
], 'auditor-config');

$this->publishes([
    __DIR__.'/../resources/views' => resource_path('views/vendor/auditor'),
], 'auditor-views');

Users then run:

php artisan vendor:publish --tag=auditor-config

Avoid a catch-all publish group — it forces users to accept files they don't want to own.


Takeaways

  • Call mergeConfigFrom in register(), but read config values in boot() or inside lazy closures.
  • Auto-discovery depends on bootstrap/cache/packages.php being fresh — invalidate it in CI.
  • mergeConfigFrom is shallow; use array_replace_recursive when your config has meaningful nested defaults.
  • Implement DeferrableProvider only for pure service bindings, never for providers that register listeners or commands.
  • Tag publishable assets granularly so consumers publish only what they intend to maintain.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why does my package's config not reflect user overrides when I read it inside register()?
The user's config file is loaded by the ConfigServiceProvider before your package provider runs, but mergeConfigFrom merges package defaults only where the user has not set a value. If you read config inside register() before mergeConfigFrom completes, or if another provider overwrites the key later, you'll see stale values. Always read resolved config in boot() or inside a lazy closure passed to the container.
Q02 When should I NOT use auto-discovery for my package?
Skip auto-discovery (or document how to disable it) when your provider has significant boot-time cost, requires environment-specific registration, or when the package is an internal monorepo module not intended for general Composer consumption. In those cases, explicit registration in config/app.php gives the application owner full control.
Q03 How do I test that my service provider registers bindings correctly?
Use an Orchestra Testbench test case. Extend Orchestra\Testbench\TestCase, override getPackageProviders() to return your provider class, then assert container bindings with $this->app->bound(MyService::class) or resolve the binding and assert its type. This gives you a real Laravel application context without a full project.

Continue reading

More Articles

View all