What the Container Actually Does
The service container is a registry that knows how to build objects. Ask it for a class and it works out what that class needs, builds those dependencies too, and hands you a fully assembled instance.
Without a container you write this:
$config = new Config('/etc/app.ini');
$logger = new FileLogger($config);
$client = new HttpClient($config, $logger);
$payment = new StripeGateway($client, $logger, $config->get('stripe.key'));With one you write this:
$payment = app(StripeGateway::class);The container reads the constructor signature through reflection, resolves each type-hinted parameter recursively, and assembles the object graph for you. That is the whole trick — but it is what makes every other Laravel feature composable.
You will see the container called the IoC container (Inversion of Control) or a DI container (Dependency Injection). Same thing. The “inversion” is that classes no longer construct their own dependencies — something outside them decides what they get.
Automatic Resolution
Anything with a type-hinted constructor and no unresolvable primitives can be built with zero configuration:
class InvoiceService
{
public function __construct(
private PdfRenderer $pdf,
private Mailer $mailer,
) {}
}
class InvoiceController extends Controller
{
// Laravel builds PdfRenderer and Mailer, then InvoiceService, then this
public function __construct(private InvoiceService $invoices) {}
// Method injection works too — mixed with route parameters
public function show(Request $request, Order $order, PdfRenderer $pdf)
{
return $pdf->render($order);
}
}Autowiring works in controllers, jobs, listeners, commands, middleware, form requests and anywhere else Laravel resolves a class for you. It does not work for objects you create with new — that bypasses the container entirely.
// Resolve manually when you need to
$service = app(InvoiceService::class);
$service = resolve(InvoiceService::class);
$service = App::make(InvoiceService::class);
// Pass constructor arguments the container cannot guess
$report = app()->makeWith(Report::class, ['month' => '2026-08']);Binding Things Manually
Autowiring fails when a constructor needs something the container cannot infer — a string, an API key, an interface. Then you bind it explicitly, normally in a service provider’s register() method.
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(StripeGateway::class, function ($app) {
return new StripeGateway(
$app->make(HttpClient::class),
config('services.stripe.secret'),
config('services.stripe.webhook_secret'),
);
});
}
}The closure runs every time the binding is resolved, and receives the container so you can pull further dependencies from it.
bind vs singleton vs scoped
| Method | Instances created | Use for |
|---|---|---|
bind() | A new one on every resolve | Stateful objects, anything cheap |
singleton() | One, reused for the whole request | Connections, clients, caches, config objects |
scoped() | One per request — reset between Octane requests | Request-scoped state under Octane |
instance() | Zero — you supply the object | Pre-built objects, test doubles |
// New instance every time
$this->app->bind(ReportBuilder::class);
// Built once, then reused
$this->app->singleton(GeoIpDatabase::class, function () {
return new GeoIpDatabase(storage_path('geoip/city.mmdb')); // slow to open
});
// Register an object you already have
$this->app->instance('feature.flags', $flags);A singleton that holds request-specific state is a bug waiting for Octane. Under a traditional PHP-FPM setup the process dies after each request so it never bites; under Octane the process lives on and the stale state leaks into the next user’s request. Use scoped() for anything request-bound.
Binding Interfaces to Implementations
This is the payoff for all of it. Depend on an interface, bind the concrete class in one place, and swapping implementations becomes a one-line change.
// app/Contracts/SmsSender.php
interface SmsSender
{
public function send(string $to, string $message): bool;
}
// app/Services/TwilioSmsSender.php
class TwilioSmsSender implements SmsSender { /* ... */ }
// app/Services/LogSmsSender.php — for local dev
class LogSmsSender implements SmsSender { /* ... */ }// AppServiceProvider::register()
$this->app->bind(SmsSender::class, function ($app) {
return $app->environment('production')
? new TwilioSmsSender(config('services.twilio'))
: new LogSmsSender($app->make(LoggerInterface::class));
});// Nothing else in the application knows or cares which one it got
class OrderController extends Controller
{
public function __construct(private SmsSender $sms) {}
public function ship(Order $order)
{
$this->sms->send($order->phone, "Order {$order->id} has shipped.");
}
}In tests you swap it for a fake in one line, with no mocking framework:
$this->app->instance(SmsSender::class, new FakeSmsSender());
// Or use the built-in helpers
$this->mock(SmsSender::class, function ($mock) {
$mock->shouldReceive('send')->once()->andReturn(true);
});Contextual Binding
Sometimes two classes need different implementations of the same interface. Contextual binding handles that without introducing a second interface.
use Illuminate\Support\Facades\Storage;
$this->app->when(PhotoController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('s3'));
$this->app->when(InvoiceController::class)
->needs(Filesystem::class)
->give(fn () => Storage::disk('local'));
// Also works for primitives
$this->app->when(ReportGenerator::class)
->needs('$rowLimit')
->give(5000);
// And for tagged collections
$this->app->tag([StripeGateway::class, PaypalGateway::class], 'gateways');
$this->app->bind(PaymentRouter::class, function ($app) {
return new PaymentRouter($app->tagged('gateways'));
});Service Providers
Service providers are where you tell the container how your application is wired. Every Laravel feature — the database, the queue, the mailer, routing — is registered by a provider listed in bootstrap/providers.php (Laravel 11+) or config/app.php.
php artisan make:provider PaymentServiceProvider<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
// ONLY bind things here. Nothing else.
$this->app->singleton(PaymentGateway::class, function ($app) {
return new StripeGateway(config('services.stripe.secret'));
});
$this->mergeConfigFrom(__DIR__ . '/../../config/payments.php', 'payments');
}
public function boot(): void
{
// Everything else — the whole container is available now
Blade::directive('money', fn ($expr) => "<?php echo money($expr); ?>");
Validator::extend('valid_card', [CardValidator::class, 'validate']);
Event::listen(PaymentFailed::class, NotifyFinanceTeam::class);
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
}
}Register it in bootstrap/providers.php:
return [
App\Providers\AppServiceProvider::class,
App\Providers\PaymentServiceProvider::class,
];register() vs boot()
Laravel runs register() on every provider first, then boot() on every provider. That ordering is the entire reason there are two methods.
Application boot sequence
-------------------------
1. register() on Provider A
2. register() on Provider B
3. register() on Provider C <-- all bindings now exist
4. boot() on Provider A <-- safe to use anything
5. boot() on Provider B
6. boot() on Provider C| Method | Do | Do NOT |
|---|---|---|
register() | Bind classes, merge config | Resolve services, hit the database, register routes or events |
boot() | Routes, events, Blade directives, validators, macros, policies, view composers | Anything slow — it runs on every request |
Resolving a service inside register() is the classic mistake. If that service is bound by a provider registered later, you get the default implementation instead of yours — or a “target class does not exist” error that only appears in production, where provider order can differ from your cached config.
Deferred Providers
A provider that only registers bindings does not need to run on requests that never use them. Implement DeferrableProvider and Laravel loads it lazily.
use Illuminate\Contracts\Support\DeferrableProvider;
class PaymentServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(PaymentGateway::class, fn () => new StripeGateway(/* ... */));
}
public function provides(): array
{
return [PaymentGateway::class];
}
}The provider is skipped entirely until something asks for PaymentGateway. A provider with a boot() method cannot be deferred — boot() has to run to do its job.
A Practical Example
Putting it together — a weather service with a cached HTTP client, an interface, and an environment-aware binding:
interface WeatherProvider
{
public function forecast(string $city): array;
}
class OpenWeatherProvider implements WeatherProvider
{
public function __construct(
private Factory $http,
private CacheRepository $cache,
private string $apiKey,
) {}
public function forecast(string $city): array
{
return $this->cache->remember("weather:{$city}", 900, function () use ($city) {
return $this->http
->timeout(5)
->get('https://api.openweathermap.org/data/2.5/forecast', [
'q' => $city, 'appid' => $this->apiKey,
])
->throw()
->json();
});
}
}// WeatherServiceProvider::register()
$this->app->singleton(WeatherProvider::class, function ($app) {
if ($app->runningUnitTests()) {
return new StubWeatherProvider();
}
return new OpenWeatherProvider(
$app->make(\Illuminate\Http\Client\Factory::class),
$app->make('cache.store'),
config('services.openweather.key'),
);
});Every controller, job and command now type-hints WeatherProvider. The API key lives in exactly one place, tests get a stub automatically, and switching providers means editing one closure.
Run php artisan about to see which providers are loaded, and php artisan optimize in production to cache the provider manifest, config and routes. Remember to run php artisan optimize:clear after a deploy that changes any of them.