Skip to content

[12.x] Add StorageUri value object for portable file references#58813

Draft
angus-mcritchie wants to merge 2 commits intolaravel:12.xfrom
angus-mcritchie:storage-uri
Draft

[12.x] Add StorageUri value object for portable file references#58813
angus-mcritchie wants to merge 2 commits intolaravel:12.xfrom
angus-mcritchie:storage-uri

Conversation

@angus-mcritchie
Copy link
Contributor

This PR introduces a StorageUri value object that provides a portable, serializable way to reference files across different storage disks using a URI format: storage://disk/path/to/file.jpg as discussed here.

There are some open questions at the bottom, but I am really looking to see if this is something @taylorotwell would be interested in merging before I go further with tests, API feedback and docs 😄

I used Opus 4.5 to generate most of this full disclaimer.

Motivation

Currently, storing file references in the database requires either:

  1. Storing just the path (losing disk context, assuming default disk)
  2. Storing disk and path in separate columns
  3. Using a custom solution per-project

This becomes problematic when:

  • Moving files between disks (local → S3)
  • Working with multi-tenant applications with per-tenant storage
  • Serializing file references for queues or APIs
  • Needing to change the default disk

The StorageUri value object solves this by encapsulating both disk and path in a single, portable value.

Usage Examples

Basic Construction

use Illuminate\Support\StorageUri;

// Create with explicit disk
$uri = new StorageUri('s3', 'avatars/photo.jpg');
$uri = StorageUri::onDisk('s3', 'avatars/photo.jpg');

// Create for default disk
$uri = StorageUri::make('documents/report.pdf');

// Parse from URI string
$uri = StorageUri::parse('storage://s3/avatars/photo.jpg');
$uri = StorageUri::of('storage://s3/avatars/photo.jpg');

Eloquent Integration

use Illuminate\Support\StorageUri;

class User extends Model
{
    protected $casts = [
        'avatar' => StorageUri::class,
    ];
}

// Usage
$user->avatar = StorageUri::onDisk('s3', 'avatars/photo.jpg');
$user->save();

// Database stores: "storage://s3/avatars/photo.jpg"

// Retrieve
$user->avatar->disk();  // "s3"
$user->avatar->path();  // "avatars/photo.jpg"
$user->avatar->url();   // Full public URL

File Uploads

// New methods on UploadedFile
$uri = $request->file('avatar')->storeUri('avatars', 's3');
$uri = $request->file('avatar')->storeUriAs('avatars', 'custom-name.jpg', 's3');

// Returns StorageUri instance ready for database storage
$user->avatar = $uri;
$user->save();

Storage Facade Integration

// Get URI from storage operations
$uri = Storage::disk('s3')->uri('path/to/file.jpg');

// StorageUri provides convenient accessors
$uri->path();       // "path/to/file.jpg"
$uri->disk();       // "s3"
$uri->extension();  // "jpg"
$uri->dirname();    // "path/to"
$uri->basename();   // "file.jpg"
$uri->filename();   // "file"

Storage Operations (via StorageUri)

$uri = StorageUri::parse($user->avatar);

// Proxy methods to Storage facade
$uri->exists();              // Check if file exists
$uri->get();                 // Get file contents
$uri->url();                 // Get public URL
$uri->temporaryUrl($expiry); // Get temporary URL
$uri->download();            // Stream download response
$uri->delete();              // Delete the file

Email Attachments

use Illuminate\Mail\Attachment;

// New factory method
$attachment = Attachment::fromStorageUri($user->avatar);

// In Mailable
public function attachments(): array
{
    return [
        Attachment::fromStorageUri($this->user->avatar),
    ];
}

Immutable Modifications

$original = StorageUri::onDisk('local', 'temp/file.jpg');

// Create copies with different disk or path
$onS3 = $original->withDisk('s3');
$renamed = $original->withPath('permanent/file.jpg');

// Original unchanged
$original->disk(); // "local"
$onS3->disk();     // "s3"

Serialization

$uri = StorageUri::onDisk('s3', 'file.jpg');

(string) $uri;        // "storage://s3/file.jpg"
$uri->toUri();        // "storage://s3/file.jpg"
$uri->toArray();      // ['disk' => 's3', 'path' => 'file.jpg']
$uri->toJson();       // '"storage:\/\/s3\/file.jpg"'
json_encode($uri);    // '"storage:\/\/s3\/file.jpg"'

API Summary

StorageUri

Method Description
new StorageUri(?string $disk, string $path) Constructor
StorageUri::make(string $path) Create for default disk
StorageUri::onDisk(string $disk, string $path) Create for specific disk
StorageUri::parse(string $uri) Parse URI string
StorageUri::of(string $uri) Alias for parse
disk(): ?string Get disk name
path(): string Get file path
extension(): string Get file extension
dirname(): string Get directory path
basename(): string Get filename with extension
filename(): string Get filename without extension
withDisk(?string $disk): self Copy with different disk
withPath(string $path): self Copy with different path
toUri(): string Get URI string
toArray(): array Get as array
toJson(): string Get as JSON
exists(): bool Check file exists
get(): string Get file contents
url(): string Get public URL
temporaryUrl(DateTimeInterface $expiry): string Get temporary URL
download(?string $name): StreamedResponse Stream download
delete(): bool Delete file

New Methods on Existing Classes

Class Method Description
FilesystemAdapter uri(string $path): StorageUri Create URI for path
UploadedFile storeUri($path, $disk): StorageUri Store and return URI
UploadedFile storeUriAs($path, $name, $disk): StorageUri Store with name, return URI
Attachment fromStorageUri(StorageUri $uri) Create attachment from URI

Zero Breaking Changes

This PR introduces only additive changes:

  • New StorageUri class
  • New AsStorageUri cast (alias)
  • New methods on existing classes (no signature changes)
  • No modifications to existing method behavior

My Assessment

Why I think this is worth doing

  1. Solves a real problem - I made a similar class in multiple projects to solve this problem in the past and always thought there should be a better way baked into Laravel.
  2. Clean API - Follows Laravel conventions (value object, Eloquent cast, facade integration)
  3. Zero breaking changes - Purely additive, safe for any 12.x or 13.x release
  4. Composable - Works with existing Laravel primitives (Storage, UploadedFile, Attachment)
  5. Familiar patterns - Uses existing patterns from the codebase (Uri class, value objects, castables)

Open Questions

  1. How should null disk URIs serialize?

    • Currently: storage:///path/to/file.jpg (triple slash)
    • Problem: PHP's parse_url() returns false for this format, so it can't round-trip through parse()
    • Alternative A: Use storage://default/path as a reserved keyword
    • Alternative B: Require explicit disk always (no null disk support)
    • Alternative C: Document the limitation and accept it
  2. Is StorageUri the right name?

    • StoragePath - emphasizes it's a path reference
    • FileReference - more generic
    • StorageLocation - descriptive but longer
  3. When should null disk resolve to the default?

    • Currently: at access time (when calling ->url(), ->exists(), etc.)
    • Alternative: at serialization time (always store the resolved disk name)

@github-actions
Copy link

Thanks for submitting a PR!

Note that draft PRs are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

return isset($value) ? StorageUri::parse($value) : null;
}

public function set($model, $key, $value, $attributes)
Copy link
Contributor

Choose a reason for hiding this comment

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

You could narrow the return type down the return type:

Suggested change
public function set($model, $key, $value, $attributes)
/**
* @return ($value is \Illuminate\Support\StorageUri ? string : ($value is null ? null : string))
*/
public function set($model, $key, $value, $attributes)

or better

Suggested change
public function set($model, $key, $value, $attributes)
/**
* @return string|null
* @phpstan-return ($value is \Illuminate\Support\StorageUri ? string : ($value is null ? null : string))
*/
public function set($model, $key, $value, $attributes)

Comment on lines +115 to +116
* @param array|string|null $name
* @param array|string $options
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe, you can narrow the array down a bit:

Suggested change
* @param array|string|null $name
* @param array|string $options
* @param array{disk: string, visibility: string}|string|null $name
* @param array{disk: string, visibility: string}|string $options

* Create a streamed response for the file.
*
* @param string|null $name
* @param array $headers
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be

Suggested change
* @param array $headers
* @param array<string, string $headers

or at least

Suggested change
* @param array $headers
* @param array<string, mixed> $headers

but maybe, this would be fine, too

Suggested change
* @param array $headers
* @param array<string, scalar> $headers

/**
* Get the instance as an array.
*
* @return array<string, string|null>
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 make it even more precise:

Suggested change
* @return array<string, string|null>
* @return array{disk: string|null, path: string}

/**
* Convert the object to its JSON representation.
*
* @param int $options
Copy link
Contributor

Choose a reason for hiding this comment

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

To narrow the range of expected integers, you can use the following:

Suggested change
* @param int $options
* @param int-mask<JSON_FORCE_OBJECT, JSON_HEX_QUOT, JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS, JSON_INVALID_UTF8_IGNORE, JSON_INVALID_UTF8_SUBSTITUTE, JSON_NUMERIC_CHECK, JSON_PARTIAL_OUTPUT_ON_ERROR, JSON_PRESERVE_ZERO_FRACTION, JSON_PRETTY_PRINT, JSON_UNESCAPED_LINE_TERMINATORS, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE, JSON_THROW_ON_ERROR> $options

}

return new static($disk, $path);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Some would probably expect that they can pass in their S3 URL. While I agree, this shouldn't be possible in the parse() method, we could offer a separate method:

    public static function fromUrl(string $url): static
    {
        foreach (config('filesystem.disks') as $disk => $config) {
            if (array_key_exists('url', $config) && str_starts_with($url, $config['url'])) {
                return new static($disk, substr($url, strlen($config['url'])));
            }
        }

        throw new InvalidArgumentException('Passed URL does not match any configured disks');
    }

@angus-mcritchie
Copy link
Contributor Author

Great suggestions with narrowing the types @shaedrich , thank you! I'd like some indication from the maintainers that this PR is on the right track at a higher level before making further tweaks 👍

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.

2 participants

Comments