Services and the Container

A service is an object that does work; the container builds it and hands it over with its own dependencies already in place.

Syntaxpublic function __construct(private PriceFormatter $formatter) { }

A service is a plain PHP object that does one job for the application: formatting a price, reading a feed, writing a file. There is nothing special about the class itself. It becomes a service once the framework knows how to build it.

The container is the object that builds services. For each one it holds a service id (a string, by default the fully qualified class name), the class to instantiate, and the arguments to pass. Your code asks for a type and receives a finished object with its own dependencies already inside. Handing dependencies in like that, instead of letting a class find them, is dependency injection.

Why not write new yourself

A controller that writes new PriceFormatter('£') owns a decision that has nothing to do with answering a request, and every other caller repeats it. Declare the type in the constructor instead and the container supplies the one configured instance.

Shorthand and longhand

A new project's config/services.yaml contains one short block: App\: resource: '../src/'. It registers every class under src/ as a service. The longhand is one definition per class, each with its class and arguments spelled out. Both build the same container; the block just saves you writing the definitions.

What trips people up

Services are private by default, so they exist to be injected, not looked up. The container is compiled to PHP in var/cache, which is why reading a service definition is cheap at runtime.

Example

Example · php
// src/Service/PriceFormatter.php
declare(strict_types=1);

namespace App\Service;

final class PriceFormatter
{
    public function __construct(
        // a string is not a service, so the container cannot match it by type;
        // with a default value it does not need to
        private string $currencySymbol = '£',
    ) {
    }

    /**
     * @param int $amount money in minor units: 1250 means 12.50
     */
    public function format(int $amount): string
    {
        return $this->currencySymbol.number_format($amount / 100, 2, '.', ',');
    }
}

// (new PriceFormatter())->format(1250)
// Output: £12.50

// (new PriceFormatter('€'))->format(999999)
// Output: €9,999.99

When to use it

  • Move price or date formatting out of a controller so every page formats the same way.
  • Share one configured HTTP or filesystem client between several classes instead of constructing it in each.
  • Give a class an argument from configuration without hard-coding the value in the class.

More examples

Injecting the service into a controller

The controller never constructs the formatter; it declares the type and the container supplies it. A request to GET /basket/total returns 200 with the body £45.99. Change the currency in configuration and this file does not move.

Example · php
// src/Controller/BasketController.php
declare(strict_types=1);

namespace App\Controller;

use App\Service\PriceFormatter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class BasketController extends AbstractController
{
    // the container reads this type and passes the PriceFormatter service
    public function __construct(
        private readonly PriceFormatter $prices,
    ) {
    }

    #[Route('/basket/total', name: 'app_basket_total', methods: ['GET'])]
    public function total(): Response
    {
        return new Response($this->prices->format(4599));
    }
}

The short registration block and its longhand

Reach for the longhand when a service needs an argument the container cannot guess, such as a currency symbol or a directory. Definitions later in the file win, so this one replaces the version created by the resource block above it.

Example · yaml
# config/services.yaml
services:
    # defaults for every service defined in this file
    _defaults:
        autowire: true
        autoconfigure: true

    # shorthand: register every class under src/ as a service,
    # using its fully qualified class name as the service id
    App\:
        resource: '../src/'

    # longhand for one of the services that block creates.
    # Same container; written out so an argument can be set.
    App\Service\PriceFormatter:
        class: App\Service\PriceFormatter
        arguments:
            $currencySymbol: '€'

One service depending on another

Services depend on services. The container works out the order: it builds PriceFormatter first, then passes it to BasketSummary. Neither class knows the container exists, which is what makes both easy to instantiate by hand in a test.

Example · php
// src/Service/BasketSummary.php
declare(strict_types=1);

namespace App\Service;

final class BasketSummary
{
    public function __construct(
        private PriceFormatter $prices,
    ) {
    }

    /**
     * @param list<array{name: string, price: int}> $lines
     */
    public function render(array $lines): string
    {
        $out = [];
        $total = 0;

        foreach ($lines as $line) {
            $out[] = $line['name'].': '.$this->prices->format($line['price']);
            $total += $line['price'];
        }

        $out[] = 'Total: '.$this->prices->format($total);

        return implode("\n", $out);
    }
}

// $summary->render([['name' => 'Mug', 'price' => 950], ['name' => 'Tea', 'price' => 420]])
// Output:
// Mug: £9.50
// Tea: £4.20
// Total: £13.70

Discussion

  • Be the first to comment on this lesson.