Why Package Architecture Matters
Dropping reusable code into app/ is fine for a single project. The moment that code needs to live in three codebases, or you want to open-source it, you need a real package. Laravel's package primitives — service providers, auto-discovery, and the config/view/migration publishing pipeline — are mature and opinionated. Understanding them at the seams saves hours of debugging.
Structuring the Package Root
A minimal layout that scales:
my-vendor/my-package/
├── src/
│ ├── MyPackageServiceProvider.php
│ ├── MyPackageManager.php
│ └── Console/
│ └── InstallCommand.php
├── config/
│ └── my-package.php
├── database/migrations/
├── resources/views/
├── tests/
├── composer.json
└── README.md
Keep src/ clean. Never put config or views inside src/ — the publishing pipeline expects them at the package root.
The Service Provider in Detail
namespace MyVendor\MyPackage;
use Illuminate\Support\ServiceProvider;
class MyPackageServiceProvider extends ServiceProvider
{
public function register(): void
{
// Merge package defaults under the host app's config.
// Host values WIN — this is the correct merge direction.
$this->mergeConfigFrom(
__DIR__.'/../config/my-package.php',
'my-package'
);
$this->app->singleton(MyPackageManager::class, function ($app) {
return new MyPackageManager(
$app['config']['my-package']
);
});
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'my-package');
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/my-package.php' => config_path('my-package.php'),
], 'my-package-config');
$this->publishes([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'my-package-migrations');
$this->commands([
Console\InstallCommand::class,
]);
}
}
}
register() vs boot()
register() is for binding — nothing else. Calling config() or resolving services here is a common mistake; other providers haven't run yet. boot() fires after all providers are registered, so it's safe to resolve bindings, load routes, and publish assets.
Auto-Discovery
Add the extra block to composer.json so Laravel registers your provider without any manual step in config/app.php:
{
"extra": {
"laravel": {
"providers": [
"MyVendor\\MyPackage\\MyPackageServiceProvider"
],
"aliases": {
"MyPackage": "MyVendor\\MyPackage\\Facades\\MyPackage"
}
}
}
}
Laravel reads this during composer install/update and writes to bootstrap/providers.php (Laravel 11+) or bootstrap/cache/packages.php (Laravel 10). Users can opt out per-package via their own composer.json extra.laravel.dont-discover array.
Config Merging — The Subtle Trap
mergeConfigFrom does a shallow merge. Nested arrays in the host config do not deep-merge with package defaults:
// Package default
'options' => ['timeout' => 30, 'retries' => 3]
// Host config publishes only:
'options' => ['timeout' => 60]
// Result after mergeConfigFrom — retries is GONE
'options' => ['timeout' => 60]
For nested config, provide a helper or document that users must publish the full config. Alternatively, implement your own deep merge in register():
$this->app->afterResolving('config', function ($config) {
$package = require __DIR__.'/../config/my-package.php';
$host = $config->get('my-package', []);
$config->set('my-package', array_replace_recursive($package, $host));
});
Testing the Package in Isolation
Use orchestra/testbench — it boots a minimal Laravel application around your package:
use Orchestra\Testbench\TestCase;
class MyPackageTest extends TestCase
{
protected function getPackageProviders($app): array
{
return [MyPackageServiceProvider::class];
}
public function test_manager_resolves(): void
{
$manager = $this->app->make(MyPackageManager::class);
$this->assertInstanceOf(MyPackageManager::class, $manager);
}
}
Never test a package by symlinking it into a real app during CI — testbench gives you a reproducible, isolated environment.
Takeaways
- Put bindings in
register(), everything else inboot()— this order is not optional. mergeConfigFromis shallow; document it or implementarray_replace_recursivefor nested defaults.- Auto-discovery via
composer.jsonextra.laravelremoves the manual provider registration step for consumers. - Tag your
publishes()groups so users can cherry-pick config, migrations, or views independently. - Use
orchestra/testbenchfor all package tests; it's the only reliable way to assert provider wiring in CI.