A Cleaner Way to Manage Service Collections in Laravel: The Power of Tagging

Photo by Rinson Chory on Unsplash
When building a feature that supports multiple, similar implementations, it's common to register each one with a central service manually. This approach works, but a more elegant solution exists: Service Container Tagging. This powerful feature of Laravel's IoC container allows you to group related services under a common "tag," making it easy to resolve all services associated with that tag as a collection.
Today, we'll explore two methods for managing a collection of services: the direct, manual way and the more flexible, declarative method using tagging. We'll use a practical example: a data exporting system that can output data as a CSV, PDF, or JSON file.
The Manual Registration Approach
First, let's define a simple interface that all our exporters will use.
// app/Contracts/ExporterInterface.phpinterface ExporterInterface{ /** * @return string the path to the generated file */ public function export(array $data): string; /** * @example 'csv', 'pdf' */ public function format(): string;}
In a manual approach, we might create an ExportService
.
// app/Services/ExportService.phpclass ExportService{ protected array $exporters = []; public function registerExporter(ExporterInterface $exporter): void { $this->exporters[] = $exporter; } public function exportData(array $data, string $format): string { foreach ($this->exporters as $exporter) { if ($exporter->format() === $format) { return $exporter->export($data); } } throw new \RuntimeException("Format not supported."); }}
To wire it up, we go to a service provider and manually register each implementation:
// app/Providers/AppServiceProvider.phppublic function boot(): void{ $exportService = $this->app->make(ExportService::class); $exportService->registerExporter(new CsvExporter()); $exportService->registerExporter(new PdfExporter());}
This approach has several issues rooted in software design principles:
Single Responsibility Principle (SRP) Violation: The
ExportService
has two distinct responsibilities. It acts as a registry for exporters and as a processor for export jobs. Not the end of the world, but still, a class should have only one reason to change.Improper Coupling: The
AppServiceProvider
is now responsible for instantiating concrete exporters (new CsvExporter()
). This tightly couples the provider to the implementations.Imperative vs. Declarative: This method is imperative. You are giving a direct command (
registerExporter
) inside the provider. A more robust design is often declarative, where you simply state a relationship between components and let the framework handle the assembly.
A Better Way: Service Container Tagging
Tagging allows us to neatly separate these responsibilities. The service container will handle the "registry" part, leaving our service to do its one true job.
First, in a service provider, we simply "tag" our exporter classes with a unique identifier:
// app/Providers/AppServiceProvider.phppublic function register(): void{ $this->app->tag([CsvExporter::class, PdfExporter::class], 'app.exporters'); }
Next, we simplify the ExportService
. It no longer needs to be a registry. Its only responsibility is to export data.
// app/Services/ExportService.phpuse Illuminate\Contracts\Container\Attribute\Tagged; class ExportService{ public function __construct( #[Tagged('app.exporters')] private readonly iterable $exporters ) {} // Omitted for brevity}
This is already much cleaner, but there's a subtle flaw: we've lost our type hint guarantee.
The registerExporter(ExporterInterface $exporter)
method in our manual approach had one benefit: PHP would enforce that only objects implementing ExporterInterface
could be passed to it. By injecting a generic iterable
, we've lost that guarantee.
What if a developer accidentally tags a class that doesn't implement our interface? The code will crash with a fatal error inside the exportData
method.
We can fix this with a runtime check in the constructor. A standard PHP way to do this is to loop and check each instance manually. It would work perfectly, but it adds a bit of boilerplate to our constructor.
For a more elegant solution, I prefer to use a dedicated assertion library: webmozart/assert. While it's a third-party package you'd need to install, it makes the check a single, expressive line.
// app/Services/ExportService.phpuse Illuminate\Contracts\Container\Attribute\Tagged;use Webmozart\Assert\Assert; class ExportService{ public function __construct( #[Tagged('app.exporters')] private iterable $exporters ) { Assert::allIsInstanceOf($exporters, ExporterInterface::class); } // Omitted for brevity}
Both methods achieve the same goal: ensuring that if a developer makes a mistake in the service provider, the application will fail immediately and predictably. The assertion library simply offers a cleaner syntax.
The Takeaway
Let's put it side-by-side:
Aspect | Manual Registration | Service Tagging |
Responsibilities | Violates SRP: Service is both a registry and a processor. | Obeys SRP: Service is only a processor. Container is the registry. |
Coupling | Service provider is tightly coupled to concrete implementations. | Service provider is loosely coupled, only aware of a tag name. |
Type Safety | Enforced at the method signature ( | Enforced at runtime with an |
Tagging in Laravel's service container lets you manage multiple implementations of an interface with less boilerplate and better design. It's a flexible, scalable pattern that makes your code easier to extend and maintain.
