sven/env-providers
Env Providers lets Laravel apps register service providers conditionally by environment, so local, staging, and production-only providers can live outside config/app.php and load automatically based on APP_ENV.
Install
composer require sven/env-providers
There is nothing to publish — no config file, no migrations, no auto-discovered service provider. The package is a single trait, so it only does something once you use it.
Where to look first
vendor/svenluijten/env-providers/src/LoadsProvidersFromEnvironment.php — it is a few dozen lines; read it before wiring it in so you know exactly what it calls ($this->app->environment() + $this->app->register()).APP_ENV value — this is the single input that drives everything. Check with php artisan about or php artisan tinker --execute="dump(app()->environment());".bootstrap/providers.php (Laravel 11+) or config/app.php (Laravel ≤10) — the provider you attach the trait to must itself be registered normally.Minimal wiring
Attach the trait to an existing provider and declare the environment → providers map:
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Sven\EnvProviders\LoadsProvidersFromEnvironment;
class AppServiceProvider extends ServiceProvider
{
use LoadsProvidersFromEnvironment;
protected function providers(): array
{
return [
'local' => [
\Barryvdh\Debugbar\ServiceProvider::class,
\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class,
],
'staging' => [
\Laravel\Telescope\TelescopeServiceProvider::class,
],
];
}
}
If your provider already defines its own register(), keep it and invoke the trait's loader from inside it instead of relying on the trait's own register().
First real use case
Debug tooling in local/testing only: Debugbar, IDE Helper, query loggers, mail-catcher UIs. This is the cleanest win, because these packages are noisy in production and you almost never want them registered there. Verify with:
php artisan tinker --execute="dump(array_keys(app()->getLoadedProviders()));"
One dedicated provider, not the trait everywhere. Create App\Providers\EnvironmentServiceProvider whose only job is the map. Sprinkling the trait across AppServiceProvider, AuthServiceProvider, etc. makes registration order hard to reason about.
Group by environment, but keep a "shared" bucket. The trait matches one environment key at a time, so a provider needed in both local and staging has to be listed twice or handled with an explicit check:
protected function providers(): array
{
return [
'local' => [DebugbarServiceProvider::class, ...$this->shared()],
'staging' => [TelescopeServiceProvider::class, ...$this->shared()],
];
}
private function shared(): array
{
return [\Sentry\Laravel\ServiceProvider::class];
}
Drive the map from config, not hardcoded strings. Environment names drift (dev vs local, prod vs production). Pulling the map out of config/ or computing it lets you keep the list testable:
protected function providers(): array
{
return config('providers.environments', []);
}
Pair it with config-level gating. If a provider is gated on environment but its config is gated on something else (a feature flag, a SENTRY_LARAVEL_DSN being present), you can end up registering a provider that has no usable configuration. Keep the two conditions in the same place:
'production' => array_filter([
config('services.sentry.dsn') ? \Sentry\Laravel\ServiceProvider::class : null,
]),
Testing environments. phpunit.xml usually sets APP_ENV=testing, which is a different key from local. Anything you want in the test suite must be listed under testing explicitly — a common source of "works in php artisan serve, fails in tests."
Per-command environments. Artisan's --env flag changes the resolved environment for that invocation (php artisan migrate --env=local). Handy for running a one-off command with debug providers attached — but be aware it changes which of your mapped providers load.
Integrating with Laravel 11's bootstrap/providers.php. List the base provider there as usual; the trait's contents stay invisible to Laravel's discovery, which is exactly what you want (they're registered imperatively at runtime).
Register in register(), never in boot(). $this->app->register() calls the provider's register() immediately, but its boot() will not be invoked if the application has already finished bootstrapping. Loading providers from a boot() method — or worse, from middleware — leaves you with registered-but-never-booted providers, and under Octane those registrations leak across requests.
Auto-discovered packages cannot be gated this way. If Composer discovery already registered the provider, $this->app->register() deduplicates by class name and silently no-ops. To keep an auto-discovered package out of an environment you must use "extra": {"laravel": {"dont-discover": [...]}} in composer.json, not this package.
config:cache freezes the environment. app.env comes from env('APP_ENV') at cache-build time. Cache config in CI with APP_ENV=local and ship it, and production will happily load your Debugbar provider. Always build the config cache on the target environment (or with the correct APP_ENV), and re-run php artisan config:clear when debugging "wrong providers loaded."
Exact-string matching. APP_ENV="Local" or a trailing space in .env will not match 'local', and the package will fail silently — no exception, just missing providers. When something isn't loading, dump(app()->environment()) first, before touching the map.
Registration order matters. Providers registered this way are appended after everything already in the bootstrapper list. If a conditional provider rebinds a core contract (mailer, filesystem, exception handler), it will win over an earlier binding — that may be what you want, but it's implicit. Put the trait-bearing provider late in bootstrap/providers.php if order is load-bearing.
Don't let the map go recursive. Registering the trait-bearing provider itself from inside its own map will not recurse (deduplication saves you), but it's confusing and order-dependent. Keep the map free of providers already listed in bootstrap/providers.php.
No environment wildcards. The lookup is a plain array key match on the current environment. If you want "everything except production," be explicit:
$env = app()->environment();
$providers = $env === 'production' ? [] : config('providers.non_production');
Debugging checklist, in order: app()->environment() → array_keys(app()->getLoadedProviders()) → php artisan about --only=environment → confirm the config cache isn't stale → confirm the package isn't auto-discovered.
Maintenance risk is low but non-zero. 76 stars, a single-maintainer package, last release October 2024, zero dependents. The upside is that it is ~30 lines of glue: if it ever goes stale or you need wildcard/array-key matching, copying the trait into app/Support/ and extending it is a five-minute job. Evaluate honestly whether
if ($this->app->environment('local')) {
$this->app->register(DebugbarServiceProvider::class);
}
already covers your case before adding a dependency — the package earns its place when the map grows past a handful of providers or when the environment list is owned by config rather than code.
How can I help you explore Laravel packages today?