Sitemap

Service decoration in Symfony: The most underused architectural lever

--

There is a moment in every mature Symfony project where a clean service starts to get… heavier.

It began as something simple.

readonly class InvoiceGenerator
{
public function generate(Order $order): Invoice
{
// pure business logic
}
}

It was clean. Focused. Testable. Exactly what we want.

Then reality kicks in:

  • Product wants execution metrics.
  • Ops wants better logging.
  • You need retries because a third-party API is flaky.
  • You add caching because performance starts to hurt.

And suddenly your “pure” service is no longer pure.

You add a logger. Then a feature flag. Then some tracing. Then maybe a circuit breaker. The business logic is still there… but it’s now buried under cross-cutting concerns. And that’s usually the moment where developers start making compromises:

  • Let’s just inject one more dependency.
  • It’s fine, it’s just a few lines.
  • We’ll refactor later.

⚠️ Any resemblance to real projects, codebases, or developers, is purely coincidental.

Probably.

We rarely do.

Symfony has a built-in mechanism that solves this cleanly: service decoration.

Not as a trick to override a service.
Not as a hack.
But as an architectural tool! I know, it’s so cool!

In this article, I want to show you how service decoration becomes a strategic lever in real production projects when used intentionally.

Because once you understand what it really gives you, you stop polluting your core services. And that changes how you design your application.

Why decoration is not just “overriding a service”

When most developers first discover service decoration in Symfony, they see it as a way to “override” a service. Technically, that’s true. But architecturally, that’s missing the point.

Decoration is not about replacing behavior. It’s about wrapping behavior without modifying it. That difference matters more than it sounds!

Open/Closed Principle in real life

In theory, we all agree with the Open/Closed Principle:

Software entities should be open for extension, but closed for modification.

In practice, we often take the pragmatic shortcut and modify the class directly. It’s faster. The dependency is already there. “It’s just a few lines.” And over time, logging, metrics, retries, feature flags, tracing, all of it slowly accumulates inside what used to be a focused business service.

Decoration gives you something very different:

  • The original service remains untouched.
  • The contract remains unchanged.
  • The behavior is extended externally.
  • The extension is explicit in the container.

That last point is critical.

With decoration, the extension is not hidden in:

  • a trait
  • a parent class
  • a subscriber
  • a compiler pass

It’s declared in the DI configuration. It’s visible. Intentional. Traceable.

That’s powerful!

  • Compared to inheritance, decoration relies purely on composition. You are not binding yourself to implementation details.
  • Compared to event subscribers, you are not introducing an implicit execution flow that developers have to mentally reconstruct.
  • Compared to traits, you are not mixing responsibilities in the same class.

Instead, you build a chain of small layers, each implementing the same interface and delegating to the next one. The result is close to a middleware pipeline, but scoped to a specific service rather than the entire HTTP kernel.

The important nuance: You control the chain

The real strength of Symfony’s decoration system is not the syntax. It’s the fact that you can stack decorators and control their order!

That means you can decide:

  • Should logging wrap caching?
  • Should retries happen before metrics?
  • Should feature flags short-circuit everything?

You are explicitly building a pipeline. And once you start seeing it that way, you stop thinking: “How do I override this service?”. And you start thinking: “What layers should wrap this behavior?”.

That mental shift is the real value!

Observability without polluting business code

Let’s go back to our InvoiceGenerator.

At the beginning, it was simple and focused:

interface InvoiceGeneratorInterface
{
public function generate(Order $order): Invoice;
}

And the implementation was doing exactly what it should: generating an invoice from an order. Nothing more.

Then production happened.

You start needing visibility. How long does invoice generation take? Does it fail? Is it slowing down under load? You add some logging.

class InvoiceGenerator implements InvoiceGeneratorInterface
{
public function __construct(
private LoggerInterface $logger,
) {}

public function generate(Order $order): Invoice
{
$start = \microtime(true);

$invoice = $this->doGenerate($order);

$this->logger->info('Invoice generated', [
'duration' => \microtime(true) - $start,
]);

return $invoice;
}
}

It looks harmless. Until you need metrics. So you inject a metrics client.

Then maybe a tracer.

Then maybe you add a retry because invoice generation now calls a third-party tax API.

At some point, your constructor looks like this:

public function __construct(
private LoggerInterface $logger,
private MetricsClient $metrics,
private TaxApiClient $taxApi,
private TracerInterface $tracer,
) {}

The class still “works”. Tests still pass. But something changed.

The service is no longer about generating invoices. It’s orchestrating infrastructure concerns. The business logic is diluted inside technical noise. And any new concern forces you to reopen and modify the class.

This is exactly the kind of situation where decoration shines.

Extracting Observability into a Decorator

Instead of injecting logging into the core service, we move that responsibility outside.

#[AsDecorator(
decorates: InvoiceGenerator::class,
priority: 10
)]
class LoggingInvoiceGenerator implements InvoiceGeneratorInterface
{
public function __construct(
#[AutowireDecorated]
private InvoiceGeneratorInterface $inner,
private LoggerInterface $logger,
) {}

public function generate(Order $order): Invoice
{
$start = \microtime(true);

try {
$invoice = $this->inner->generate($order);

$this->logger->info('Invoice generated', [
'duration' => \microtime(true) - $start,
]);

return $invoice;
} catch (\Throwable $e) {
$this->logger->error('Invoice generation failed', [
'exception' => $e,
]);

throw $e;
}
}
}

The original InvoiceGenerator goes back to being boring and that’s exactly what we want.

From the outside, nothing changes. The interface stays the same. Consumers don’t even know there’s a decorator involved.

But architecturally, we’ve made a strong decision:

  • Observability is not business logic.
  • Observability wraps business logic.
  • Observability can evolve independently.

If tomorrow we want to replace logging with OpenTelemetry, or remove it entirely in certain environments, we don’t touch the core service.

That’s the key benefit: we extend behavior without reopening the class every time production teaches us something new. And production always teaches us something new.

Above all, priority introduces something important: order.

Stacking decorators intentionally

Now let’s imagine that we want to add a retry around invoice generation.

#[AsDecorator(
decorates: InvoiceGenerator::class,
priority: 20,
)]
class RetryInvoiceGenerator implements InvoiceGeneratorInterface
{
private const int MAX_ATTEMPTS = 3;

public function __construct(
#[AutowireDecorated]
private readonly InvoiceGeneratorInterface $inner,
private readonly LoggerInterface $logger,
) {}

public function generate(Order $order): Invoice
{
$attempt = 0;

while ($attempt < self::MAX_ATTEMPTS) {
try {
$attempt++;

return $this->inner->generate($order);
} catch (TransientTaxException $e) {
if ($attempt >= self::MAX_ATTEMPTS) {
$this->logger->error('Invoice generation failed after all retries.', [
'attempts' => $attempt,
'orderId' => $order->getId(),
'exception' => $e::class,
]);

throw $e;
}

$this->logger->warning('Retrying invoice generation...', [
'attempt' => $attempt,
'remaining' => self::MAX_ATTEMPTS - $attempt,
'orderId' => $order->getId(),
]);
}
}

throw new \LogicException('Unreachable code in RetryInvoiceGenerator.');
}
}

This is intentionally simple. In a real system you might use exponential backoff or Symfony’s HttpClient retry strategy, but the architectural idea is the same.

With these priorities:

  • RetryInvoiceGenerator (priority 20)
  • LoggingInvoiceGenerator (priority 10)
  • InvoiceGenerator (core)

The execution chain becomes:

Retry -> Logging -> Core

That means retries wrap the logging layer. If you reverse priorities, the logging layer would wrap the retry logic instead and that changes what exactly you measure.

This is where decoration becomes interesting. You’re no longer “overriding” a service. You’re designing a pipeline around it.

Each layer has a single responsibility. Each layer can evolve independently. And the core service remains boring.

In production, boring is exactly what you want.

The hidden costs

Decoration is powerful. But like any powerful mechanism in Symfony’s container, it can become a problem if you use it everywhere.

The first trap is obvious: layer explosion.

It starts clean. A logging decorator. Then a retry decorator. Then metrics. Then a feature flag. Then a fallback strategy. A year later, your service call goes through six layers before reaching the core logic.

Technically, it works. Architecturally, it might even be “pure”. But cognitively, it becomes heavy.

When debugging a production issue, you now need to mentally reconstruct the entire chain. The stack trace gets deeper. The execution order depends on priorities. If someone changes a priority value without fully understanding the pipeline, behavior can subtly change.

And finally, if everything becomes a decorator, nothing is.

Decoration is not a default design choice. It’s a strategic one. Use it where responsibilities truly need isolation and where production concerns justify the extra layer.

Conclusion

Service decoration is not really about Symfony. Symfony simply makes it easy. What decoration really gives you is disciplined composition.

When a production requirement appears like logging, metrics, retry, feature flag, fallback, the reflex in many teams is to open the class and “just add a few lines”. It feels pragmatic. It feels fast. And sometimes it is.

Until the class becomes a dumping ground for infrastructure concerns.

Decoration forces a different question: “should this responsibility live inside the service, or should it wrap it?”

That question alone changes how you design systems. You start thinking in layers.

The core service expresses business intent. Around it, you compose infrastructure layers that can evolve independently. You can enable or disable them. You can reorder them. You can replace them. You can even remove them entirely without touching the core logic.

That separation is what keeps long-lived Symfony applications maintainable.

Not because the pattern is clever.
Not because the container is powerful.

But because the boundaries remain clear.

Once you adopt that mindset, decoration stops being a trick to override services. It becomes a tool to protect your domain from technical noise.

And in real-world projects, the ones that live for years, survive refactors, and accumulate production scars, that protection makes a difference.

Thank you for taking the time to read this article, I hope you enjoyed it and learned something! If so, please feel free to put a clap 👏 on it and any feedback is welcome!

--

--

Nicolas Jourdan
Nicolas Jourdan

Written by Nicolas Jourdan

Symfony Certified 🎓 Lead Tech at Smart Traffik 🚀