Photo of Christoph Rumpel

4 Ways The Laravel Service Container Helps Us Managing Our Dependencies

The service container is a quite complex topic, and I see many struggling to understand what it does. It was the same for me, and the main reason is that many explanations concentrate on "how" to use the container. With this article, I want to give you my introduction to this topic by focusing on the "why" and "when" the container can help us with our dependencies.

As an example, let's say we have an export class. It helps us to save statistics for a specific user in a CSV file.

class UserStatsCsvExporter implements UserStatsExporterContract
{
    public function export(int $userId)
    {
         // Load user statistics...
         // Export file...
    }
}

Inside a controller, we create a new instance of the class and call the export method.

class ExportController extends Controller
{
    public function handle()
    {
        $userStatsExporter = new UserStatsCsvExporter();

        return $userStatsExporter->export(12);
    }
}

The exporter class is a dependency of our controller, but as you can see, we can handle it ourselves. So why do we need help from the service container with managing our dependency? And the answer is: Our handle method should not be responsible for creating the exporter class. Its only responsibility is to call the export method. This way we also apply the inversion of control principle.

1. Auto-Resolving

This is why we want to use dependency injection when we have a dependency. So what we can do instead of creating the instance in the handle method, is injecting it. This can be done inside the controller's constructor, but also with the method itself in Laravel. This is called method-injection.

public function handle(UserStatsCsvExporter $userStatsExporter)
{
    return $userStatsExporter->export(12);
}

By type-hinting the service class, we get an instance of it inside the handle method we can use. Interesting here is that this is already working, without us telling Laravel how to instantiate the class. It works because under the hood, we are already using the service container. More specifically, we are using the auto-resolving feature of the container.

Through PHP's reflection API, Laravel can find the dependency we need from our type-hint and can create it for us automatically. This is pretty cool when you think about it.

But there is more. What if our exporter class has a dependency itself.

class UserStatsCsvExporter implements UserStatsExporterContract
{

    /** @var Translator */
    private $translator;

    public function __construct(Translator $translator)
    {
        $this->translator = $translator;
    }

    public function export(int $userId)
    {
        // Load user statistics...
        // Export file...
    }
}

I have now added a constructor where we have a dependency for a translator class. The beautiful thing now is that our controller code is still working. So Laravel's auto-resolving feature is smart enough also to take care of the dependency of our dependency. How cool is that?

This is working as long our dependencies are just simple classes that Laravel can create on its own. Auto-Resolving stops working when we need to pass specific values to our classes.

2. Bind To The Container

Inside the Translator class, I have added a new constructor that defines we a language string when initializing this class.

class Translator
{
    /** @var string */
    private $language;

    public function __construct(string $language)
    {
        $this->language = $language;
    }
    
    public function translate(string $word)
    {
        // Translate word...
    }
}

Now Laravel has no clue on what to pass in here, and this is why auto-resolving is not working anymore. This is when we need to tell Laravel explicitly how to create our export instance and its dependencies. And the best place to write this code is inside a service provider.

Note: Service providers are the central place to configure your application. We use them to register services or components of Laravel or ourselves.

I have created a new service provider for our export class.

class UserStatsExporterProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(UserStatsCsvExporter::class, function() {
           return new UserStatsCsvExporter(new Translator(config('app.locale')));
        });
    }
}

In every service provider, we have access to the service container via $this->app. This is now our central place to define how to create a new instance of our exporter. The new language string is now being loaded from our configuration file. The information on how to create this instance is now being saved inside the service container instance. This means that every time we need this class, we can ask the service container for it and we never have to write the code for creating the instance again.

If you like, you can dump the container out by using dd(app()) in your controller. Under the bindings property, you will find an array that now also contains our Exporter class.

Screenshot of the dumped container and its bindings array with our class inside it

3. Bind To Interfaces

You probably have already seen that our CSV exporter class implements an interface. And this is because we also have a class for exporting to XML next to the CSV version. It also implements the interface. So maybe we want to switch out the implementation we use for exporting user statistics to use XML.

Of course, we could type-hint the XML version of the export class.

public function handle(UserStatsXmlExporter $userStatsExporter)
{
    return $userStatsExporter->export(12);
}

And then change the code in the service provider.

public function register()
    {
        $this->app->bind(UserStatsXmlExporter::class, function() {
           return new UserStatsXmlExporter(new Translator(config('app.locale')));
        });
    }

This would work, but there is a better way. Since we already have an interface defined, we can type-hint it instead of the concrete implementation we use.

public function handle(UserStatsExporterContract $userStatsExporter)
{
    return $userStatsExporter->export(12);
}

This way we say that we don't care if the injected class is for CSV or XML exporting. For us critical is, that it implements the exporter interface so we can make sure it is a proper exporter.

To make this work, we also need to bind now to the name of the interface inside our service provider.

public function register()
{
    $this->app->bind(UserStatsExporterContract::class, function() {
       return new UserStatsXmlExporter(new Translator(config('app.locale')));
    });
}

There is now only one place to change code if we want to switch out the exporter implementation we want to use. It's the service provider.

4. Sharing An Instance

The last feature of the service container I want to talk about today is about sharing. When we dump out two instances of our export class, you will see they have two different reference IDs. This means we have created two separate instances of the same class.

public function handle(UserStatsExporterContract $userStatsExporter)
{
    dd(app(UserStatsExporterContract::class), app(UserStatsExporterContract::class));
    
    return $userStatsExporter->export(12);
}
Screenshot showing two instances with a different reference id
Note: You can see that we used the "app()" helper method to resolve the instances directly from the container instead of using depenendy injection.

For most cases, this is what we want, but sometimes we want always to get the same instance back from the container. We can do this by using the singleton method instead of the bind method inside our service provider.

public function register()
{
    $this->app->singleton(UserStatsExporterContract::class, function() {
       return new UserStatsXmlExporter(new Translator(config('app.locale')));
    });
}
Screenshot showing two instances with the same reference id

You can see that now the reference IDs are the same when we dump out the two instances again. And there are two reasons when this makes sense:

1. Store State

When you store some information inside an instance that should still be there when another part of your application resolves it, then it makes sense to create a singleton.

2. Performance

Sometimes creating an instance is not as easy as spinning up a new class. You maybe have lots of dependencies you need to take care of, load configurations and more. In this case, it can also make sense to share the already created instance after it has been resolved the first time.

A good example is Laravel's database service. When you use it, it needs to open up a connection to your database driver. Here it makes sense to share the instance, so the connection stays open until the process is closed.

Note: When using a singleton, this instance will only be shared during the current request.

Conclusion

So these are the four ways the service container can help us managing our dependencies. I hope with this article, I could give you a practical approach to this topic by concentrating on "why" and "when" you want to use the service container.

If you want to learn more about this topic, check out my series Raiders of The Lost Service Container of my Laravel Core Adventures project. There you will find videos where I explain the container in detail.

There is also a nice talk by Matt Stauffer called Mastering The Illuminate Container I can recommend.

Let's stay in touch

Sign up for my newsletter and I will let you know about more content and new projects of mine once a month.