Object Storage and Managed Services

Keep uploads, the database and the cache outside the container so a pod can be destroyed without losing anything.

Syntax$storage->writeStream(string $location, $contents, array $config = []): void

A container's filesystem is temporary. When the platform replaces a pod, everything written inside it is gone. Uploads cannot live on the pod's disk.

Object storage is a service that stores files under string keys and serves them over HTTP. Amazon S3 is one, and several providers offer S3-compatible APIs. There are no real directories, only keys that happen to contain slashes.

One interface, many backends

Flysystem is a PHP library that puts a single interface in front of local disks, S3 and others. The league/flysystem-bundle package wires it into Symfony. Every entry under flysystem.storages becomes a service, and you ask for it typed as League\Flysystem\FilesystemOperator. Two ways to say which storage you want, and they do the same thing: the attribute #[Target('covers.storage')] on the constructor argument, or the longhand $storage: '@covers.storage' under that service in config/services.yaml. Because the caller only sees the interface, the local folder used in development and the bucket used in production are interchangeable.

Managed services hold the rest of the state

A managed service is one the provider runs, patches and backs up; you get a hostname and credentials. Point DATABASE_URL at a managed Postgres and REDIS_URL at a managed Redis, and the application keeps no state of its own. That is what makes a pod disposable.

What trips people up

A write that works locally can fail against a bucket. Most buckets now have ACLs disabled, so requesting public visibility makes the S3 API reject the call. Keep objects private and hand out short-lived URLs.

Example

Example · php
<?php
// src/Service/CoverStorage.php

declare(strict_types=1);

namespace App\Service;

use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Target;

final class CoverStorage
{
    public function __construct(
        // 'covers.storage' is the name given under flysystem.storages.
        #[Target('covers.storage')]
        private readonly FilesystemOperator $storage,
    ) {
    }

    /**
     * Streams a local file into object storage and returns the key it was stored under.
     */
    public function store(int $articleId, string $localPath, string $extension): string
    {
        $key = sprintf('articles/%d/cover.%s', $articleId, $extension);

        $handle = fopen($localPath, 'r');

        if (false === $handle) {
            throw new \RuntimeException(sprintf('Cannot read "%s".', $localPath));
        }

        // writeStream copies through the stream, so a large file never sits in memory.
        $this->storage->writeStream($key, $handle);

        // The adapter may already have closed the handle.
        if (is_resource($handle)) {
            fclose($handle);
        }

        return $key;
    }
}

// $coverStorage->store(7, '/tmp/php8a2f1c', 'jpg');
// Output: articles/7/cover.jpg

When to use it

  • Store user avatars and article covers so they survive a pod being replaced.
  • Serve a private invoice PDF through an expiring link instead of streaming it from PHP.
  • Move the cache off the container filesystem so two pods agree on what is cached.
  • Swap a local development folder for a production bucket without touching application code.

More examples

Pointing a storage at an S3 bucket

Install league/flysystem-async-aws-s3, then name the S3 client service under the adapter's client key. No access key appears here: the client resolves credentials from the environment, which is what lets a pod with an attached identity work without secrets. In development the same storage name is declared with the local adapter and a directory, and no PHP changes.

Example · yaml
# config/packages/flysystem.yaml
flysystem:
    storages:
        covers.storage:
            asyncaws:
                client: 'AsyncAws\S3\S3Client'
                bucket: '%env(S3_BUCKET)%'
                prefix: 'covers'

# config/services.yaml
services:
    AsyncAws\S3\S3Client:
        arguments:
            -
                region: '%env(AWS_REGION)%'

Managed Redis as the cache backend

Reach for this as soon as there is more than one pod. A filesystem cache lives inside each container, so two pods disagree and a restart empties it. One managed Redis instance is shared by every pod and survives restarts. REDIS_URL comes from the provider; the same instance can back the queue transport, with its own DSN in MESSENGER_TRANSPORT_DSN so cache keys and queued messages do not share a database number.

Example · yaml
# config/packages/cache.yaml
framework:
    cache:
        app: cache.adapter.redis
        default_redis_provider: '%env(REDIS_URL)%'

        pools:
            article.cache:
                adapter: cache.adapter.redis
                default_lifetime: 300

A time-limited URL for a private object

GET /articles/7/cover returns 302 with a Location header pointing at a signed bucket URL that stops working at the given time. The bucket stays private and the bytes never pass through PHP, so one pod can hand out thousands of downloads. The S3 adapter signs the URL itself, so the storage configured above needs nothing extra; a temporary_url_generator service only has to be set for adapters that cannot sign a URL, such as the local adapter used in development. In real code the expiry is relative to now rather than a fixed date.

Example · php
<?php
// src/Controller/CoverController.php

declare(strict_types=1);

namespace App\Controller;

use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Attribute\Route;

final class CoverController extends AbstractController
{
    public function __construct(
        #[Target('covers.storage')]
        private readonly FilesystemOperator $storage,
    ) {
    }

    #[Route('/articles/{id}/cover', name: 'app_article_cover', methods: ['GET'])]
    public function __invoke(int $id): RedirectResponse
    {
        $key = sprintf('articles/%d/cover.jpg', $id);

        // The URL is signed by the storage provider and expires on its own.
        $url = $this->storage->temporaryUrl($key, new \DateTimeImmutable('2026-01-01 12:05:00'));

        return new RedirectResponse($url, 302);
    }
}

Discussion

  • Be the first to comment on this lesson.