Local Disk or Object Storage

Write files through a filesystem abstraction so the same code runs against local disk in development and S3 in production.

Syntax$filesystem->writeStream(string $path, resource $contents, array $config = []): void

Calling file_put_contents() ties your code to one machine's disk. Flysystem puts an interface in front of storage instead. An adapter knows how to talk to one backend: a local directory, an S3 bucket, an in-memory array. A filesystem operator is the object your code calls, and it holds one adapter. The type to inject is League\Flysystem\FilesystemOperator; the methods are write(), writeStream(), read(), readStream(), fileExists(), delete(), mimeType() and fileSize().

Configuring storages

composer require league/flysystem-bundle adds a flysystem configuration key. Each entry under storages becomes a service, named by you. Inject a specific one with #[Target('documents.storage')], because the argument name does not choose the storage. Swap local for aws in config/packages/prod/ and the calling code does not change: that substitution is the whole point of the abstraction.

Why the container's disk is not storage

A container filesystem is a scratch space. It is discarded when the container restarts, and with several replicas behind a load balancer each one has its own, so an upload that lands on replica A is a 404 on replica B. Anything a user must get back later belongs in object storage or a shared volume. The local adapter is for development, tests and genuinely temporary work.

Example

Example · php
<?php
// src/Storage/DocumentStorage.php

declare(strict_types=1);

namespace App\Storage;

use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\File\UploadedFile;

final class DocumentStorage
{
    public function __construct(
        // The interface is the type; Target names which storage.
        #[Target('documents.storage')]
        private readonly FilesystemOperator $filesystem,
    ) {
    }

    public function store(UploadedFile $file, string $path): void
    {
        // writeStream takes an open resource, so the bytes are never
        // all held in memory at once.
        $handle = fopen($file->getPathname(), 'rb');

        $this->filesystem->writeStream($path, $handle, [
            // private keeps the object unreadable without the app.
            'visibility' => 'private',
        ]);
    }

    public function exists(string $path): bool
    {
        return $this->filesystem->fileExists($path);
    }

    public function mediaType(string $path): string
    {
        return $this->filesystem->mimeType($path);
    }

    public function remove(string $path): void
    {
        // delete() is idempotent: a missing path is not an error.
        // UnableToDeleteFile is thrown only when the removal itself fails.
        $this->filesystem->delete($path);
    }
}

When to use it

  • Keep uploaded invoices in S3 in production while developers work against var/storage.
  • Delete a stored file when its database row is deleted, without knowing which backend holds it.
  • Run the upload test suite against an in-memory filesystem so nothing is written to disk.
  • Move an existing directory of files into a bucket before moving the app onto Kubernetes.

More examples

Local in development, S3 in production

The storage keeps its name, documents.storage, in both environments, so no injected code changes. The S3 adapter needs composer require league/flysystem-aws-s3-v3 and an S3Client service. Files under config/packages/prod/ are merged over the defaults only in the prod environment, which is how one key gets two backends.

Example · yaml
# config/packages/flysystem.yaml
flysystem:
    storages:
        documents.storage:
            local:
                directory: '%kernel.project_dir%/var/storage/documents'

# config/packages/prod/flysystem.yaml
flysystem:
    storages:
        documents.storage:
            # visibility is a storage option, a sibling of the adapter key,
            # not an option of the aws adapter itself.
            visibility: private
            aws:
                # Service id of an Aws\S3\S3Client instance.
                client: 'Aws\S3\S3Client'
                bucket: '%env(S3_BUCKET)%'
                prefix: 'documents'

# config/services.yaml
services:
    Aws\S3\S3Client:
        arguments:
            -   version: '2006-03-01'
                region: '%env(AWS_REGION)%'
                credentials:
                    key: '%env(AWS_ACCESS_KEY_ID)%'
                    secret: '%env(AWS_SECRET_ACCESS_KEY)%'

An in-memory filesystem for tests

The memory adapter holds everything in a PHP array for the life of the process, so a test can upload a file, assert fileExists() and finish without touching disk or leaving anything to clean up. Because the injected type is still FilesystemOperator, the service under test is unchanged.

Example · yaml
# config/packages/test/flysystem.yaml
flysystem:
    storages:
        documents.storage:
            memory: ~

# requires: composer require --dev league/flysystem-memory

Copying between two storages

Two storages, two Target attributes, one loop. This is the shape of a one-off move from a server directory into a bucket, usually driven by a console command that reads the paths from the database. Each copy is a stream, so a 2 GB file costs no more memory than a 2 KB one.

Example · php
<?php
// src/Storage/StorageMigrator.php

declare(strict_types=1);

namespace App\Storage;

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

final class StorageMigrator
{
    public function __construct(
        #[Target('legacy.storage')]
        private readonly FilesystemOperator $source,
        #[Target('documents.storage')]
        private readonly FilesystemOperator $destination,
    ) {
    }

    /**
     * @param list<string> $paths
     *
     * @return int the number of files copied
     */
    public function migrate(array $paths): int
    {
        $copied = 0;

        foreach ($paths as $path) {
            if (!$this->source->fileExists($path)) {
                continue;
            }

            // Streaming keeps memory flat whatever the file size.
            $this->destination->writeStream(
                $path,
                $this->source->readStream($path),
                ['visibility' => 'private'],
            );

            ++$copied;
        }

        return $copied;
    }
}

Discussion

  • Be the first to comment on this lesson.