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

Most Laravel packages look fine from the outside but become maintenance nightmares once they grow. The root cause is almost always a service provider that does too much, config merging that silently overwrites user values, or auto-discovery that registers things the consuming app never asked for.

This article walks through the decisions that separate a throwaway package from one you'd actually ship to Packagist.


Structuring the Service Provider

A service provider has two jobs: bind things in register() and bootstrap side-effects in boot(). Mixing them causes subtle ordering bugs.

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['db']->connection(
                    $app['config']['auditor.connection']
                )
            );
        });
    }

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

Key decisions here:

  • mergeConfigFrom in register() so the config is available when other providers bind against it.
  • Migrations and publishable assets are gated behind runningInConsole() — no filesystem overhead on every web request.
  • Routes are always loaded; gate them behind a config flag if the package is optional.

mergeConfigFrom — The Subtle Trap

mergeConfigFrom only does a shallow merge. If your config has nested arrays and the user publishes a partial override, nested keys they omit will be dropped.

// Package default
'drivers' => [
    'database' => ['table' => 'audit_logs'],
    'redis'    => ['prefix' => 'audit:'],
],

// User publishes and sets only:
'drivers' => [
    'database' => ['table' => 'my_audits'],
],
// 'redis' key is now gone — mergeConfigFrom won't restore it.

The fix is a recursive merge in register():

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

This ensures package defaults fill every missing nested key without overwriting user values.


Auto-Discovery Done Right

Auto-discovery via composer.json is convenient but opt-in should be the default for anything that registers routes, middleware, or commands.

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

For packages that should be explicitly registered (e.g., they alter query behavior globally), document the manual registration path and consider adding a dont-discover note in your README. Consumers can always add your provider to bootstrap/providers.php in Laravel 11+.


Testing the Package in Isolation

Use orchestra/testbench to boot a minimal Laravel application inside your test suite without a full app skeleton.

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.connection', 'testing');
    }

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

        $this->assertInstanceOf(AuditLogger::class, $logger);
    }
}

This pattern lets you assert config merging, binding resolution, and route registration without touching a real application.


Takeaways

  • Keep register() for bindings and config; keep boot() for side-effects.
  • mergeConfigFrom is shallow — use array_replace_recursive for nested defaults.
  • Gate migrations and publishable assets behind runningInConsole().
  • Auto-discovery is a convenience, not a mandate — document manual registration for invasive packages.
  • orchestra/testbench is non-negotiable for reliable package tests.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why does my nested config disappear after a user publishes a partial config file?
Because `mergeConfigFrom` performs a shallow merge. Nested arrays in the user's published file replace the entire nested array from the package default. Use `array_replace_recursive` in `register()` to preserve all nested defaults while still respecting user overrides.
Q02 Should I always enable auto-discovery for my Laravel package?
Not necessarily. Auto-discovery is convenient for utility packages, but packages that register global middleware, alter query behavior, or load routes unconditionally should document manual registration so consumers can control when the package is active.
Q03 How do I test a package without creating a full Laravel application?
Use `orchestra/testbench`. It boots a minimal Laravel container, lets you declare your service providers via `getPackageProviders()`, and configure the environment via `defineEnvironment()` — all without a real app skeleton.

Continue reading

More Articles

View all