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

MethodInstances createdUse for
bind()A new one on every resolveStateful objects, anything cheap
singleton()One, reused for the whole requestConnections, clients, caches, config objects
scoped()One per request — reset between Octane requestsRequest-scoped state under Octane
instance()Zero — you supply the objectPre-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
MethodDoDo NOT
register()Bind classes, merge configResolve services, hit the database, register routes or events
boot()Routes, events, Blade directives, validators, macros, policies, view composersAnything 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.