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. 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.Manual Safety Checks: If you accidentally register the same exporter twice, the naive implementation above will cause bugs. You have to add your own safety logic to prevent this.
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, JsonExporter::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.
public function __construct( #[Tagged('app.exporters')] private iterable $exporters) { foreach ($exporters as $exporter) { if (! $exporter instanceof ExporterInterface) { throw new \InvalidArgumentException('All tagged exporters must implement ExporterInterface.'); } } // Omitted for brevity}
This works 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 |
Duplicate Handling | Requires manual, custom-built logic to prevent bugs. | Handled automatically and safely by the service container. |
This declarative approach, combined with a simple runtime assertion, is a powerful pattern for building clean, reliable, and maintainable systems in Laravel.
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.
