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 Structure Matters

Dropping logic into a src/ folder and registering a service provider is easy. Doing it in a way that survives upgrades, plays nicely with config:cache, and lets consumers override every default — that takes deliberate design.

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


The Service Provider Skeleton

Start with a provider that separates registration from booting. Registration is for binding; booting is for everything that reads those bindings.

namespace Acme\Beacon;

use Illuminate\Support\ServiceProvider;

final class BeaconServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(
            __DIR__.'/../config/beacon.php',
            'beacon'
        );

        $this->app->singleton(BeaconClient::class, function ($app) {
            return new BeaconClient(
                config('beacon.endpoint'),
                config('beacon.timeout'),
            );
        });
    }

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

            $this->commands([
                Commands\BeaconPingCommand::class,
            ]);
        }

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

Why mergeConfigFrom and Not Just config()->set()

mergeConfigFrom only runs when the key doesn't already exist in the config repository. That means a published and customised config/beacon.php wins automatically — no extra logic needed. It also survives config:cache because the merge happens at registration time, before the cache is written.


Auto-Discovery via composer.json

Laravel reads the extra.laravel key during composer require and registers providers and facades without any manual step.

{
  "extra": {
    "laravel": {
      "providers": [
        "Acme\\Beacon\\BeaconServiceProvider"
      ],
      "aliases": {
        "Beacon": "Acme\\Beacon\\Facades\\Beacon"
      }
    }
  }
}

Consumers who want to opt out add the provider to the dont-discover array in their own composer.json. Design your package so manual registration still works identically — don't rely on discovery order.


Config Merging in Depth

A common mistake is publishing a flat config and then adding nested keys in a later version. Consumers who published early now have a stale file and silently miss new keys.

Two mitigations:

  1. Namespace every key under a single top-level arraybeacon.http.timeout rather than beacon.timeout. Adding beacon.http.retries later won't break existing published files because mergeConfigFrom does a shallow merge at the top level only.

  2. Document the merge limitation. For deeply nested defaults, consider a ConfigFactory that reads the published file and fills gaps explicitly:

final class BeaconConfig
{
    public static function fromArray(array $raw): self
    {
        return new self(
            endpoint: $raw['endpoint'] ?? 'https://beacon.example.com',
            timeout: $raw['http']['timeout'] ?? 5,
            retries: $raw['http']['retries'] ?? 3,
        );
    }
}

This makes defaults explicit and testable without relying on merge behaviour.


Testing the Package in Isolation

Use orchestra/testbench to boot a minimal Laravel application inside your test suite.

use Orchestra\Testbench\TestCase;
use Acme\Beacon\BeaconServiceProvider;

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

    protected function defineEnvironment($app): void
    {
        $app['config']->set('beacon.endpoint', 'https://test.local');
    }

    public function test_client_is_bound(): void
    {
        $client = $this->app->make(BeaconClient::class);
        $this->assertInstanceOf(BeaconClient::class, $client);
    }
}

This pattern lets you assert that your provider registers bindings correctly, that config defaults apply, and that publishable assets exist at the expected paths — all without a host application.


Takeaways

  • Use mergeConfigFrom in register(), not boot() — it must run before bindings that read config.
  • Guard console-only registration (commands, publishes) behind runningInConsole() to avoid overhead on every request.
  • Auto-discovery is a convenience, not a contract — always support manual registration.
  • Shallow config merging is a known limitation; compensate with explicit factory classes for nested defaults.
  • orchestra/testbench gives you a real Laravel container in tests; use defineEnvironment to override config per test.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does `mergeConfigFrom` work after `config:cache` has been run?
Yes. `mergeConfigFrom` runs during the `register` phase, which happens before the config cache is written. Once the cache exists, Laravel loads it directly and skips the merge — but by then the merged values are already baked in.
Q02 Should I load migrations automatically or require consumers to publish them?
Use `loadMigrationsFrom` for packages where the schema is an implementation detail consumers shouldn't modify. If consumers are likely to need custom columns or indexes, publish the migrations instead so they own the files.
Q03 How do I prevent my package's service provider from being auto-discovered in a specific app?
The consuming application adds the provider class to the `extra.laravel.dont-discover` array in its own `composer.json`. Your package needs no special handling — Laravel's discovery mechanism respects that list automatically.

Continue reading

More Articles

View all