DEV Community

Cover image for Storage::put() in Your Use Case Is a Leak: Put Files Behind a Port in Laravel
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

Storage::put() in Your Use Case Is a Leak: Put Files Behind a Port in Laravel


Your invoice upload has been "working" for weeks. Then someone asks for the PDF of invoice 4812 and the bucket has nothing at that key. No exception in the logs, no failed job. The use case called Storage::disk('s3')->put(...), and per the Laravel file storage docs, when a write fails put returns false unless the disk has 'throw' => true in its config. Nobody checked the return value, because nobody thinks of a file write as something that answers with a boolean.

That is one cost of calling the Storage facade from inside a use case. Your business rule now depends on a key in config/filesystems.php, and whether a failed write is loud or silent is decided by a config flag the use case never sees.

The fix is five small pieces: a DocumentStore interface the domain owns, a Flysystem-backed adapter, an in-memory adapter for fast tests, a service provider binding, and a contract test both adapters pass. Everything targets PHP 8.3+ and Laravel 13, which requires PHP 8.3 as its minimum.

What the facade call costs you

Here is the shape that shows up in a lot of Laravel codebases:

<?php

namespace App\Billing;

use Illuminate\Support\Facades\Storage;

final class AttachInvoicePdf
{
    public function __invoke(string $invoiceId, string $pdf): string
    {
        $path = "invoices/{$invoiceId}.pdf";

        if (Storage::disk('s3')->exists($path)) {
            throw new \DomainException('Already attached');
        }

        Storage::disk('s3')->put($path, $pdf);

        return $path;
    }
}
Enter fullscreen mode Exit fullscreen mode

Three things leak out of those few lines.

The disk name. 's3' is an infrastructure decision written into a billing rule. Giving invoices their own disk, or running them on local in staging, means editing the use case, even though nothing about billing changed.

The failure mode. put reports failure as false by default. Laravel's FilesystemAdapter catches UnableToWriteFile, rethrows it only when the disk's throw option is set, and passes it to the exception handler only when the disk's report option is set; both default to false in the framework source. The code above ignores the return value, so the write can fail without anyone hearing about it.

The test. To exercise this rule you need a booted Laravel application and Storage::fake('s3'). That fake is not an in-memory map: the Storage facade source builds a local driver rooted at storage/framework/testing/disks/{disk} and cleans that directory on each call. Your billing rule test now touches the real filesystem and the framework container to check one if.

The port: four methods the domain actually needs

Start from what the billing code needs, not from what Flysystem offers. It needs to store bytes under a key, read them back, ask whether something is there, and remove it. That is the whole port.

<?php

namespace App\Billing\Domain;

interface DocumentStore
{
    /** @throws DocumentStoreFailed */
    public function put(DocumentKey $key, string $bytes): void;

    /** @throws DocumentNotFound|DocumentStoreFailed */
    public function get(DocumentKey $key): string;

    /** @throws DocumentStoreFailed */
    public function exists(DocumentKey $key): bool;

    /** Deleting a missing key is a no-op. */
    public function delete(DocumentKey $key): void;
}
Enter fullscreen mode Exit fullscreen mode

put returns void. Failure is an exception, always, regardless of which adapter sits behind it. That single decision removes the silent-false problem at the boundary.

The key is a value object, so path rules live in one place:

<?php

namespace App\Billing\Domain;

final readonly class DocumentKey
{
    private function __construct(public string $value) {}

    public static function of(string $value): self
    {
        if (
            $value === ''
            || str_starts_with($value, '/')
            || str_contains($value, '..')
        ) {
            throw new \InvalidArgumentException(
                "Invalid document key: {$value}"
            );
        }

        return new self($value);
    }

    public static function forInvoice(string $invoiceId): self
    {
        return self::of("invoices/{$invoiceId}.pdf");
    }
}
Enter fullscreen mode Exit fullscreen mode

An invoice id of ../../etc/passwd never reaches an adapter. The two exceptions are plain domain types:

<?php

namespace App\Billing\Domain;

final class DocumentNotFound extends \RuntimeException
{
    public static function at(DocumentKey $key): self
    {
        return new self("No document at {$key->value}");
    }
}

final class DocumentStoreFailed extends \RuntimeException {}
Enter fullscreen mode Exit fullscreen mode

In a real project, give each class its own file so Composer's PSR-4 autoloader finds it. They share a block here to save scrolling.

With the port in place, the use case loses every trace of Laravel:

<?php

namespace App\Billing\Application;

use App\Billing\Domain\DocumentKey;
use App\Billing\Domain\DocumentStore;

final readonly class AttachInvoicePdf
{
    private const MAX_BYTES = 5 * 1024 * 1024;

    public function __construct(private DocumentStore $documents) {}

    public function __invoke(
        string $invoiceId,
        string $pdf,
    ): DocumentKey {
        if (!str_starts_with($pdf, '%PDF-')) {
            throw new InvoiceRejected('Not a PDF');
        }
        if (strlen($pdf) > self::MAX_BYTES) {
            throw new InvoiceRejected('PDF over 5 MB');
        }

        $key = DocumentKey::forInvoice($invoiceId);

        if ($this->documents->exists($key)) {
            throw new InvoiceRejected(
                "Invoice {$invoiceId} already has a PDF"
            );
        }

        $this->documents->put($key, $pdf);

        return $key;
    }
}
Enter fullscreen mode Exit fullscreen mode

InvoiceRejected is a one-line final class InvoiceRejected extends \DomainException {}. The rules (PDF magic bytes, size cap, one PDF per invoice) read top to bottom with no disk names and no facade. The controller turns the upload into a string and calls the use case; that is where $request->file('pdf') belongs.

Two adapters: Flysystem for real, an array for tests

The Laravel docs open by saying the filesystem layer is built on Flysystem, and Laravel 13's composer.json requires league/flysystem ^3.25.1. So you already have it installed. The production adapter wraps Flysystem's FilesystemOperator and translates its exceptions. Every Flysystem exception implements the League\Flysystem\FilesystemException marker interface, per the exception handling docs, so one catch covers write, read, existence-check and delete failures.

<?php

namespace App\Billing\Infrastructure;

use App\Billing\Domain\DocumentKey;
use App\Billing\Domain\DocumentNotFound;
use App\Billing\Domain\DocumentStore;
use App\Billing\Domain\DocumentStoreFailed;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;

final readonly class FlysystemDocumentStore implements DocumentStore
{
    public function __construct(
        private FilesystemOperator $fs,
    ) {}

    public function put(DocumentKey $key, string $bytes): void
    {
        try {
            $this->fs->write($key->value, $bytes);
        } catch (FilesystemException $e) {
            throw $this->failed('write', $key, $e);
        }
    }

    public function get(DocumentKey $key): string
    {
        if (!$this->exists($key)) {
            throw DocumentNotFound::at($key);
        }
        try {
            return $this->fs->read($key->value);
        } catch (FilesystemException $e) {
            throw $this->failed('read', $key, $e);
        }
    }

    public function exists(DocumentKey $key): bool
    {
        try {
            return $this->fs->fileExists($key->value);
        } catch (FilesystemException $e) {
            throw $this->failed('check', $key, $e);
        }
    }

    public function delete(DocumentKey $key): void
    {
        if (!$this->exists($key)) {
            return;
        }
        try {
            $this->fs->delete($key->value);
        } catch (FilesystemException $e) {
            throw $this->failed('delete', $key, $e);
        }
    }

    private function failed(
        string $op,
        DocumentKey $key,
        FilesystemException $e,
    ): DocumentStoreFailed {
        return new DocumentStoreFailed(
            "Document {$op} failed: {$key->value}",
            previous: $e,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The exists guard in get and delete costs an extra request per call on a remote backend like S3. It buys a uniform contract: "not found" is a domain exception and deleting twice is safe, whatever the backend does by default. If the round trip matters at your volume, drop the guard and let the contract test tell you whether your backend already behaves that way.

The test adapter is an array:

<?php

namespace App\Billing\Infrastructure;

use App\Billing\Domain\DocumentKey;
use App\Billing\Domain\DocumentNotFound;
use App\Billing\Domain\DocumentStore;

final class InMemoryDocumentStore implements DocumentStore
{
    /** @var array<string, string> */
    private array $files = [];

    public function put(DocumentKey $key, string $bytes): void
    {
        $this->files[$key->value] = $bytes;
    }

    public function get(DocumentKey $key): string
    {
        return $this->files[$key->value]
            ?? throw DocumentNotFound::at($key);
    }

    public function exists(DocumentKey $key): bool
    {
        return isset($this->files[$key->value]);
    }

    public function delete(DocumentKey $key): void
    {
        unset($this->files[$key->value]);
    }
}
Enter fullscreen mode Exit fullscreen mode

You could instead pass Flysystem's own InMemoryFilesystemAdapter (package league/flysystem-memory) into FlysystemDocumentStore. That is a fine choice too. The hand-rolled array has no dependency and lets the use-case tests run without any Flysystem code at all.

Now the use-case test needs neither the container nor a fake disk:

<?php

namespace Tests\Unit\Billing;

use App\Billing\Application\AttachInvoicePdf;
use App\Billing\Application\InvoiceRejected;
use App\Billing\Infrastructure\InMemoryDocumentStore;
use PHPUnit\Framework\TestCase;

final class AttachInvoicePdfTest extends TestCase
{
    public function test_stores_pdf_under_invoice_key(): void
    {
        $docs = new InMemoryDocumentStore();
        $attach = new AttachInvoicePdf($docs);

        $key = $attach('inv-42', '%PDF-1.7 body');

        self::assertSame('invoices/inv-42.pdf', $key->value);
        self::assertSame('%PDF-1.7 body', $docs->get($key));
    }

    public function test_rejects_a_second_pdf(): void
    {
        $attach = new AttachInvoicePdf(
            new InMemoryDocumentStore(),
        );
        $attach('inv-42', '%PDF-1.7 first');

        $this->expectException(InvoiceRejected::class);
        $attach('inv-42', '%PDF-1.7 second');
    }
}
Enter fullscreen mode Exit fullscreen mode

It extends PHPUnit's TestCase, not Laravel's. No storage/ directory is touched.

Wiring it in a service provider

The disk choice moves to config, where it belongs. Create config/documents.php:

<?php

return [
    'disk' => env('DOCUMENTS_DISK', 'local'),
];
Enter fullscreen mode Exit fullscreen mode

Then bind the port. Storage::disk() returns Laravel's FilesystemAdapter, and its getDriver() method returns the underlying League\Flysystem\FilesystemOperator (see the framework source). That raw operator throws on failure, whatever the disk's throw setting says, which is what the adapter's catch blocks expect.

<?php

namespace App\Providers;

use App\Billing\Domain\DocumentStore;
use App\Billing\Infrastructure\FlysystemDocumentStore;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\ServiceProvider;

final class DocumentStoreServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(DocumentStore::class, function () {
            /** @var FilesystemAdapter $disk */
            $disk = Storage::disk(config('documents.disk'));

            return new FlysystemDocumentStore(
                $disk->getDriver(),
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Add the provider to bootstrap/providers.php. Going to S3 is now an environment change: install league/flysystem-aws-s3-v3 as the S3 driver section describes, fill in the AWS_* variables, and set DOCUMENTS_DISK=s3. The same s3 disk config also talks to S3-compatible services such as Cloudflare R2 or DigitalOcean Spaces through its endpoint option, per the same page. AttachInvoicePdf does not change.

The binding uses bind, not singleton, so a feature test that calls Storage::fake('local') before resolving the use case still gets the fake disk. Your HTTP-level tests keep working the way they did.

The contract test both adapters must pass

The in-memory adapter is only a safe stand-in if it behaves like the real one. Write the expectations once, in an abstract test, and run them against each adapter:

<?php

namespace Tests\Contract;

use App\Billing\Domain\DocumentKey;
use App\Billing\Domain\DocumentNotFound;
use App\Billing\Domain\DocumentStore;
use PHPUnit\Framework\TestCase;

abstract class DocumentStoreContract extends TestCase
{
    abstract protected function store(): DocumentStore;

    public function test_put_then_get_round_trips_bytes(): void
    {
        $store = $this->store();
        $key = DocumentKey::of('invoices/a.pdf');
        $bytes = "%PDF-1.7\x00\xff\n";

        $store->put($key, $bytes);

        self::assertTrue($store->exists($key));
        self::assertSame($bytes, $store->get($key));
    }

    public function test_put_overwrites(): void
    {
        $store = $this->store();
        $key = DocumentKey::of('invoices/b.pdf');

        $store->put($key, 'old');
        $store->put($key, 'new');

        self::assertSame('new', $store->get($key));
    }

    public function test_missing_key_is_not_found(): void
    {
        $store = $this->store();
        $key = DocumentKey::of('invoices/none.pdf');

        self::assertFalse($store->exists($key));
        $this->expectException(DocumentNotFound::class);
        $store->get($key);
    }

    public function test_delete_is_idempotent(): void
    {
        $store = $this->store();
        $key = DocumentKey::of('invoices/c.pdf');

        $store->put($key, 'x');
        $store->delete($key);
        $store->delete($key);

        self::assertFalse($store->exists($key));
    }
}
Enter fullscreen mode Exit fullscreen mode

The file is named DocumentStoreContract.php, so PHPUnit's default *Test.php discovery skips the abstract class and only runs the concrete ones. A stock Laravel phpunit.xml only scans tests/Unit and tests/Feature, so add a <testsuite name="Contract"> with <directory>tests/Contract</directory> to it, or these tests never run. The in-memory subclass is short:

<?php

namespace Tests\Contract;

use App\Billing\Domain\DocumentStore;
use App\Billing\Infrastructure\InMemoryDocumentStore;

final class InMemoryDocumentStoreTest extends DocumentStoreContract
{
    protected function store(): DocumentStore
    {
        return new InMemoryDocumentStore();
    }
}
Enter fullscreen mode Exit fullscreen mode

The Flysystem one runs against a real directory using the local adapter that ships in the core league/flysystem package:

<?php

namespace Tests\Contract;

use App\Billing\Domain\DocumentStore;
use App\Billing\Infrastructure\FlysystemDocumentStore;
use Illuminate\Filesystem\Filesystem as Files;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;

final class FlysystemDocumentStoreTest extends DocumentStoreContract
{
    private string $root;

    protected function setUp(): void
    {
        $this->root = sys_get_temp_dir()
            . '/docs-' . bin2hex(random_bytes(4));
    }

    protected function tearDown(): void
    {
        (new Files())->deleteDirectory($this->root);
    }

    protected function store(): DocumentStore
    {
        return new FlysystemDocumentStore(new Filesystem(
            new LocalFilesystemAdapter($this->root),
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

When you move to S3, add a third subclass whose store() builds a Filesystem over the S3 adapter pointed at a throwaway bucket, and run it in CI against a local S3-compatible server. If S3 reports "not found" or overwrites differently from your local disk, this is where you find out, before production does.

What this port leaves out, on purpose

The port takes the whole file as a string. For a 5 MB invoice cap that is fine. For video or large exports it is not: add a putStream(DocumentKey $key, $resource) method backed by Flysystem's writeStream, which the Filesystem API docs document alongside write, and add a contract case for it.

It also has no URLs. Temporary download links are a delivery concern, so they belong in a separate port (say, DocumentLinks) implemented with Storage::temporaryUrl(), not in the store the use case writes to. Keep the port as small as the rules that call it. When a new rule needs a new capability, add the method, add the contract case, and both adapters have to earn it.


If this was useful

The DocumentStore port is one small instance of the idea Decoupled PHP is built around: the domain names what it needs, Laravel or Symfony gets wired in at the edge, and swapping a vendor turns into a new adapter plus a passing contract test. The book applies the same move across a whole application, keeping the framework at the edge, with runnable examples.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)