Why Package Architecture Still Trips Senior Devs
Writing a Laravel package feels straightforward until you hit subtle ordering issues, config collisions, or auto-discovery that silently fails in certain deployment pipelines. This article walks through the mechanics that matter — not the boilerplate generators, but the decisions underneath them.
Service Provider Anatomy
Every package's entry point is a service provider. The two methods you'll always implement are register (bind things into the container) and boot (act on the fully-booted application).
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['config']->get('auditor'),
$app[\Psr\Log\LoggerInterface::class]
);
});
}
public function boot(): void
{
$this->publishes([
__DIR__.'/../config/auditor.php' => config_path('auditor.php'),
], 'auditor-config');
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
}
Key rule: never call config() inside register(). The config repository exists, but other providers haven't merged their values yet. Read config in boot() or lazily inside closures.
Auto-Discovery via composer.json
Laravel's auto-discovery reads the extra.laravel key so users don't need to add your provider manually.
{
"extra": {
"laravel": {
"providers": [
"Acme\\Auditor\\AuditorServiceProvider"
],
"aliases": {
"Auditor": "Acme\\Auditor\\Facades\\Auditor"
}
}
}
}
Auto-discovery runs during composer install/update and writes to bootstrap/cache/packages.php. In CI pipelines that cache the vendor directory without re-running composer dump-autoload, this file can be stale. Always invalidate the bootstrap cache when the vendor hash changes.
Users can opt out per-package in their own composer.json:
{
"extra": {
"laravel": {
"dont-discover": ["acme/auditor"]
}
}
}
Config Merging Done Right
mergeConfigFrom performs a shallow merge. If your config has nested arrays and the user publishes a partial override, nested keys the user omits will still come from your package defaults — but only one level deep.
// Package default
return [
'driver' => 'database',
'channels' => ['slack', 'log'],
'options' => ['retry' => 3, 'timeout' => 30],
];
If the user's published config only sets 'driver' => 'redis', the options array is preserved from your defaults. However, if they set 'options' => ['retry' => 5], the timeout key disappears — shallow merge, not recursive.
For recursive merging, do it yourself in register():
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)
);
}
Deferred Providers for Performance
If your package only needs to resolve its bindings on demand, implement \Illuminate\Contracts\Support\DeferrableProvider:
use Illuminate\Contracts\Support\DeferrableProvider;
class AuditorServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function provides(): array
{
return [AuditLogger::class];
}
}
Laravel will skip booting this provider entirely until something resolves AuditLogger::class from the container. For packages that add CLI commands or event listeners, deferral is wrong — those need to register during every request cycle.
Publishable Groups and Selective Publishing
Tag your publishable assets so users can cherry-pick:
$this->publishes([
__DIR__.'/../config/auditor.php' => config_path('auditor.php'),
], 'auditor-config');
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/auditor'),
], 'auditor-views');
Users then run:
php artisan vendor:publish --tag=auditor-config
Avoid a catch-all publish group — it forces users to accept files they don't want to own.
Takeaways
- Call
mergeConfigFrominregister(), but read config values inboot()or inside lazy closures. - Auto-discovery depends on
bootstrap/cache/packages.phpbeing fresh — invalidate it in CI. mergeConfigFromis shallow; usearray_replace_recursivewhen your config has meaningful nested defaults.- Implement
DeferrableProvideronly for pure service bindings, never for providers that register listeners or commands. - Tag publishable assets granularly so consumers publish only what they intend to maintain.