Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel's Service Container Explained: Who Calls new on Your Classes?

About Post

Here's a question that sounds silly until you try to answer it: who calls new on your controllers?

You write public function __construct(private InvoiceService $invoices), you never create an InvoiceService anywhere, and yet it shows up, fully built, with its own dependencies already inside it. It feels like magic. It isn't. It's the service container, and once you see how it works, a lot of Laravel stops feeling mysterious and starts feeling like a tool you control.

The problem it solves

Start with the version most of us wrote early on:

class InvoiceService
{
    public function charge(Invoice $invoice): Payment
    {
        $gateway = new StripeGateway(config('services.stripe.secret'));

        return $gateway->charge($invoice->amount, $invoice->currency);
    }
}

It works. It's also welded shut. You can't test charge() without hitting a real payment API, you can't switch gateways without editing this class, and every class that needs a gateway repeats the same new plus config lookup.

Dependency injection is the boring fix: don't build what you need, ask for it.

class InvoiceService
{
    public function __construct(private PaymentGateway $gateway) {}

    public function charge(Invoice $invoice): Payment
    {
        return $this->gateway->charge($invoice->amount, $invoice->currency);
    }
}

Now InvoiceService doesn't know or care which gateway it gets. Somebody else decides. In Laravel, that somebody is the container.

Auto-wiring: the part that feels like magic

When Laravel needs an object (a controller, a job, a listener, a form request), it asks the container to resolve it. For a concrete class, the container uses PHP's Reflection API to read the constructor, looks at each type hint, resolves those classes the same way, and keeps going down the tree until everything is built.

That's why this works with zero configuration:

class ContractPdfService
{
    public function __construct(
        private PdfRenderer $renderer,
        private ContractRepository $contracts,
    ) {}
}

$service = app(ContractPdfService::class); // fully built, dependencies included

Auto-wiring has two limits worth knowing:

  • Interfaces. The container can't guess which class implements PaymentGateway. You'll see Target [App\Contracts\PaymentGateway] is not instantiable.
  • Scalars. A constructor that wants string $apiKey can't be auto-wired either. There's no class to reflect on.

Both are solved by telling the container what you want. That's a binding.

Bindings: teaching the container

Bindings live in the register() method of a service provider, usually AppServiceProvider:

public function register(): void
{
    $this->app->bind(PaymentGateway::class, function ($app) {
        return new StripeGateway(config('services.stripe.secret'));
    });
}

Now any class in your app that type-hints PaymentGateway gets a StripeGateway. Switching providers later means changing this one closure, not hunting through the codebase. If the implementation can be auto-wired itself, you can skip the closure: $this->app->bind(PaymentGateway::class, StripeGateway::class).

The scalar problem has a neat answer since Laravel 11: contextual attributes. Instead of a binding, put the attribute on the parameter.

use Illuminate\Container\Attributes\Config;

class StripeGateway implements PaymentGateway
{
    public function __construct(
        #[Config('services.stripe.secret')] private string $secret,
    ) {}
}

bind, singleton or scoped?

This is where real bugs come from, so it's worth a table.

MethodWhat you getGood for
bindA new instance every time it's resolvedStateful or cheap objects
singletonOne instance for the life of the app processExpensive, stateless clients (an HTTP client, an SDK)
scopedOne instance per request or queued job, then resetPer-request state (current tenant, request context)

The gotcha: "the life of the app process" means something different in different places. In classic PHP-FPM, the process forgets everything after each request, so a singleton that holds state looks harmless. Run the same code in a long-lived queue worker or under Laravel Octane, and that singleton survives between jobs or requests. If it caches "the current user" or "the current tenant", the next job gets the previous one's data.

Rule of thumb: if the object holds anything about who or what is being processed right now, it's scoped or bind, never singleton.

The real payoff: testing

Because your code asks the container for a PaymentGateway instead of building one, a test can put something else in the container first. Laravel's mock() helper does exactly that:

use Mockery\MockInterface;

public function test_paying_an_invoice_records_a_payment(): void
{
    $this->mock(PaymentGateway::class, function (MockInterface $mock) {
        $mock->shouldReceive('charge')->once()->andReturn(new ChargeResult('ch_test', 'succeeded'));
    });

    $invoice = Invoice::factory()->create();

    $this->actingAs($invoice->tenant->user)
        ->postJson("/api/invoices/{$invoice->id}/pay")
        ->assertOk();

    $this->assertDatabaseHas('payments', ['invoice_id' => $invoice->id]);
}

No network, no test keys, no flaky third party. The controller, the service and the database code are all real. Only the edge of the system is swapped. If you prefer a hand-written fake over Mockery, $this->instance(PaymentGateway::class, new FakeGateway) does the same job.

The test of good design: if swapping a dependency in a test needs anything more than one line of container setup, the class is probably building its own dependencies somewhere. Find the new.

Mistakes I see with the container

  • Calling app() everywhere. Pulling dependencies out of the container inside methods (app(PaymentGateway::class)->charge(...)) works, but it hides what a class needs. Constructor injection makes dependencies visible at a glance. Keep app() for the rare places injection can't reach.
  • Resolving things in register(). Inside register(), other providers may not have registered their bindings yet. Bind in register(), use services in boot().
  • Interfaces for everything. You don't need an interface for every class. Add one where there's a real seam: external services, payment gateways, SMS providers, storage. A concrete class can still be mocked when you need to.
  • Stateful singletons. Covered above, and worth repeating, because it's the one that only fails in production workers.

If you remember one thing

The container is a factory that reads your constructors. Ask for what you need in the constructor, bind interfaces where you want a choice, pick scoped over singleton when state is involved, and your tests get easy almost by accident.

What's the strangest container bug you've chased? Mine always seem to involve a singleton that remembered a little too much.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close