In one sentence

The service container is Laravel's object factory: you ask for a class, and it builds it, along with everything that class needs, automatically.

๐Ÿ”Think of a restaurant kitchen. You order a burger; you don't bake the bun, grill the patty or slice the tomato yourself. The kitchen knows the recipe, prepares every ingredient in the right order and hands you the finished plate.

Why it matters

The engine behind Laravel's "magic"

๐Ÿช„

It explains the magic

Ever wondered how a Request $request argument just "appears" in your controller? That's the container at work.

๐Ÿ”Œ

Swap parts easily

Depend on an interface, then tell the container which real class to use. Switch from Mailgun to SES by changing one line.

๐Ÿงช

Testable code

Because classes receive their dependencies instead of creating them, tests can hand in fakes and mocks.

The big picture

The container builds a whole tree of objects

๐Ÿงฐ Service container app(PostController::class) PostController __construct(PostPublisher $publisher) PostPublisher needs SlugGenerator + Notifier SlugGenerator concrete class, no dependencies Notifier (interface) bound โ†’ MailNotifier 1 2 3 4
The container reads each constructor, builds the deepest dependencies first (1, 2), then works back up (3, 4) and hands you a ready-to-use PostController.
Dependency
Another object a class needs to do its job. PostPublisher depends on a SlugGenerator.
Dependency injection (DI)
Handing a class its dependencies from the outside (usually through the constructor) instead of letting it create them with new.
Resolving
Asking the container to build an object for you: "give me a PostPublisher".
Binding
A recipe you register that tells the container how to build something it can't figure out alone, like which class to use for an interface.
Step by step

From plain DI to the container

We'll use a blog where publishing a post creates a slug and notifies subscribers.

  1. Dependency injection, without any container

    First, the bad version: the class builds its own helpers with new. It's glued to those exact classes, and you can't swap them in a test.

    
              class PostPublisher
              {
                  public function publish(Post $post): void
                  {
                      $slugs = new SlugGenerator();          // hard-wired
                      $notifier = new MailNotifier();         // hard-wired
                      // ...
                  }
              }
            

    Now the DI version. The class simply asks for what it needs in its constructor. Whoever creates it must supply them:

    
              class PostPublisher
              {
                  public function __construct(
                      private SlugGenerator $slugs,
                      private Notifier $notifier,
                  ) {}
    
                  public function publish(Post $post): void
                  {
                      $post->slug = $this->slugs->for($post->title);
                      $post->save();
                      $this->notifier->send("New post: {$post->title}");
                  }
              }
    
              // Building it by hand: works, but tedious
              $publisher = new PostPublisher(new SlugGenerator(), new MailNotifier());
            

    That last line is the problem DI creates: somebody has to wire everything together. That somebody is the service container.

  2. Let the container resolve it automatically

    Type-hint a class in a controller, and Laravel builds it for you. This is called automatic resolution (or "autowiring"). The container uses PHP's reflection to read the constructor, then resolves each type-hinted parameter, recursively.

    
              class PostController extends Controller
              {
                  public function __construct(private PostPublisher $publisher) {}
    
                  // Method injection works too: Laravel fills type-hinted arguments
                  // and route parameters side by side.
                  public function publish(Request $request, Post $post)
                  {
                      $this->publisher->publish($post);
                      return back()->with('status', 'Published!');
                  }
              }
            

    Concrete classes with no special setup need zero configuration. You don't register SlugGenerator anywhere; the container just does new SlugGenerator().

    โ„น๏ธ Where does injection happen?

    Anything Laravel builds for you gets autowiring: controllers, middleware, listeners, jobs' handle() methods, form requests, Artisan commands and more.

  3. Bind an interface to an implementation

    Notifier is an interface, so the container can't guess which class to build. You register a binding in a service provider's register() method:

    
              use App\Contracts\Notifier;
              use App\Services\MailNotifier;
    
              class AppServiceProvider extends ServiceProvider
              {
                  public function register(): void
                  {
                      $this->app->bind(Notifier::class, MailNotifier::class);
                  }
              }
            

    Every class that type-hints Notifier now receives a MailNotifier. Tomorrow you switch to SlackNotifier by editing this one line. Nothing else changes. That's polymorphism paying off.

    Need custom setup? Pass a closure. It receives the container, so you can resolve other things inside it:

    
              $this->app->bind(Notifier::class, function (Application $app) {
                  return new SlackNotifier(
                      webhook: config('services.slack.webhook'),
                      http: $app->make(HttpClient::class),
                  );
              });
            
  4. bind vs singleton vs scoped

    These three methods differ in how many instances the container hands out:

    MethodWhat you getGood for
    bind()A new object every time it's resolvedLightweight, stateful helpers
    singleton()Built once, then the same object for the rest of the app's lifeExpensive clients (API SDKs, connections)
    scoped()Once per request or queued job, then flushedPer-request state under Octane or queue workers
    instance()The exact object you already createdRegistering a pre-built object
    
              $this->app->singleton(StripeClient::class, fn () =>
                  new StripeClient(config('services.stripe.secret'))
              );
    
              $this->app->scoped(CurrentTeam::class);
            
    โš ๏ธ Singletons and long-running workers

    Under Laravel Octane or a queue worker, the app stays in memory between requests. A singleton holding "the current user" would leak into the next request. Use scoped() for anything request-specific.

  5. Resolve things manually

    Sometimes you're not in a place with injection (a plain helper function, a Tinker session). Ask the container directly. All of these do the same thing:

    
              $publisher = app(PostPublisher::class);
              $publisher = app()->make(PostPublisher::class);
              $publisher = resolve(PostPublisher::class);
    
              // Pass extra constructor values the container can't guess:
              $report = app()->makeWith(PostReport::class, ['year' => 2025]);
            

    Prefer constructor injection when you can: it shows a class's dependencies right in its signature. Manual resolving hides them.

  6. Contextual binding (briefly)

    Occasionally two classes need different implementations of the same interface. Contextual binding says "when building X, give it Y":

    
              $this->app->when(AdminAlertController::class)
                  ->needs(Notifier::class)
                  ->give(SlackNotifier::class);
    
              $this->app->when(NewsletterController::class)
                  ->needs(Notifier::class)
                  ->give(MailNotifier::class);
            

    You won't need this often, but it's good to know it exists before you reach for messy if statements.

Common mistakes

Avoid this, do that

โŒ Avoid

          public function store(Request $request)
          {
              $gateway = new StripeGateway('sk_live_...');
              $gateway->charge(...);
          }
        

Creating dependencies with new inside methods hard-wires them and makes the code impossible to test without real charges.

โœ… Do

          public function store(Request $request, PaymentGateway $gateway)
          {
              $gateway->charge(...);
          }
        

Type-hint an interface and bind it in a provider. Tests can then swap in a fake gateway.

โŒ Avoid

          public function register(): void
          {
              $this->app->bind(SlugGenerator::class, SlugGenerator::class);
          }
        

Binding a concrete class to itself does nothing useful; the container already builds it automatically.

โœ… Do

          public function register(): void
          {
              $this->app->bind(Notifier::class, MailNotifier::class);
          }
        

Only bind what needs a decision: interfaces, config values or one-instance rules.

๐Ÿ’ก Pro tip: swap bindings in tests

In a test you can replace any binding before the code runs. The rest of the app receives your fake without knowing it:


        $this->app->instance(Notifier::class, new FakeNotifier());
      
๐Ÿ‹๏ธ Try it yourself
  1. Create app/Services/SlugGenerator.php with a for(string $title): string method that calls Str::slug().
  2. Type-hint it in a controller method and return its output. Notice you registered nothing.
  3. Create a Notifier interface and two classes, LogNotifier and MailNotifier. Bind the interface to LogNotifier in AppServiceProvider.
  4. In php artisan tinker, compare app(Notifier::class) === app(Notifier::class) with bind(), then with singleton().
Recap

Key takeaways

Check yourself

Mini quiz