Mastering Symfony’s Service Container: With Real-life Examples
In this article, we will learn about Symfony Service Containers by building a product management system. We will start with the very basics, like registering a service and autowiring, and go into deeper topics like service decorators and service subscribers. All of the features will be illustrated with real-life examples
What is a service container?
In Symfony, your app uses helpful objects called services for various tasks, like sending emails or saving data. These services reside in a special object called the service container. This container acts like a central place where these services live, simplifying how objects are created.
Please read more about Service Container documentation
What is dependency injection?
Dependency injection in Symfony is a way to provide the necessary objects (dependencies) that a class or service needs from external sources rather than letting the class create those objects itself.
Instead of a class creating its own dependencies, Symfony’s Dependency Injection Container (DIC) injects these dependencies into the class when it’s instantiated. This allows for greater flexibility, easier testing, and helps keep code modular and maintainable.
In Symfony, you define the dependencies your class needs through configuration (usually in a services.yaml or services.php file), and the Dependency Injection Container manages the injection of these dependencies when the class is used. This promotes a more decoupled and manageable codebase. You can watch this great talk in Youtube about DI Demystifying Dependency Injection Containers by Kai Sassnowski
Github Repo
In this article, we will try to create a dummy product CRUD. First of all, let’s clone the repo
git clone https://github.com/digitaldreams/symfony-service-container
Here is the list of features and their corresponding Github tags
- Create Product (v1, v2)
- Notify admin when new product created (v3.*)
- Decoupling ProductRepository using Dependency Inversion Principle (v4)
- Fetch and show products from Different vendors (v5.*)
- Apply discount to products (v6)
- Handling uploads (v7)
In this article we will write our services in yaml file format. Symfony also supports php or xml. Learn more about Yaml from symfony docs if you are not familiar with it.
How to create a service in services.yaml
services: # It's the root key. All of your application services will be under this service key.
service_id, or fully qualified class name:
class: Required if you choose key as a String instead of a fully qualified Name
arguments:
@service_id, or Fully Qualified Class Name Here, is mentatory because it refers to a service. Its your first Argument
As you can see from the above example, it seems like a multidimensional associative array.
Service ID: Each service has a service ID as a key, and its value will be its definition. It can be a snake-case string or a fully qualified class name. If the service ID is a string rather than a fully qualified class name, then class
key must be provided in its definition.
Service defination:
Under the service ID, we need to write the rules for the service container for this class. We can map which service will be injected in the constructor arguments using arguments
We can add,tags
like we do in blog posts. So that we can organise and fetch services based on tags.
Create Product
Please run git checkout v1
namespace App\Controller;
use App\Service\CreateProductService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class CreateProductController
{
protected CreateProductService $createProduct;
public function __construct(CreateProductService $createProduct)
{
$this->createProduct = $createProduct;
}
#[Route('/product/create')]
public function create(Request $request)
{
$product = $this->createProduct->save(['name' => $request->get('name'), 'price' => $request->get('price')]);
return new JsonResponse([
'id' => $product->getId(),
'name' => $product->getName(),
'price' => $product->getPrice()
]);
}
}
It’s a simple product controller that creates a product using CreateProductService
which it dependsProductRepository
.
First-level dependency
services:
App\Controller\ProductController:
arguments:
- '@App\Service\CreateProductService'
tags: [ 'controller.service_arguments' ]
Here ProductController
needs CreateProductService
as its first argument. Now, containers need to know how to be constructedCreateProductService
.
Note: A string start with @ symbol is considered to be a service. It can be a fully qualified name of that class or service_id like
@doctrine
Second-level dependency
services:
App\Service\CreateProductService:
arguments:
- '@App\Persistence\Repository\ProductRepository'
Again, CreateProductService
needs ProductRepository
as its first argument. Let's define that service too.
Third-level dependency
services:
App\Persistence\Repository\ProductRepository:
arguments:
- '@doctrine'
Here our repository needsdoctrine
as its first argument: It's a third-party service, so its already defined. It's important to notice here. It's a string, not a fully qualified name. The main benefit of this string-based service ID is that others who are using your service do not need to know the fully qualified name of your class. So that you can rename or change the namespace of your class anytime, which is not possible for class-based ID.
A web app has hundreds of services. Defining each of them manually is horrible. Let’s make it simple.
Please run git checkout v2
services:
_defaults:
autowire: true
App\:
resource: '../src/'
exclude: '../src/{Entity,Kernel.php}'
Here we instruct Symfony to load all of the classes as services inside the src folder, except Entity
folder and kernel.php
file._defaults
holds the global configuration. Symfony does not register the controller as a service. We need to add controller.service_arguments
tag to make it a working controller.
With autowire: true
, you're able to type-hint arguments in the __construct()
method of your services and the container will automatically pass you the correct arguments
services:
App\Controller\:
resource: '../src/Controller'
tags: [ 'controller.service_arguments' ]
If your controller extends from
Symfony\Bundle\FrameworkBundle\Controller\AbstractController
then above configuration is not required.
Like earlier, we load all of the classes inside the Controller folder and add a tag to each of them. That’s it. Now you are free to add any Classes and Controllers throughout your application
There are three ways that dependencies can be injected. Each injection point has advantages and disadvantages to consider, as well as different ways of working with them when using the service container. In the above, we just learned about constructor injection.
Notify admin when new product created
please run git checkout v3
When a product is created, we should notify the admin.
namespace App\Service;
use App\Entity\Product;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
class NewProductCreatedNotification
{
protected string $adminEmail;
public string $emailFrom ;
public function __construct(protected MailerInterface $mailer)
{
}
public function setAdminEmail(string $adminEmail): self
{
$this->adminEmail = $adminEmail;
return $this;
}
public function send(Product $product)
{
$email= new Email();
$email->from($this->emailFrom)
->to($this->adminEmail)
->subject('New product created')
->text("Name: {$product->getName()} \n Price: {$product->getPrice()}");
$this->mailer->send($email);
}
}
Now let’s use this class to send admin email in CreateProductService
namespace App\Service;
use App\Entity\Product;
use App\Persistence\Repository\ProductRepository;
class CreateProductService
{
public function __construct(
protected ProductRepository $repository,
protected NewProductCreatedNotification $productCreatedNotification
) {
}
public function save(array $data)
{
$product = new Product(id: null, name: $data['name'], price: $data['price'], status: 'created');
$this->repository->save($product);
$this->productCreatedNotification->send($product);
return $product;
}
}
adminEmail
andemailFrom
property are required, but we haven't set them up here. Let's set it up via services.yaml
services:
App\Service\NewProductCreatedNotification:
autowire: false
arguments:
- '@mailer'
calls:
setAdminEmail: [ 'admin@example.com' ]
properties:
emailFrom: 'no-reply@example.com'
Firstly, Tell Symfony not use autowire for this specific service via autowire: false
Secondly, once the service container instantiates this class, callsetAdminEmail
and pass admin@example.com
as an argument to that method.
Thirdly, Set emailFrom
property value to no-reply@example.com
Lets load emails from environment variables
calls:
setAdminEmail: [ '%env(ADMIN_EMAIL)%' ]
properties:
emailFrom: '%env(FROM_EMAIL)%'
These emails might be required by other services. So make it a Service parameters. So that these values can be accessed throughout the application
parameters:
app.admin_email: '%env(ADMIN_EMAIL)%'
app.from_email: '%env(FROM_EMAIL)%'
services:
App\Service\NewProductCreatedNotification:
autowire: false
arguments:
- '@mailer'
calls:
setAdminEmail: [ "@=parameter('app.admin_email')" ]
properties:
emailFrom: "@=parameter('app.from_email')"
Here we are using express language to set the value.
Decoupling ProductRepository using Dependency Inversion Principle
The Dependency Inversion Principle (DIP) in SOLID suggests that high-level modules should not rely on low-level details but on abstractions. For instance, a ProductRepository
extending Doctrine should depend on an interface or abstraction (ProductRepositoryInterface) defining database operations rather than directly on Doctrine. This separation allows flexibility in changing database frameworks while adhering to DIP principles.
namespace App\Persistence\Repository;
use App\Entity\Product;
interface ProductRepository
{
public function save(Product $product): Product;
public function findById(int $id): Product;
}
So we converted ProductRepository
as interface
namespace App\Persistence\Repository;
class DoctrineProductRepository extends ServiceEntityRepository implements ProductRepository
{
The code goes here. Please run git checkout v4 to see the full code
}
services:
App\Persistence\Repository\ProductRepository: '@App\Persistence\Repository\DoctrineProductRepository'
We tell you here when ProductRepository
interface needs give DoctrineProductRepository
class.
Now we have other implementation of ProductionRepository
like InMemoryProductRepository
. Sometimes I need to access that as well. To do so, like bind it,
Binding
services:
_defaults:
bind:
App\Persistence\Repository\ProductRepository $inMemoryProductRepository: '@App\Persistence\Repository\InMemoryProductRepository'
If you name a variable$inMemoryProductRepository
in a service constructor, it will automatically use InMemoryProductRepository
instead of the defaultDoctrineProductRepository
.
class CreateProductService
{
public function __construct(
protected ProductRepository $inMemoryProductRepository,
) {
}
}
We can also bind scalar value like string or an integer
services:
_defaults:
bind:
string $adminEmail: 'admin@example.com'
string $emailFrom: 'no-reply@example.com'
class SomeClass {
public function __constructor(string $adminEmail, string $emailFrom)
}
When a dependency injection container creates an instance of SomeClass
or any class and its constructor has $adminEmail
or $emailFrom
arguments. It will resolve these arguments with the configured value.
Show products from Different vendors
In our mini-e-commerce application, we need to show our products from different vendors like amazon and eBay in an api endpoint /api/products
please run git checkout v5
namespace App\Service\Vendors;
interface VendorInterface
{
public function getName(): string;
public function getAll(int $limit = 10, int $page = 1);
}
We create Amazon
, Ebay
, InMemoryProduct
and DoctrineProduct
that implements VendorInterface
namespace App\Service\Vendors;
use App\Entity\Product;
class VendorsHandler
{
/**
* @param VendorInterface[] $vendors
*/
public function __construct(protected iterable $vendors)
{
}
/**
* @return Product[]
*/
public function getAll(int $limit = 10, $page = 1): array
{
$products = [];
foreach ($this->vendors as $vendor) {
$vendorProducts = $vendor->getAll($limit, $page);
foreach ($vendorProducts as $vendorProduct) {
// To see full code please git checkout v5
}
}
return $products;
}
}
In the constructor above, it expects a list of vendor objects as an array.
services:
App\Service\Vendors\VendorsHandler:
arguments:
- !tagged_iterator vendor
Here we are told to find all of the services that have a tag called vendor
and inject all of them as an array.
services:
App\Service\Vendors\Amazon:
tags: ['vendor']
App\Service\Vendors\Ebay:
tags: [ 'vendor' ]
App\Service\Vendors\InMemoryProduct:
tags: [ 'vendor' ]
App\Service\Vendors\DoctrineProduct:
tags: [ 'vendor' ]
Here we tagged classes so that Symfony will inject them inside the VendorsHandler
constructor argument. Let's maintain this tagging in a better way.
#pelase run git checkout v5.1
services:
_defaults:
autoconfigure: true
_instanceof:
App\Service\Vendors\VendorInterface:
tags: [ 'vendor' ]
Now, whenever a container sees a service implement,VendorInterface
it will automatically add vendor
tag for you via autoconfigure
. So you don't have to add tags to your vendor services manually. All you need to do is implement the interface.
// Plase run git checkout v5.1
namespace App\Controller;
use App\Service\Vendors\VendorsHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class ProductIndexController
{
public function __construct(protected VendorsHandler $vendorsHandler)
{
}
#[Route('/products')]
public function index(Request $request)
{
$products = $this->vendorsHandler->getAll($request->get('perPage', 10), $request->get('page', 1));
return new JsonResponse([
'data' => $products,
'pagination' => [
'perPage' => $request->get('perPage', 10),
'page' => $request->get('page', 1)
]
]);
}
}
Now products from different vendors will be shown in this controller. The addition or removal of any vendors will be implemented here automatically.
Apply discount to products
In our Mini E-commerce app. From time to time, we like to discount our products for a certain time frame only. Manually adjusting all of the product prices is almost impossible. To do that, we can use Decorator pattern
.
<?php
namespace App\Service\Offers;
use App\Service\Vendors\VendorsHandler;
class OfferHandler
{
public function __construct(
protected VendorsHandler $vendorsHandler,
/**
* @var OfferInterface[] $offers
*/
protected iterable $offers
) {
}
public function getAll(int $limit = 10, $page = 1): array
{
$products = $this->vendorsHandler->getAll($limit, $page);
foreach ($this->offers as $offer) {
if ($offer->endAt() >= new \DateTimeImmutable()) {
$products = array_map(fn($product) => $offer->applyDiscount($product), $products);
}
}
return $products;
}
}
App\Service\Offers\OfferHandler:
decorates: 'App\Service\Vendors\VendorsHandler'
arguments:
- '@.inner'
- !tagged_iterator offers
Here we decorate theVendorsHandler
class. When the vendor handler class is needed, the service container will give the offer handler. The first argument@.inner
means we want to inject the original service, which is the vendor handler class. Second, we want to inject all of the services that have a tag called offers
. so that we can apply offers to the products fetched from VendorsHandler.
When to use the decorator pattern?
When you need to alter a service temporarily, it’s best to decorate it or modify a service provided by a third-party library.
A few things needed to be doneProductIndexController
. Please rungit checkout v6
to see these minor changes.
To learn more, read How to decorate a service from the documentation.
Handling uploads
please run git checkout v7
We’re aiming to upload products using a specific file type. Each file extension has its own handler. For instance, the CscFileParser manages all files with the.csv extension.
namespace App\Service\Uploads;
interface FileImporterInterface
{
public function upload(File $file): array;
}
We created CsvFileImport
ExcelFileImporter
and JsonFileImport
class that implements the above interface.
Typically, we’d make a new classUploadHandler
that gets all the necessary classes through its constructor. It would also take the uploaded file as an argument for itsupload
method and then use a switch statement to pick the right class based on the file extension. The service container has to create and provide all the classes needed to start up the UploadHandler, even though only one of those classes will be used at any given time.
There are three ways we can improve this situation.
The first two options will do the job for us, but considering our situation, service subscribers would be a better fit.
namespace App\Service\Uploads;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Psr\Container\ContainerInterface;
class UploadHandler implements ServiceSubscriberInterface
{
public function __construct(private ContainerInterface $container)
{
}
public static function getSubscribedServices(): array
{
return [
'csv' => CsvFileImport::class,
'json' => JsonFileImport::class,
'xlsx' => ExcelFileImporter::class,
];
}
public function upload(File $file)
{
$extension= $file->guessExtension();
if ($this->container->has($extension)) {
$handler = $this->container->get($extension);
return $handler->upload($file);
}
}
}
We will register our services using thesegetSubscribedServices
methods. The key will be the extension, and the value will be a fully qualified class name. Service Container will instantiate only one service for handling the uploaded file.