Why Package Architecture Matters
Dropping reusable code into a packages/ directory is easy. Shipping something that installs cleanly, respects host-app configuration, and doesn't pollute the container is hard. This article walks through the decisions that separate a throwaway internal package from one you'd confidently open-source.
Service Provider Anatomy
Every package starts with a service provider. Keep it thin — registration logic only, no business logic.
namespace Acme\Auditor;
use Illuminate\Support\ServiceProvider;
class AuditorServiceProvider extends ServiceProvider
{
// Defer binding until the service is actually resolved
public bool $defer = false;
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['db']
);
});
}
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');
}
}
register() vs boot()
register()— bind things into the container. No facades, no other services. Other providers may not be loaded yet.boot()— everything else: routes, views, migrations, event listeners. All providers have been registered by this point.
Violating this order is the single most common cause of "service not found" errors in packages.
Auto-Discovery via composer.json
Laravel reads the extra.laravel key to register providers and aliases automatically — no manual config/app.php edits needed.
{
"extra": {
"laravel": {
"providers": [
"Acme\\Auditor\\AuditorServiceProvider"
],
"aliases": {
"Auditor": "Acme\\Auditor\\Facades\\Auditor"
}
}
}
}
Host apps can opt out per-package in their own composer.json:
{
"extra": {
"laravel": {
"dont-discover": ["acme/auditor"]
}
}
}
This is important for packages that should be explicitly configured before loading.
Config Merging Done Right
mergeConfigFrom performs a shallow merge. Nested arrays in the host app's published config will be completely replaced by the package default if the key exists at the top level. This surprises most developers.
// Package default
[
'driver' => 'database',
'channels' => ['slack', 'log'],
]
// Host app publishes and sets only:
[
'driver' => 'redis',
]
// Result after mergeConfigFrom — 'channels' is MISSING
// because the host key 'auditor' exists, so no merge happens
For deep merges, do it manually in register():
public function register(): void
{
$default = require __DIR__ . '/../config/auditor.php';
$app = $this->app['config']->get('auditor', []);
$this->app['config']->set(
'auditor',
array_replace_recursive($default, $app)
);
}
Deferred Providers for Heavy Bindings
If your package registers a service that isn't needed on every request, defer it:
use Illuminate\Contracts\Support\DeferrableProvider;
class AuditorServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function provides(): array
{
return [AuditLogger::class];
}
public function register(): void
{
$this->app->singleton(AuditLogger::class, ...);
}
}
Laravel caches the provides() list and only boots this provider when AuditLogger is actually resolved. Don't defer providers that register routes or listeners — those must run on every request.
Testing Your Package in Isolation
Use orchestra/testbench to bootstrap a minimal Laravel app inside your test suite:
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.driver', 'array');
}
public function test_logger_resolves_from_container(): void
{
$logger = $this->app->make(AuditLogger::class);
$this->assertInstanceOf(AuditLogger::class, $logger);
}
}
Key Takeaways
- Keep
register()for container bindings only; useboot()for everything that touches other services. mergeConfigFromis shallow — implementarray_replace_recursivefor nested config safety.- Auto-discovery via
extra.laravelremoves friction but always document the opt-out path. - Implement
DeferrableProviderfor heavy services not needed on every request. - Use
orchestra/testbenchto test your provider lifecycle without a full app install.