Why Package Structure Matters
Dropping logic into a src/ folder and registering a service provider is easy. Doing it in a way that survives upgrades, plays nicely with config:cache, and lets consumers override every default — that takes deliberate design.
This article walks through the decisions that separate a throwaway package from one you'd actually publish.
The Service Provider Skeleton
Start with a provider that separates registration from booting. Registration is for binding; booting is for everything that reads those bindings.
namespace Acme\Beacon;
use Illuminate\Support\ServiceProvider;
final 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.endpoint'),
config('beacon.timeout'),
);
});
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/beacon.php' => config_path('beacon.php'),
], 'beacon-config');
$this->commands([
Commands\BeaconPingCommand::class,
]);
}
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
}
}
Why mergeConfigFrom and Not Just config()->set()
mergeConfigFrom only runs when the key doesn't already exist in the config repository. That means a published and customised config/beacon.php wins automatically — no extra logic needed. It also survives config:cache because the merge happens at registration time, before the cache is written.
Auto-Discovery via composer.json
Laravel reads the extra.laravel key during composer require and registers providers and facades without any manual step.
{
"extra": {
"laravel": {
"providers": [
"Acme\\Beacon\\BeaconServiceProvider"
],
"aliases": {
"Beacon": "Acme\\Beacon\\Facades\\Beacon"
}
}
}
}
Consumers who want to opt out add the provider to the dont-discover array in their own composer.json. Design your package so manual registration still works identically — don't rely on discovery order.
Config Merging in Depth
A common mistake is publishing a flat config and then adding nested keys in a later version. Consumers who published early now have a stale file and silently miss new keys.
Two mitigations:
-
Namespace every key under a single top-level array —
beacon.http.timeoutrather thanbeacon.timeout. Addingbeacon.http.retrieslater won't break existing published files becausemergeConfigFromdoes a shallow merge at the top level only. -
Document the merge limitation. For deeply nested defaults, consider a
ConfigFactorythat reads the published file and fills gaps explicitly:
final class BeaconConfig
{
public static function fromArray(array $raw): self
{
return new self(
endpoint: $raw['endpoint'] ?? 'https://beacon.example.com',
timeout: $raw['http']['timeout'] ?? 5,
retries: $raw['http']['retries'] ?? 3,
);
}
}
This makes defaults explicit and testable without relying on merge behaviour.
Testing the Package in Isolation
Use orchestra/testbench to boot a minimal Laravel application inside your test suite.
use Orchestra\Testbench\TestCase;
use Acme\Beacon\BeaconServiceProvider;
class BeaconClientTest extends TestCase
{
protected function getPackageProviders($app): array
{
return [BeaconServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('beacon.endpoint', 'https://test.local');
}
public function test_client_is_bound(): void
{
$client = $this->app->make(BeaconClient::class);
$this->assertInstanceOf(BeaconClient::class, $client);
}
}
This pattern lets you assert that your provider registers bindings correctly, that config defaults apply, and that publishable assets exist at the expected paths — all without a host application.
Takeaways
- Use
mergeConfigFrominregister(), notboot()— it must run before bindings that read config. - Guard console-only registration (
commands,publishes) behindrunningInConsole()to avoid overhead on every request. - Auto-discovery is a convenience, not a contract — always support manual registration.
- Shallow config merging is a known limitation; compensate with explicit factory classes for nested defaults.
orchestra/testbenchgives you a real Laravel container in tests; usedefineEnvironmentto override config per test.