Verified on Shopware 6.7

Shopware's dependency injection is constructor-based. The container builds your object graph at boot time, services are wired once, and that's it. The Symfony docs are clear: using ContainerInterface as a dependency is an anti-pattern. Pass what you need, not the whole container.

That rule holds almost everywhere. There is one specific place in Shopware where it breaks down, and it is worth understanding why.


The Problem: Dynamic Repository Access from a Twig Filter

Suppose you need a Twig filter that fetches entities by ID - generic enough to work with any entity type, driven by the template:

{% set products = { repositoryName: 'product', ids: productIds, associations: ['cover'] } | getEntityResultsByIds %}
{% set categories = { repositoryName: 'category', ids: categoryIds } | getEntityResultsByIds %}

The entity type is not known until runtime. That means you cannot inject a fixed repository. You need to resolve product.repository or category.repository dynamically - and the only thing that can do that at runtime is the container.

Constructor injection cannot express "give me whatever repository corresponds to this string." The container can.


Building the Filter

<?php declare(strict_types=1);

namespace Acme\Resources\TwigExtensions;

use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

class GetEntityResultsByIds extends AbstractExtension
{
    // Intentional service locator.
    // Dynamic repository resolution from a Twig filter is not possible
    // with constructor injection - the entity type is not known at build time.
    public function __construct(private readonly ContainerInterface $container) {}

    public function getFilters(): array
    {
        return [new TwigFilter('getEntityResultsByIds', [$this, 'getResults'])];
    }

    public function getResults(array $params): array
    {
        $repositoryName = $params['repositoryName'];
        $repository     = $this->container->get("{$repositoryName}.repository");

        $criteria = new Criteria($params['ids'] ?? []);

        foreach ($params['associations'] ?? [] as $association) {
            $criteria->addAssociation($association);
        }

        return $repository->search($criteria, Context::createDefaultContext())->getElements();
    }
}

Register it in services.xml:

<service id="Acme\Resources\TwigExtensions\GetEntityResultsByIds" public="true">
    <argument type="service" id="service_container"/>
    <tag name="twig.extension"/>
</service>

service_container is the Symfony DI container itself. The public="true" attribute is required when injecting the container this way.


Why the Clean Solution Doesn't Exist Here

The standard Symfony alternative to a service locator is a tagged service map - inject a ServiceLocator keyed by type, populated via !tagged_locator. That works when the set of types is finite and known at compile time.

Shopware's entity repositories are registered dynamically by the DAL. The full list is not enumerable at container build time in a way that makes tagged locators practical for this pattern. You would need to manually register every repository you might ever want to access, which defeats the purpose of a generic filter.

The container at runtime knows all of them. So the container is what you use.


When This Pattern Is Justified

1. Generic entity access in templates

The case above. You have a layout component - a CMS element, a shared Twig macro - that renders entities of different types depending on where it is used. Parameterizing the entity type through the template is cleaner than writing a separate filter per entity type.

2. Quick repository access during development and debugging

In a complex plugin ecosystem, you sometimes need to inspect entity state mid-render without wiring a dedicated service. A generic getEntityResultsByIds filter available in every template makes this trivial during development:

{# Dump the raw order line items without touching PHP #}
{% set items = { repositoryName: 'order_line_item', ids: [lineItemId] } | getEntityResultsByIds %}
{{ dump(items) }}

This is not something you build exclusively for production use - it is a development utility that happens to also be useful in real template logic. Having it always available in your Resources plugin means you reach for it instead of adding a one-off service injection every time you need a quick entity lookup during a debug session.

3. Twig extensions that bridge plugin boundaries

If your Resources plugin provides shared Twig utilities consumed by a dozen feature plugins, and those utilities need access to repositories that are only relevant in specific contexts, a service locator in the extension avoids forcing every feature plugin to inject repositories into a shared service just to cover cases it doesn't use.


What to Watch For

Fail loudly when the repository doesn't exist. The container will throw a ServiceNotFoundException if you pass an invalid name, which is the correct behavior - but make sure it propagates clearly rather than getting swallowed somewhere up the stack.

Do not use this as a general-purpose escape hatch. The moment you find yourself resolving services by name in a subscriber or a controller, stop - that is the pattern being abused. It belongs only where dynamic resolution is genuinely the only option.

Document the why, not the what. The comment in the class should say why constructor injection doesn't work here, not just that a service locator is being used. Future reviewers will thank you.


A service locator is only an anti-pattern when there is a better option. Here, there isn't.