Why Most Package Tutorials Stop Too Early
Most guides show you how to create a ServiceProvider, register a binding, and call it done. Production packages need more: deferred loading, safe config merging that doesn't clobber user values, reliable auto-discovery, and a test harness that bootstraps the package in isolation. Let's work through each layer.
Auto-Discovery: What Composer Actually Does
Laravel reads the extra.laravel key in your composer.json during composer install and writes discovered providers/aliases into bootstrap/cache/packages.php.
{
"extra": {
"laravel": {
"providers": [
"Acme\\Auditor\\AuditorServiceProvider"
],
"aliases": {
"Auditor": "Acme\\Auditor\\Facades\\Auditor"
}
}
}
}
If you want users to opt in rather than auto-load, omit the extra.laravel block and document the manual registration step. Never assume auto-discovery is always desirable — heavy providers that touch the database or filesystem should be opt-in.
Deferred Providers: Load Only When Needed
A provider that registers a single binding doesn't need to boot on every request. Implement DeferrableProvider and declare provides():
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
class AuditorServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(AuditorManager::class, function ($app) {
return new AuditorManager($app['config']['auditor']);
});
}
public function provides(): array
{
return [AuditorManager::class];
}
}
Laravel caches the provides() map. The provider's register() is only called the first time AuditorManager::class is resolved. This matters in Octane environments where the container is reused across requests.
Config Merging Without Clobbering User Values
The naive approach overwrites user config:
// BAD — overwrites published config
$this->app['config']->set('auditor', require __DIR__.'/../config/auditor.php');
Use mergeConfigFrom() instead. It only fills keys that don't already exist:
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/auditor.php', 'auditor');
}
Caveat: mergeConfigFrom is a shallow merge. Nested arrays are replaced wholesale if the user has published and partially customised them. For deep merging, do it explicitly:
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)
);
}
This ensures user overrides win at every nesting level.
Publishing Assets Cleanly
Group publishable assets with tags so users can publish selectively:
public function boot(): void
{
$this->publishes([
__DIR__.'/../config/auditor.php' => config_path('auditor.php'),
], 'auditor-config');
$this->publishes([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'auditor-migrations');
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
Use loadMigrationsFrom() so tests and fresh installs run migrations automatically, but still allow users to publish and customise them when needed.
Testing the Package in Isolation with Orchestra Testbench
Orchestra Testbench gives you a minimal Laravel application without a full project:
use Orchestra\Testbench\TestCase;
class AuditorTest extends TestCase
{
protected function getPackageProviders($app): array
{
return [AuditorServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('auditor.driver', 'database');
}
public function test_manager_resolves(): void
{
$manager = $this->app->make(AuditorManager::class);
$this->assertInstanceOf(AuditorManager::class, $manager);
}
}
This pattern lets CI validate the full provider lifecycle — registration, booting, config merging — without any host application.
Key Takeaways
- Use
DeferrableProviderfor bindings that aren't needed on every request; declareprovides()accurately. mergeConfigFrom()is shallow — usearray_replace_recursivewhen your config has nested arrays users might partially override.- Tag publishable assets so users can selectively publish config, migrations, or views.
loadMigrationsFrom()keeps tests and fresh installs working without requiring a publish step.- Orchestra Testbench is non-negotiable for package CI; test the full provider lifecycle, not just unit logic.
- Omit
extra.laravelfor heavy providers that should be opt-in rather than auto-discovered.