Skip to content

Conversation

@michaelcontento
Copy link
Contributor

This PR introduces a fluent wrapper for storage operations similar to Str::of() - eliminating repetitive path parameters throughout your codebase.

Motivation

Currently, every filesystem operation requires repeating the path:

throw_unless(Storage::disk('s3')->exists('some/path.txt'));
Storage::disk('s3')->readStream('some/path.txt');
Storage::disk('s3')->size('some/path.txt');

With Storage::at(), the path is provided once:

$file = Storage::disk('s3')->at('some/path.txt');
throw_unless($file->exists());
$file->readStream();
$file->size();

Features

  • Fluent file operations - work with a single file/directory path
  • Nested navigation - chain at() calls: $storage->at('dir1')->at('dir2')
  • Method chaining - copyTo() and moveTo() return new path instances for further operations
  • Full filesystem compatibility - all Illuminate\Contracts\Filesystem\Filesystem methods supported
  • Traits integration - Conditionable, Dumpable, Macroable, Tappable
  • Type-safe - uses union types for Cloud/Filesystem contract support

Example

// Basic file operations
$file = Storage::disk('s3')->at('uploads/photo.jpg');

if ($file->exists()) {
    $content = $file->get();
    $size = $file->size();
    $mime = $file->mimeType();
}

// Chaining operations
$file->copyTo('backup.jpg')
     ->setVisibility('private');

// Nested navigation
Storage::disk('s3')
    ->at('uploads')
    ->at('photos')
    ->at('vacation')
    ->files();

// Conditional operations
Storage::at('file.txt')
    ->when($shouldProcess, fn($f) => $f->copyTo('processed/'))
    ->when($shouldBackup, fn($f) => $f->copyTo('backup/'));

Implementation

  • New StoragePath class in Illuminate\Filesystem
  • Added at() method to FilesystemAdapter, FilesystemManager, and Storage facade
  • Updated contracts to include at() method
  • copyTo()/moveTo() return new instances; copy()/move() return bool (for compatibility)

Note

  • This is a new fluent API feature, not a breaking change. Existing code continues to work unchanged. The StoragePath wrapper is opt-in via the at() method.

I appreciate feedback on the approach and am happy to adjust if needed (e.g., alternative naming, implementation details, or framework target version). I can provide comprehensive tests if this is considered for merge. :-)

@antonkomarev
Copy link
Contributor

antonkomarev commented Jan 4, 2026

Adding new methods to the interface is a breaking change.

@michaelcontento
Copy link
Contributor Author

You're absolutely right, and I appreciate you pointing that out! Adding a new method to the interface is indeed a breaking change for anyone implementing Filesystem directly.

I naively assumed that would affect very few people in practice, but you're correct that it's still a BC break and we shouldn't take that lightly.

Setting that concern aside, what do you think of the overall approach and the Storage::at() idea itself? Do you see it as a valuable addition to the framework? I'd love to hear your thoughts on the concept 😄

namespace Illuminate\Contracts\Filesystem;

use Illuminate\Filesystem\StoragePath;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* @method \Illuminate\Filesystem\StoragePath at(string $path)
*/

Use docblock instead adding breaking change method in a minor release.

@shaedrich
Copy link
Contributor

Your example is slightly contrived as

throw_unless(Storage::disk('s3')->exists('some/path.txt'));
Storage::disk('s3')->readStream('some/path.txt');
Storage::disk('s3')->size('some/path.txt');

can be written as

$disk = Storage::disk('s3');
throw_unless($disk->exists('some/path.txt'));
$disk->readStream('some/path.txt');
$disk->size('some/path.txt');

which will result in a more fair comparison
without having to repeat the disk() part

Comment on lines +23 to +31
protected string $path;

protected FilesystemContract|CloudFilesystemContract|FilesystemAdapter $filesystem;

public function __construct(string $path, FilesystemContract|CloudFilesystemContract|FilesystemAdapter $filesystem)
{
$this->path = $path;
$this->filesystem = $filesystem;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want, you can use constructor property promotion here:

Suggested change
protected string $path;
protected FilesystemContract|CloudFilesystemContract|FilesystemAdapter $filesystem;
public function __construct(string $path, FilesystemContract|CloudFilesystemContract|FilesystemAdapter $filesystem)
{
$this->path = $path;
$this->filesystem = $filesystem;
}
public function __construct(
protected string $path,
protected FilesystemContract|CloudFilesystemContract|FilesystemAdapter $filesystem
) {
}

/**
* Get the contents of a file as decoded JSON.
*/
public function json(int $flags = 0): ?array
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can narrow this further down if you want

Suggested change
public function json(int $flags = 0): ?array
/**
* @param int-mask<JSON_BIGINT_AS_STRING, JSON_INVALID_UTF8_IGNORE, JSON_INVALID_UTF8_SUBSTITUTE, JSON_OBJECT_AS_ARRAY, JSON_THROW_ON_ERROR>
*/
public function json(int $flags = 0): ?array

/**
* Get the file size.
*/
public function size(): int
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume, you can safely do this here:

Suggested change
public function size(): int
/**
* @return non-negative-int
*/
public function size(): int

Comment on lines +111 to +121
/**
* Get the checksum for the file.
*/
public function checksum(array $options = []): string|false
{
if ($this->filesystem instanceof FilesystemAdapter) {
return $this->filesystem->checksum($this->path, $options);
}

return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably overkill but if the class was made generic, we could do the following here to help static analysis:

Suggested change
/**
* Get the checksum for the file.
*/
public function checksum(array $options = []): string|false
{
if ($this->filesystem instanceof FilesystemAdapter) {
return $this->filesystem->checksum($this->path, $options);
}
return false;
}
/**
* Get the checksum for the file.
*
* @return (TFilesystem is FilesystemAdapter ? string|false : false)
*/
public function checksum(array $options = []): string|false
{
if ($this->filesystem instanceof FilesystemAdapter) {
return $this->filesystem->checksum($this->path, $options);
}
return false;
}

* Write the contents of a file.
*
* @param \Psr\Http\Message\StreamInterface|\Illuminate\Http\File|\Illuminate\Http\UploadedFile|string|resource $contents
* @param mixed $options
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could try to narrow this a bit:

Suggested change
* @param mixed $options
* @param \Illuminate\Contracts\Filesystem\Filesystem::VISIBILITY_PUBLIC|\Illuminate\Contracts\Filesystem\Filesystem::VISIBILITY_PRIVATE|(array{visibility: \Illuminate\Contracts\Filesystem\Filesystem::VISIBILITY_PUBLIC|\Illuminate\Contracts\Filesystem\Filesystem::VISIBILITY_PRIVATE}&array<string, mixed>) $options

or

Suggested change
* @param mixed $options
* @param \Illuminate\Contracts\Filesystem\Filesystem::VISIBILITY_*|(array{visibility: \Illuminate\Contracts\Filesystem\Filesystem::VISIBILITY_*}&array<string, mixed>) $options

or

Suggested change
* @param mixed $options
* @template TVisibility \Illuminate\Contracts\Filesystem\Filesystem::VISIBILITY_PUBLIC|\Illuminate\Contracts\Filesystem\Filesystem::VISIBILITY_PRIVATE
* @param TVisibility|(array{visibility: TVisibility}&array<string, mixed>) $options

Comment on lines +300 to +310
/**
* Get the URL for the file.
*/
public function url(): string
{
if ($this->filesystem instanceof CloudFilesystemContract) {
return $this->filesystem->url($this->path);
}

throw new RuntimeException('This driver does not support retrieving URLs.');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here with the generics

Suggested change
/**
* Get the URL for the file.
*/
public function url(): string
{
if ($this->filesystem instanceof CloudFilesystemContract) {
return $this->filesystem->url($this->path);
}
throw new RuntimeException('This driver does not support retrieving URLs.');
}
/**
* Get the URL for the file.
*
* @return (TFilesystem is \Illuminate\Contracts\Filesystem\Cloud ? string : never)
* @throws \RuntimeException
*/
public function url(): string
{
if ($this->filesystem instanceof CloudFilesystemContract) {
return $this->filesystem->url($this->path);
}
throw new RuntimeException('This driver does not support retrieving URLs.');
}

@taylorotwell
Copy link
Member

I think it's a kinda cool idea but not sure I want to introduce it / maintain it right now. Release it as a package! 🔥

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants