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 inboot(). - Auto-discovery via
composer.jsonextra.laraveleliminates manual registration for most users. mergeConfigFrom()is shallow — usearray_replace_recursivefor nested config structures.- Implement
DeferrableProviderfor 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.