Why Package Architecture Matters
Most Laravel packages look fine from the outside but become maintenance nightmares once they grow. The root cause is almost always a service provider that does too much, config merging that silently overwrites user values, or auto-discovery that registers things the consuming app never asked for.
This article walks through the decisions that separate a throwaway package from one you'd actually ship to Packagist.
Structuring the Service Provider
A service provider has two jobs: bind things in register() and bootstrap side-effects in boot(). Mixing them causes subtle ordering bugs.
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['db']->connection(
$app['config']['auditor.connection']
)
);
});
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/auditor.php' => config_path('auditor.php'),
], 'auditor-config');
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
$this->loadRoutesFrom(__DIR__.'/../routes/auditor.php');
}
}
Key decisions here:
mergeConfigFrominregister()so the config is available when other providers bind against it.- Migrations and publishable assets are gated behind
runningInConsole()— no filesystem overhead on every web request. - Routes are always loaded; gate them behind a config flag if the package is optional.
mergeConfigFrom — The Subtle Trap
mergeConfigFrom only does a shallow merge. If your config has nested arrays and the user publishes a partial override, nested keys they omit will be dropped.
// Package default
'drivers' => [
'database' => ['table' => 'audit_logs'],
'redis' => ['prefix' => 'audit:'],
],
// User publishes and sets only:
'drivers' => [
'database' => ['table' => 'my_audits'],
],
// 'redis' key is now gone — mergeConfigFrom won't restore it.
The fix is a recursive merge in register():
public function register(): void
{
$this->app->afterResolving('config', function ($config) {
$config->set('auditor', array_replace_recursive(
require __DIR__.'/../config/auditor.php',
$config->get('auditor', [])
));
});
}
This ensures package defaults fill every missing nested key without overwriting user values.
Auto-Discovery Done Right
Auto-discovery via composer.json is convenient but opt-in should be the default for anything that registers routes, middleware, or commands.
{
"extra": {
"laravel": {
"providers": [
"Acme\\Auditor\\AuditorServiceProvider"
],
"aliases": {
"Auditor": "Acme\\Auditor\\Facades\\Auditor"
}
}
}
}
For packages that should be explicitly registered (e.g., they alter query behavior globally), document the manual registration path and consider adding a dont-discover note in your README. Consumers can always add your provider to bootstrap/providers.php in Laravel 11+.
Testing the Package in Isolation
Use orchestra/testbench to boot a minimal Laravel application inside your test suite without a full app skeleton.
use Orchestra\Testbench\TestCase;
class AuditLoggerTest extends TestCase
{
protected function getPackageProviders($app): array
{
return [AuditorServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('auditor.connection', 'testing');
}
public function test_logger_resolves_from_container(): void
{
$logger = $this->app->make(AuditLogger::class);
$this->assertInstanceOf(AuditLogger::class, $logger);
}
}
This pattern lets you assert config merging, binding resolution, and route registration without touching a real application.
Takeaways
- Keep
register()for bindings and config; keepboot()for side-effects. mergeConfigFromis shallow — usearray_replace_recursivefor nested defaults.- Gate migrations and publishable assets behind
runningInConsole(). - Auto-discovery is a convenience, not a mandate — document manual registration for invasive packages.
orchestra/testbenchis non-negotiable for reliable package tests.