Skip to content

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

Learn how to build a production-ready Laravel package from scratch — covering service provider design, auto-discovery, config merging, and the subtle pitfalls that trip up even experienced package authors.

Published · Updated · 1 min read

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

Why Package Architecture Matters

Most Laravel developers have consumed dozens of packages but never authored one. When you finally do, the gap between "it works locally" and "it works reliably for everyone" turns out to be surprisingly wide. This article focuses on the three pillars that determine package quality: service provider design, Composer auto-discovery, and config merging — with real code and the edge cases that documentation glosses over.


Structuring the Service Provider

Your service provider is the package's public API to the framework. Keep it thin: register bindings, merge config, load routes/views, and declare publishables. Avoid business logic here.

<?php

namespace Acme\Beacon;

use Illuminate\Support\ServiceProvider;

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.api_key'),
                config('beacon.timeout', 5)
            );
        });
    }

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

            $this->publishes([
                __DIR__.'/../database/migrations' => database_path('migrations'),
            ], 'beacon-migrations');
        }

        $this->loadRoutesFrom(__DIR__.'/../routes/beacon.php');
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'beacon');
    }
}

The register vs boot Distinction

register() runs before any other provider's boot(). Never call config(), route(), or any service that might not be bound yet inside register(). Defer to boot() for anything that depends on the container being fully populated.


Auto-Discovery via Composer

Laravel's auto-discovery reads the extra.laravel key in composer.json, so users never need to touch config/app.php.

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

If a consuming application opts out of auto-discovery for your package (via their own dont-discover list), your README should document the manual registration steps. Don't assume discovery is always active.


Config Merging: The Subtle Trap

mergeConfigFrom() only performs a shallow merge. Nested arrays in the user's published config will be completely replaced by the package default if a top-level key matches. This surprises people constantly.

// Package default
[
    'drivers' => ['http', 'grpc'],
    'retry'   => ['times' => 3, 'sleep' => 100],
]

// User publishes and changes only retry.times to 5
// mergeConfigFrom will NOT merge 'retry' deeply —
// the user's entire 'retry' array replaces the default.

For nested config, provide a custom merge in register():

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

        $config->set('beacon', array_replace_recursive($package, $user));
    });
}

Use array_replace_recursive rather than array_merge so integer-keyed arrays (like driver lists) are replaced rather than appended.


Deferring Expensive Bindings

If your singleton is heavy and not always needed, implement DeferrableProvider:

use Illuminate\Contracts\Support\DeferrableProvider;

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

Laravel will skip loading the provider entirely until something resolves BeaconClient::class from the container.


Testing Your Package in Isolation

Use Orchestra Testbench to bootstrap a minimal Laravel application inside your package's test suite:

use Orchestra\Testbench\TestCase;

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

    protected function defineEnvironment($app): void
    {
        $app['config']->set('beacon.api_key', 'test-key');
    }

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

This gives you a real Laravel container without requiring a host application.


Key Takeaways

  • Keep service providers thin: bindings in register(), side effects in boot().
  • Auto-discovery via composer.json extra.laravel eliminates manual registration for most users.
  • mergeConfigFrom() is shallow — use array_replace_recursive for nested config structures.
  • Implement DeferrableProvider for heavy singletons that aren't always needed.
  • Use Orchestra Testbench to test your package against a real Laravel container without a host app.
  • Always document manual registration steps for apps that opt out of auto-discovery.

Frequently asked questions

When should I use `publishes()` with tags vs without?

Always use tags. They let users run `php artisan vendor:publish --tag=beacon-config` to publish only what they need, rather than everything the package exposes. Without tags, users must publish all assets at once, which pollutes their project.

Does `mergeConfigFrom()` work if the user has never published the config file?

Yes — that is its primary purpose. If no user config exists, the package defaults are used in full. If the user has published and modified the config, their top-level keys take precedence. The shallow-merge caveat only matters for nested arrays.

How do I handle migrations that should not be auto-loaded?

Use `publishes()` with a migrations tag and instruct users to run `vendor:publish`. Avoid `loadMigrationsFrom()` in most packages — it runs migrations automatically during `php artisan migrate`, which surprises users and makes it hard to review changes before applying them.

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