Swap Symfony Storage Backends Without Touching Your Controllers
Bind a storage interface to a concrete Symfony service in services.yaml so controllers never hard-code LocalStorage or S3.
23 Nov 2025, 21:57 UTC

The hard-coded storage problem
Your Symfony controller creates new LocalStorage() directly. It works on your laptop, but when production needs an S3-compatible bucket, you either edit the controller or duplicate upload logic. That coupling also makes unit tests awkward: you cannot easily swap in a fake storage object without changing the class under test.
The useful takeaway is that Symfony's service container can bind an interface to a concrete implementation in configuration. Your controller asks for the interface, and the container decides which class to instantiate. Changing storage backends becomes a YAML edit, not a controller change.
Define the interface, not the class
Start with a small interface in src/Storage/StorageInterface.php:
namespace App\Storage;
interface StorageInterface
{
public function put(string $path, string $content): void;
public function get(string $path): string;
}
Now create two implementations. LocalStorage writes to the local filesystem, and S3Storage calls an S3-compatible API. Both implement StorageInterface, so either one can satisfy a constructor that asks for the interface.
Wire the container in services.yaml
Register both classes and tell the container which implementation to inject when it sees the interface. In config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
App\Storage\LocalStorage: ~
App\Storage\S3Storage:
arguments:
$bucket: '%env(S3_BUCKET)%'
$endpoint: '%env(S3_ENDPOINT)%'
# The controller receives this interface.
# Change the alias below to switch backends.
App\Storage\StorageInterface: '@App\Storage\LocalStorage'
The %env() syntax reads environment variables, so bucket names and endpoints can differ between development, staging, and production without changing code. The alias line is the seam: it binds StorageInterface to LocalStorage today, and to S3Storage tomorrow.
Inject the interface into a controller
With autowiring enabled—the default in Symfony Flex applications—the container resolves the type-hint automatically:
namespace App\Controller;
use App\Storage\StorageInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class UploadController extends AbstractController
{
public function __construct(
private StorageInterface $storage,
) {}
#[Route('/upload')]
public function upload(): Response
{
$this->storage->put('example.txt', 'hello');
return new Response('stored');
}
}
The controller never names LocalStorage or S3Storage. It depends on the interface, and Symfony's dependency injection container supplies the configured implementation.
Verify which implementation is active
Run this from your project root, alongside bin/console:
php bin/console debug:container App\Storage\StorageInterface
- Where: project root of the Symfony application.
- Permissions: read access to the application; no write access is required for this read-only inspection.
- Expected check: the command should report the service ID aliased to the concrete class currently bound, such as
App\Storage\LocalStorage. If it reports no matching service, confirm the namespace matchesservices.yamland that the configuration file is imported. - Risk: none for the diagnostic itself. Running
cache:clearafterward to force a fresh container would write tovar/cache/.
Trade-off: explicit binding beats guesswork
If you register several classes that implement the same interface and rely only on autowiring, Symfony may refuse to guess which one to inject and throw an error about multiple matching services. Explicit binding in services.yaml removes that ambiguity, but you must remember to update the alias when you add a new implementation. Container configuration is also compiled, so production changes to YAML typically require a cache warm-up or clear.
Change one line, not the controller
To move to S3, update the alias:
App\Storage\StorageInterface: '@App\Storage\S3Storage'
The controller, routes, and tests stay untouched. Start with one seam: an interface used by a controller, bound in services.yaml, verified with debug:container. It keeps infrastructure details out of your application code and makes swapping backends a configuration decision rather than a refactor.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.