# Search Results

## Uploads Testing file uploads
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

## Testing file uploads

You can use Laravel's existing file upload testing helpers to test file uploads.

Below is a complete example of testing the `UploadPhoto` component with Livewire:

```php
<?php

namespace Tests\Feature\Livewire;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use App\Livewire\UploadPhoto;
use Livewire\Livewire;
use Tests\TestCase;

class UploadPhotoTest extends TestCase
{
    public function test_can_upload_photo()
    {
        Storage::fake('avatars');

        $file = UploadedFile::fake()->image('avatar.png');

        Livewire::test(UploadPhoto::class)
            ->set('photo', $file)
            ->call('upload', 'uploaded-avatar.png');

        Storage::disk('avatars')->assertExists('uploaded-avatar.png');
    }
}
```

Below is an example of the `upload-photo` component required to make the previous test pass:

```php
<?php // resources/views/components/⚡upload-photo.blade.php

use Livewire\WithFileUploads;
use Livewire\Component;

new class extends Component {
    use WithFileUploads;

    public $photo;

    public function upload($name)
    {
        $this->photo->storeAs('/', $name, disk: 'avatars');
    }

    // ...
};
```

For more information on testing file uploads, please consult [Laravel's file upload testing documentation](https://laravel.com/docs/http-tests#testing-file-uploads).

---

## Components Input > File
Components Input | livewire/flux@2.x | Source: https://fluxui.dev/components/input#livewire/flux

File

Flux provides a special input component for file uploads. It's a simple wrapper around the native input[type="file"] element.

<flux:input type="file" wire:model="logo" label="Logo"/><flux:input type="file" wire:model="attachments" label="Attachments" multiple />

---

## Http Tests Testing File Uploads
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

## Testing File Uploads

The `Illuminate\Http\UploadedFile` class provides a `fake` method which may be used to generate dummy files or images for testing. This, combined with the `Storage` facade's `fake` method, greatly simplifies the testing of file uploads. For example, you may combine these two features to easily test an avatar upload form:

```php tab=Pest
<?php

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

test('avatars can be uploaded', function () {
    Storage::fake('avatars');

    $file = UploadedFile::fake()->image('avatar.jpg');

    $response = $this->post('/avatar', [
        'avatar' => $file,
    ]);

    Storage::disk('avatars')->assertExists($file->hashName());
});
```

```php tab=PHPUnit
<?php

namespace Tests\Feature;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_avatars_can_be_uploaded(): void
    {
        Storage::fake('avatars');

        $file = UploadedFile::fake()->image('avatar.jpg');

        $response = $this->post('/avatar', [
            'avatar' => $file,
        ]);

        Storage::disk('avatars')->assertExists($file->hashName());
    }
}
```

If you would like to assert that a given file does not exist, you may use the `assertMissing` method provided by the `Storage` facade:

```php
Storage::fake('avatars');

// ...

Storage::disk('avatars')->assertMissing('missing.jpg');
```

---

## Uploads File validation
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

## File validation

Like we've discussed, validating file uploads with Livewire is the same as handling file uploads from a standard Laravel controller.

> [!warning] Ensure S3 is properly configured
> Many of the validation rules relating to files require access to the file. When [uploading directly to S3](#uploading-directly-to-amazon-s3), these validation rules will fail if the S3 file object is not publicly accessible.

For more information on file validation, consult [Laravel's file validation documentation](https://laravel.com/docs/validation#available-validation-rules).

---

## Http Tests Validation Assertions assertInvalid
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

#### assertInvalid

Assert that the response has validation errors for the given keys. This method may be used for asserting against responses where the validation errors are returned as a JSON structure or where the validation errors have been flashed to the session:

```php
$response->assertInvalid(['name', 'email']);
```

You may also assert that a given key has a particular validation error message. When doing so, you may provide the entire message or only a small portion of the message:

```php
$response->assertInvalid([
    'name' => 'The name field is required.',
    'email' => 'valid email address',
]);
```

---

## Filesystem Testing
Filesystem | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/filesystem.md

## Testing

The `Storage` facade's `fake` method allows you to easily generate a fake disk that, combined with the file generation utilities of the `Illuminate\Http\UploadedFile` class, greatly simplifies the testing of file uploads. For example:

```php tab=Pest
<?php

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

test('albums can be uploaded', function () {
    Storage::fake('photos');

    $response = $this->json('POST', '/photos', [
        UploadedFile::fake()->image('photo1.jpg'),
        UploadedFile::fake()->image('photo2.jpg')
    ]);

    // Assert one or more files were stored...
    Storage::disk('photos')->assertExists('photo1.jpg');
    Storage::disk('photos')->assertExists(['photo1.jpg', 'photo2.jpg']);

    // Assert one or more files were not stored...
    Storage::disk('photos')->assertMissing('missing.jpg');
    Storage::disk('photos')->assertMissing(['missing.jpg', 'non-existing.jpg']);

    // Assert that the number of files in a given directory matches the expected count...
    Storage::disk('photos')->assertCount('/wallpapers', 2);

    // Assert that a given directory is empty...
    Storage::disk('photos')->assertDirectoryEmpty('/wallpapers');
});
```

```php tab=PHPUnit
<?php

namespace Tests\Feature;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_albums_can_be_uploaded(): void
    {
        Storage::fake('photos');

        $response = $this->json('POST', '/photos', [
            UploadedFile::fake()->image('photo1.jpg'),
            UploadedFile::fake()->image('photo2.jpg')
        ]);

        // Assert one or more files were stored...
        Storage::disk('photos')->assertExists('photo1.jpg');
        Storage::disk('photos')->assertExists(['photo1.jpg', 'photo2.jpg']);

        // Assert one or more files were not stored...
        Storage::disk('photos')->assertMissing('missing.jpg');
        Storage::disk('photos')->assertMissing(['missing.jpg', 'non-existing.jpg']);

        // Assert that the number of files in a given directory matches the expected count...
        Storage::disk('photos')->assertCount('/wallpapers', 2);

        // Assert that a given directory is empty...
        Storage::disk('photos')->assertDirectoryEmpty('/wallpapers');
    }
}
```

By default, the `fake` method will delete all files in its temporary directory. If you would like to keep these files, you may use the "persistentFake" method instead. For more information on testing file uploads, you may consult the [HTTP testing documentation's information on file uploads](/docs/{{version}}/http-tests#testing-file-uploads).

> [!WARNING]
> The `image` method requires the [GD extension](https://www.php.net/manual/en/book.image.php).

---

## Uploads Configuration
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

## Configuration

Because Livewire stores all file uploads temporarily before the developer can validate or store them, it assumes some default handling behavior for all file uploads.

### Global validation

By default, Livewire will validate all temporary file uploads with the following rules: `file|max:12288` (Must be a file less than 12MB).

If you wish to customize these rules, you can do so inside your application's `config/livewire.php` file:

```php
'temporary_file_upload' => [
    // ...
    'rules' => 'file|mimes:png,jpg,pdf|max:102400', // (100MB max, and only accept PNGs, JPEGs, and PDFs)
],
```

### Global middleware

The temporary file upload endpoint is assigned a throttling middleware by default. You can customize exactly what middleware this endpoint uses via the following configuration option:

```php
'temporary_file_upload' => [
    // ...
    'middleware' => 'throttle:5,1', // Only allow 5 uploads per user per minute
],
```

### Temporary upload directory

Temporary files are uploaded to the specified disk's `livewire-tmp/` directory. You can customize this directory via the following configuration option:

```php
'temporary_file_upload' => [
    // ...
    'directory' => 'tmp',
],
```

---

## Uploads JavaScript upload API
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

## JavaScript upload API

Integrating with third-party file-uploading libraries often requires more control than a simple `<input type="file" wire:model="...">` element.

For these scenarios, Livewire exposes dedicated JavaScript functions.

These functions exist on a JavaScript component object, which can be accessed using Livewire's convenient `$wire` object from within your Livewire component's template:

```blade
<script>
    let file = $wire.el.querySelector('input[type="file"]').files[0]

    // Upload a file...
    $wire.upload('photo', file, (uploadedFilename) => {
        // Success callback...
    }, () => {
        // Error callback...
    }, (event) => {
        // Progress callback...
        // event.detail.progress contains a number between 1 and 100 as the upload progresses
    }, () => {
        // Cancelled callback...
    })

    // Upload multiple files...
    $wire.uploadMultiple('photos', [file], successCallback, errorCallback, progressCallback, cancelledCallback)

    // Remove single file from multiple uploaded files...
    $wire.removeUpload('photos', uploadedFilename, successCallback)

    // Cancel an upload...
    $wire.cancelUpload('photos')
</script>
```

---

## Uploads Cancelling an upload
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

## Cancelling an upload

If an upload is taking a long time, a user may want to cancel it. You can provide this functionality with Livewire's `$cancelUpload()` function in JavaScript.

Here's an example of creating a "Cancel Upload" button in a Livewire component using `wire:click` to handle the click event:

```blade
<form wire:submit="save">
    <!-- File Input -->
    <input type="file" wire:model="photo">

    <!-- Cancel upload button -->
    <button type="button" wire:click="$cancelUpload('photo')">Cancel Upload</button>

    <!-- ... -->
</form>
```

When "Cancel upload" is pressed, the file upload will request will be aborted and the file input will be cleared. The user can now attempt another upload with a different file.

Alternatively, you can call `cancelUpload(...)` from Alpine like so:

```blade
<button type="button" x-on:click="$wire.cancelUpload('photo')">Cancel Upload</button>
```

---

## Validation See also
Validation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/validation

## See also

- **[Forms](/docs/4.x/forms)** — Validate form inputs with real-time feedback
- **[Properties](/docs/4.x/properties)** — Validate property values before persisting
- **[Validate Attribute](/docs/4.x/attribute-validate)** — Use #[Validate] for property validation
- **[Actions](/docs/4.x/actions)** — Validate data in action methods

---

## Validation Validating Files Validating Image Dimensions
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### Validating Image Dimensions

You may also validate the dimensions of an image. For example, to validate that an uploaded image is at least 1000 pixels wide and 500 pixels tall, you may use the `dimensions` rule:

```php
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;

File::image()->dimensions(
    Rule::dimensions()
        ->maxWidth(1000)
        ->maxHeight(500)
)
```

> [!NOTE]
> More information regarding validating image dimensions may be found in the [dimension rule documentation](#rule-dimensions).

---

## Requests Files Retrieving Uploaded Files Validating Successful Uploads
Requests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/requests.md

#### Validating Successful Uploads

In addition to checking if the file is present, you may verify that there were no problems uploading the file via the `isValid` method:

```php
if ($request->file('photo')->isValid()) {
    // ...
}
```

---

## Uploads
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

Livewire offers powerful support for uploading files within your components.

First, add the `WithFileUploads` trait to your component. Once this trait has been added to your component, you can use `wire:model` on file inputs as if they were any other input type and Livewire will take care of the rest.

Here's an example of a simple component that handles uploading a photo:

```php
<?php // resources/views/components/⚡upload-photo.blade.php

use Livewire\Attributes\Validate;
use Livewire\WithFileUploads;
use Livewire\Component;

new class extends Component {
    use WithFileUploads;

    #[Validate('image|max:1024')] // 1MB Max
    public $photo;

    public function save()
    {
        $this->photo->store(path: 'photos');
    }
};
```

```blade
<form wire:submit="save">
    <input type="file" wire:model="photo">

    @error('photo') <span class="error">{{ $message }}</span> @enderror

    <button type="submit">Save photo</button>
</form>
```

> [!warning] The "upload" method is reserved
> Notice the above example uses a "save" method instead of an "upload" method. This is a common "gotcha". The term "upload" is reserved by Livewire. You cannot use it as a method or property name in your component.

From the developer's perspective, handling file inputs is no different than handling any other input type: Add `wire:model` to the `<input>` tag and everything else is taken care of for you.

However, more is happening under the hood to make file uploads work in Livewire. Here's a glimpse at what goes on when a user selects a file to upload:

1. When a new file is selected, Livewire's JavaScript makes an initial request to the component on the server to get a temporary "signed" upload URL.
2. Once the URL is received, JavaScript does the actual "upload" to the signed URL, storing the upload in a temporary directory designated by Livewire and returning the new temporary file's unique hash ID.
3. Once the file is uploaded and the unique hash ID is generated, Livewire's JavaScript makes a final request to the component on the server, telling it to "set" the desired public property to the new temporary file.
4. Now, the public property (in this case, `$photo`) is set to the temporary file upload and is ready to be stored or validated at any point.

---

## Uploads Temporary preview URLs
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

## Temporary preview URLs

After a user chooses a file, you should typically show them a preview of that file before they submit the form and store the file.

Livewire makes this trivial by using the `->temporaryUrl()` method on uploaded files.

> [!info] Temporary URLs are restricted to images
> For security reasons, temporary preview URLs are only supported on files with image MIME types.

Let's explore an example of a file upload with an image preview:

```php
<?php // resources/views/components/⚡upload-photo.blade.php

use Livewire\Attributes\Validate;
use Livewire\WithFileUploads;
use Livewire\Component;

new class extends Component {
    use WithFileUploads;

    #[Validate('image|max:1024')]
    public $photo;

    // ...
};
```

```blade
<form wire:submit="save">
    @if ($photo) <!-- [tl! highlight:2] -->
        <img src="{{ $photo->temporaryUrl() }}">
    @endif

    <input type="file" wire:model="photo">

    @error('photo') <span class="error">{{ $message }}</span> @enderror

    <button type="submit">Save photo</button>
</form>
```

As previously discussed, Livewire stores temporary files in a non-public directory; therefore, typically there's no simple way to expose a temporary, public URL to your users for image previewing.

However, Livewire solves this issue by providing a temporary, signed URL that pretends to be the uploaded image so your page can show an image preview to your users.

This URL is protected against showing files in directories above the temporary directory. And, because it's signed, users can't abuse this URL to preview other files on your system.

> [!tip] S3 temporary signed URLs
> If you've configured Livewire to use S3 for temporary file storage, calling `->temporaryUrl()` will generate a temporary, signed URL to S3 directly so that image previews aren't loaded from your Laravel application server.

---

## Validation Deprecated `[#Rule]` attribute
Validation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/validation

## Deprecated `[#Rule]` attribute

When Livewire v3 first launched, it used the term "Rule" instead of "Validate" for it's validation attributes (`#[Rule]`).

Because of naming conflicts with Laravel rule objects, this has since been changed to `#[Validate]`. Both are supported in Livewire v3, however it is recommended that you change all occurrences of `#[Rule]` with `#[Validate]` to stay current.

---

## Uploads Loading indicators
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

## Loading indicators

Although `wire:model` for file uploads works differently than other `wire:model` input types under the hood, the interface for showing loading indicators remains the same.

You can display a loading indicator scoped to the file upload using `wire:loading`:

```blade
<input type="file" wire:model="photo">

<div wire:loading wire:target="photo">Uploading...</div>
```

Or more simply using Livewire's automatic `data-loading` attribute:

```blade
<div>
    <input type="file" wire:model="photo">

    <div class="not-data-loading:hidden">Uploading...</div>
</div>
```

Now, while the file is uploading, the "Uploading..." message will be shown and then hidden when the upload is finished.

[Learn more about loading states →](/docs/4.x/loading-states)

---

## Uploads See also
Uploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads

## See also

- **[Forms](/docs/4.x/forms)** — Handle file uploads in forms
- **[Validation](/docs/4.x/validation)** — Validate uploaded files
- **[Loading States](/docs/4.x/loading-states)** — Show upload progress indicators
- **[wire:model](/docs/4.x/wire-model)** — Bind file inputs to properties

_[Truncated]_


Output:
Wall time: 4.5370 seconds
Output:
[{"type":"text","text":"# Search Results\n\n## Uploads Testing file uploads\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\n## Testing file uploads\n\nYou can use Laravel's existing file upload testing helpers to test file uploads.\n\nBelow is a complete example of testing the `UploadPhoto` component with Livewire:\n\n```php\n<?php\n\nnamespace Tests\\Feature\\Livewire;\n\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Storage;\nuse App\\Livewire\\UploadPhoto;\nuse Livewire\\Livewire;\nuse Tests\\TestCase;\n\nclass UploadPhotoTest extends TestCase\n{\n    public function test_can_upload_photo()\n    {\n        Storage::fake('avatars');\n\n        $file = UploadedFile::fake()->image('avatar.png');\n\n        Livewire::test(UploadPhoto::class)\n            ->set('photo', $file)\n            ->call('upload', 'uploaded-avatar.png');\n\n        Storage::disk('avatars')->assertExists('uploaded-avatar.png');\n    }\n}\n```\n\nBelow is an example of the `upload-photo` component required to make the previous test pass:\n\n```php\n<?php // resources/views/components/⚡upload-photo.blade.php\n\nuse Livewire\\WithFileUploads;\nuse Livewire\\Component;\n\nnew class extends Component {\n    use WithFileUploads;\n\n    public $photo;\n\n    public function upload($name)\n    {\n        $this->photo->storeAs('/', $name, disk: 'avatars');\n    }\n\n    // ...\n};\n```\n\nFor more information on testing file uploads, please consult [Laravel's file upload testing documentation](https://laravel.com/docs/http-tests#testing-file-uploads).\n\n---\n\n## Components Input > File\nComponents Input | livewire/flux@2.x | Source: https://fluxui.dev/components/input#livewire/flux\n\n
File

Flux provides a special input component for file uploads. It's a simple wrapper around the native input[type=\"file\"] element.

<flux:input type=\"file\" wire:model=\"logo\" label=\"Logo\"/><flux:input type=\"file\" wire:model=\"attachments\" label=\"Attachments\" multiple />

\n\n---\n\n## Http Tests Testing File Uploads\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n## Testing File Uploads\n\nThe `Illuminate\\Http\\UploadedFile` class provides a `fake` method which may be used to generate dummy files or images for testing. This, combined with the `Storage` facade's `fake` method, greatly simplifies the testing of file uploads. For example, you may combine these two features to easily test an avatar upload form:\n\n```php tab=Pest\n<?php\n\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Storage;\n\ntest('avatars can be uploaded', function () {\n    Storage::fake('avatars');\n\n    $file = UploadedFile::fake()->image('avatar.jpg');\n\n    $response = $this->post('/avatar', [\n        'avatar' => $file,\n    ]);\n\n    Storage::disk('avatars')->assertExists($file->hashName());\n});\n```\n\n```php tab=PHPUnit\n<?php\n\nnamespace Tests\\Feature;\n\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Tests\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    public function test_avatars_can_be_uploaded(): void\n    {\n        Storage::fake('avatars');\n\n        $file = UploadedFile::fake()->image('avatar.jpg');\n\n        $response = $this->post('/avatar', [\n            'avatar' => $file,\n        ]);\n\n        Storage::disk('avatars')->assertExists($file->hashName());\n    }\n}\n```\n\nIf you would like to assert that a given file does not exist, you may use the `assertMissing` method provided by the `Storage` facade:\n\n```php\nStorage::fake('avatars');\n\n// ...\n\nStorage::disk('avatars')->assertMissing('missing.jpg');\n```\n\n---\n\n## Uploads File validation\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\n## File validation\n\nLike we've discussed, validating file uploads with Livewire is the same as handling file uploads from a standard Laravel controller.\n\n> [!warning] Ensure S3 is properly configured\n> Many of the validation rules relating to files require access to the file. When [uploading directly to S3](#uploading-directly-to-amazon-s3), these validation rules will fail if the S3 file object is not publicly accessible.\n\nFor more information on file validation, consult [Laravel's file validation documentation](https://laravel.com/docs/validation#available-validation-rules).\n\n---\n\n## Http Tests Validation Assertions assertInvalid\nHttp Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md\n\n\n#### assertInvalid\n\nAssert that the response has validation errors for the given keys. This method may be used for asserting against responses where the validation errors are returned as a JSON structure or where the validation errors have been flashed to the session:\n\n```php\n$response->assertInvalid(['name', 'email']);\n```\n\nYou may also assert that a given key has a particular validation error message. When doing so, you may provide the entire message or only a small portion of the message:\n\n```php\n$response->assertInvalid([\n    'name' => 'The name field is required.',\n    'email' => 'valid email address',\n]);\n```\n\n---\n\n## Filesystem Testing\nFilesystem | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/filesystem.md\n\n\n## Testing\n\nThe `Storage` facade's `fake` method allows you to easily generate a fake disk that, combined with the file generation utilities of the `Illuminate\\Http\\UploadedFile` class, greatly simplifies the testing of file uploads. For example:\n\n```php tab=Pest\n<?php\n\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Storage;\n\ntest('albums can be uploaded', function () {\n    Storage::fake('photos');\n\n    $response = $this->json('POST', '/photos', [\n        UploadedFile::fake()->image('photo1.jpg'),\n        UploadedFile::fake()->image('photo2.jpg')\n    ]);\n\n    // Assert one or more files were stored...\n    Storage::disk('photos')->assertExists('photo1.jpg');\n    Storage::disk('photos')->assertExists(['photo1.jpg', 'photo2.jpg']);\n\n    // Assert one or more files were not stored...\n    Storage::disk('photos')->assertMissing('missing.jpg');\n    Storage::disk('photos')->assertMissing(['missing.jpg', 'non-existing.jpg']);\n\n    // Assert that the number of files in a given directory matches the expected count...\n    Storage::disk('photos')->assertCount('/wallpapers', 2);\n\n    // Assert that a given directory is empty...\n    Storage::disk('photos')->assertDirectoryEmpty('/wallpapers');\n});\n```\n\n```php tab=PHPUnit\n<?php\n\nnamespace Tests\\Feature;\n\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Tests\\TestCase;\n\nclass ExampleTest extends TestCase\n{\n    public function test_albums_can_be_uploaded(): void\n    {\n        Storage::fake('photos');\n\n        $response = $this->json('POST', '/photos', [\n            UploadedFile::fake()->image('photo1.jpg'),\n            UploadedFile::fake()->image('photo2.jpg')\n        ]);\n\n        // Assert one or more files were stored...\n        Storage::disk('photos')->assertExists('photo1.jpg');\n        Storage::disk('photos')->assertExists(['photo1.jpg', 'photo2.jpg']);\n\n        // Assert one or more files were not stored...\n        Storage::disk('photos')->assertMissing('missing.jpg');\n        Storage::disk('photos')->assertMissing(['missing.jpg', 'non-existing.jpg']);\n\n        // Assert that the number of files in a given directory matches the expected count...\n        Storage::disk('photos')->assertCount('/wallpapers', 2);\n\n        // Assert that a given directory is empty...\n        Storage::disk('photos')->assertDirectoryEmpty('/wallpapers');\n    }\n}\n```\n\nBy default, the `fake` method will delete all files in its temporary directory. If you would like to keep these files, you may use the \"persistentFake\" method instead. For more information on testing file uploads, you may consult the [HTTP testing documentation's information on file uploads](/docs/{{version}}/http-tests#testing-file-uploads).\n\n> [!WARNING]\n> The `image` method requires the [GD extension](https://www.php.net/manual/en/book.image.php).\n\n---\n\n## Uploads Configuration\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\n## Configuration\n\nBecause Livewire stores all file uploads temporarily before the developer can validate or store them, it assumes some default handling behavior for all file uploads.\n\n### Global validation\n\nBy default, Livewire will validate all temporary file uploads with the following rules: `file|max:12288` (Must be a file less than 12MB).\n\nIf you wish to customize these rules, you can do so inside your application's `config/livewire.php` file:\n\n```php\n'temporary_file_upload' => [\n    // ...\n    'rules' => 'file|mimes:png,jpg,pdf|max:102400', // (100MB max, and only accept PNGs, JPEGs, and PDFs)\n],\n```\n\n### Global middleware\n\nThe temporary file upload endpoint is assigned a throttling middleware by default. You can customize exactly what middleware this endpoint uses via the following configuration option:\n\n```php\n'temporary_file_upload' => [\n    // ...\n    'middleware' => 'throttle:5,1', // Only allow 5 uploads per user per minute\n],\n```\n\n### Temporary upload directory\n\nTemporary files are uploaded to the specified disk's `livewire-tmp/` directory. You can customize this directory via the following configuration option:\n\n```php\n'temporary_file_upload' => [\n    // ...\n    'directory' => 'tmp',\n],\n```\n\n---\n\n## Uploads JavaScript upload API\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\n## JavaScript upload API\n\nIntegrating with third-party file-uploading libraries often requires more control than a simple `<input type=\"file\" wire:model=\"...\">` element.\n\nFor these scenarios, Livewire exposes dedicated JavaScript functions.\n\nThese functions exist on a JavaScript component object, which can be accessed using Livewire's convenient `$wire` object from within your Livewire component's template:\n\n```blade\n<script>\n    let file = $wire.el.querySelector('input[type=\"file\"]').files[0]\n\n    // Upload a file...\n    $wire.upload('photo', file, (uploadedFilename) => {\n        // Success callback...\n    }, () => {\n        // Error callback...\n    }, (event) => {\n        // Progress callback...\n        // event.detail.progress contains a number between 1 and 100 as the upload progresses\n    }, () => {\n        // Cancelled callback...\n    })\n\n    // Upload multiple files...\n    $wire.uploadMultiple('photos', [file], successCallback, errorCallback, progressCallback, cancelledCallback)\n\n    // Remove single file from multiple uploaded files...\n    $wire.removeUpload('photos', uploadedFilename, successCallback)\n\n    // Cancel an upload...\n    $wire.cancelUpload('photos')\n</script>\n```\n\n---\n\n## Uploads Cancelling an upload\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\n## Cancelling an upload\n\nIf an upload is taking a long time, a user may want to cancel it. You can provide this functionality with Livewire's `$cancelUpload()` function in JavaScript.\n\nHere's an example of creating a \"Cancel Upload\" button in a Livewire component using `wire:click` to handle the click event:\n\n```blade\n<form wire:submit=\"save\">\n    <!-- File Input -->\n    <input type=\"file\" wire:model=\"photo\">\n\n    <!-- Cancel upload button -->\n    <button type=\"button\" wire:click=\"$cancelUpload('photo')\">Cancel Upload</button>\n\n    <!-- ... -->\n</form>\n```\n\nWhen \"Cancel upload\" is pressed, the file upload will request will be aborted and the file input will be cleared. The user can now attempt another upload with a different file.\n\nAlternatively, you can call `cancelUpload(...)` from Alpine like so:\n\n```blade\n<button type=\"button\" x-on:click=\"$wire.cancelUpload('photo')\">Cancel Upload</button>\n```\n\n---\n\n## Validation See also\nValidation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/validation\n\n## See also\n\n- **[Forms](/docs/4.x/forms)** — Validate form inputs with real-time feedback\n- **[Properties](/docs/4.x/properties)** — Validate property values before persisting\n- **[Validate Attribute](/docs/4.x/attribute-validate)** — Use #[Validate] for property validation\n- **[Actions](/docs/4.x/actions)** — Validate data in action methods\n\n---\n\n## Validation Validating Files Validating Image Dimensions\nValidation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md\n\n\n#### Validating Image Dimensions\n\nYou may also validate the dimensions of an image. For example, to validate that an uploaded image is at least 1000 pixels wide and 500 pixels tall, you may use the `dimensions` rule:\n\n```php\nuse Illuminate\\Validation\\Rule;\nuse Illuminate\\Validation\\Rules\\File;\n\nFile::image()->dimensions(\n    Rule::dimensions()\n        ->maxWidth(1000)\n        ->maxHeight(500)\n)\n```\n\n> [!NOTE]\n> More information regarding validating image dimensions may be found in the [dimension rule documentation](#rule-dimensions).\n\n---\n\n## Requests Files Retrieving Uploaded Files Validating Successful Uploads\nRequests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/requests.md\n\n\n#### Validating Successful Uploads\n\nIn addition to checking if the file is present, you may verify that there were no problems uploading the file via the `isValid` method:\n\n```php\nif ($request->file('photo')->isValid()) {\n    // ...\n}\n```\n\n---\n\n## Uploads\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\nLivewire offers powerful support for uploading files within your components.\n\nFirst, add the `WithFileUploads` trait to your component. Once this trait has been added to your component, you can use `wire:model` on file inputs as if they were any other input type and Livewire will take care of the rest.\n\nHere's an example of a simple component that handles uploading a photo:\n\n```php\n<?php // resources/views/components/⚡upload-photo.blade.php\n\nuse Livewire\\Attributes\\Validate;\nuse Livewire\\WithFileUploads;\nuse Livewire\\Component;\n\nnew class extends Component {\n    use WithFileUploads;\n\n    #[Validate('image|max:1024')] // 1MB Max\n    public $photo;\n\n    public function save()\n    {\n        $this->photo->store(path: 'photos');\n    }\n};\n```\n\n```blade\n<form wire:submit=\"save\">\n    <input type=\"file\" wire:model=\"photo\">\n\n    @error('photo') <span class=\"error\">{{ $message }}</span> @enderror\n\n    <button type=\"submit\">Save photo</button>\n</form>\n```\n\n> [!warning] The \"upload\" method is reserved\n> Notice the above example uses a \"save\" method instead of an \"upload\" method. This is a common \"gotcha\". The term \"upload\" is reserved by Livewire. You cannot use it as a method or property name in your component.\n\nFrom the developer's perspective, handling file inputs is no different than handling any other input type: Add `wire:model` to the `<input>` tag and everything else is taken care of for you.\n\nHowever, more is happening under the hood to make file uploads work in Livewire. Here's a glimpse at what goes on when a user selects a file to upload:\n\n1. When a new file is selected, Livewire's JavaScript makes an initial request to the component on the server to get a temporary \"signed\" upload URL.\n2. Once the URL is received, JavaScript does the actual \"upload\" to the signed URL, storing the upload in a temporary directory designated by Livewire and returning the new temporary file's unique hash ID.\n3. Once the file is uploaded and the unique hash ID is generated, Livewire's JavaScript makes a final request to the component on the server, telling it to \"set\" the desired public property to the new temporary file.\n4. Now, the public property (in this case, `$photo`) is set to the temporary file upload and is ready to be stored or validated at any point.\n\n---\n\n## Uploads Temporary preview URLs\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\n## Temporary preview URLs\n\nAfter a user chooses a file, you should typically show them a preview of that file before they submit the form and store the file.\n\nLivewire makes this trivial by using the `->temporaryUrl()` method on uploaded files.\n\n> [!info] Temporary URLs are restricted to images\n> For security reasons, temporary preview URLs are only supported on files with image MIME types.\n\nLet's explore an example of a file upload with an image preview:\n\n```php\n<?php // resources/views/components/⚡upload-photo.blade.php\n\nuse Livewire\\Attributes\\Validate;\nuse Livewire\\WithFileUploads;\nuse Livewire\\Component;\n\nnew class extends Component {\n    use WithFileUploads;\n\n    #[Validate('image|max:1024')]\n    public $photo;\n\n    // ...\n};\n```\n\n```blade\n<form wire:submit=\"save\">\n    @if ($photo) <!-- [tl! highlight:2] -->\n        <img src=\"{{ $photo->temporaryUrl() }}\">\n    @endif\n\n    <input type=\"file\" wire:model=\"photo\">\n\n    @error('photo') <span class=\"error\">{{ $message }}</span> @enderror\n\n    <button type=\"submit\">Save photo</button>\n</form>\n```\n\nAs previously discussed, Livewire stores temporary files in a non-public directory; therefore, typically there's no simple way to expose a temporary, public URL to your users for image previewing.\n\nHowever, Livewire solves this issue by providing a temporary, signed URL that pretends to be the uploaded image so your page can show an image preview to your users.\n\nThis URL is protected against showing files in directories above the temporary directory. And, because it's signed, users can't abuse this URL to preview other files on your system.\n\n> [!tip] S3 temporary signed URLs\n> If you've configured Livewire to use S3 for temporary file storage, calling `->temporaryUrl()` will generate a temporary, signed URL to S3 directly so that image previews aren't loaded from your Laravel application server.\n\n---\n\n## Validation Deprecated `[#Rule]` attribute\nValidation | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/validation\n\n## Deprecated `[#Rule]` attribute\n\nWhen Livewire v3 first launched, it used the term \"Rule\" instead of \"Validate\" for it's validation attributes (`#[Rule]`).\n\nBecause of naming conflicts with Laravel rule objects, this has since been changed to `#[Validate]`. Both are supported in Livewire v3, however it is recommended that you change all occurrences of `#[Rule]` with `#[Validate]` to stay current.\n\n---\n\n## Uploads Loading indicators\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\n## Loading indicators\n\nAlthough `wire:model` for file uploads works differently than other `wire:model` input types under the hood, the interface for showing loading indicators remains the same.\n\nYou can display a loading indicator scoped to the file upload using `wire:loading`:\n\n```blade\n<input type=\"file\" wire:model=\"photo\">\n\n<div wire:loading wire:target=\"photo\">Uploading...</div>\n```\n\nOr more simply using Livewire's automatic `data-loading` attribute:\n\n```blade\n<div>\n    <input type=\"file\" wire:model=\"photo\">\n\n    <div class=\"not-data-loading:hidden\">Uploading...</div>\n</div>\n```\n\nNow, while the file is uploading, the \"Uploading...\" message will be shown and then hidden when the upload is finished.\n\n[Learn more about loading states →](/docs/4.x/loading-states)\n\n---\n\n## Uploads See also\nUploads | livewire/livewire@4.x | Source: https://livewire.laravel.com/docs/4.x/uploads\n\n## See also\n\n- **[Forms](/docs/4.x/forms)** — Handle file uploads in forms\n- **[Validation](/docs/4.x/validation)** — Validate uploaded files\n- **[Loading States](/docs/4.x/loading-states)** — Show upload progress indicators\n- **[wire:model](/docs/4.x/wire-model)** — Bind file inputs to properties\n\n_[Truncated]_"}]


Docs confirm Livewire’s `WithFileUploads` flow and Flux’s file input wrapper, with Laravel’s `Storage::fake` and uploaded image helpers for tests. I’m inspecting the existing product media model/job/service and the admin product form before deciding the smallest UI change.


rg -n "media|ProductMedia|image|upload" specs/01-DATABASE-SCHEMA.md specs/03-ADMIN-UI.md specs/07-SEEDERS-AND-TEST-DATA.md specs/09-IMPLEMENTATION-ROADMAP.md


rg -n "ProductMedia|Media|media" app database tests resources routes -g '!vendor'


sed -n '1,260p' app/Models/ProductMedia.php


sed -n '1,260p' app/Jobs/ProcessProductMedia.php


sed -n '1,360p' app/Livewire/Admin/Products/Form.php


sed -n '1,340p' resources/views/livewire/admin/products/form.blade.php


sed -n '1,260p' app/Models/ProductMedia.php

<?php

namespace App\Models;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;

class ProductMedia extends Model
{
    /** @use HasFactory<\Database\Factories\ProductMediaFactory> */
    use HasFactory;

    public const UPDATED_AT = null;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'type',
        'storage_key',
        'alt_text',
        'width',
        'height',
        'mime_type',
        'byte_size',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'image',
        'position' => 0,
        'status' => 'processing',
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });

        static::deleted(function (ProductMedia $media): void {
            $disk = Storage::disk('public');

            $disk->delete($media->storage_key);
            $disk->deleteDirectory("media/{$media->product_id}/{$media->getKey()}");
        });
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => MediaType::class,
            'width' => 'integer',
            'height' => 'integer',
            'byte_size' => 'integer',
            'position' => 'integer',
            'status' => MediaStatus::class,
        ];
    }
}


rg -n "media|ProductMedia|image|upload" specs/01-DATABASE-SCHEMA.md specs/03-ADMIN-UI.md specs/07-SEEDERS-AND-TEST-DATA.md specs/09-IMPLEMENTATION-ROADMAP.md

rg -n 'media|ProductMedia|image|upload' specs/01-DATABASE-SCHEMA.md specs/03-ADMIN-UI.md specs/07-SEEDERS-AND-TEST-DATA.md specs/09-IMPLEMENTATION-ROADMAP.md

Question: media|ProductMedia|image|upload

specs/01-DATABASE-SCHEMA.md:340:### product_media
specs/01-DATABASE-SCHEMA.md:348:| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |
specs/01-DATABASE-SCHEMA.md:353:| mime_type | TEXT | Yes | NULL | - | e.g. "image/jpeg" |
specs/01-DATABASE-SCHEMA.md:361:- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product
specs/01-DATABASE-SCHEMA.md:362:- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing
specs/01-DATABASE-SCHEMA.md:363:- `idx_product_media_status` on (`status`) - Finding media by processing status
specs/01-DATABASE-SCHEMA.md:1264:    products ||--o{ product_media : "has"
specs/01-DATABASE-SCHEMA.md:1308:    product_media {
specs/01-DATABASE-SCHEMA.md:1492:| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |
specs/01-DATABASE-SCHEMA.md:1544:        product_media
specs/01-DATABASE-SCHEMA.md:1600:    products --> product_media
specs/01-DATABASE-SCHEMA.md:1646:- **Batch 4** (depends on Batch 3): `product_options`, `product_variants`, `product_media`, `collection_products`, `customer_addresses`, `carts`, `navigation_items`, `theme_files`, `theme_settings`, `search_queries`, `shipping_rates`, `oauth_tokens`, `webhook_subscriptions`
specs/01-DATABASE-SCHEMA.md:1674:| 2 - Catalog, Collections, Inventory, Media | products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media | 9 |
specs/07-SEEDERS-AND-TEST-DATA.md:1431:**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.
specs/07-SEEDERS-AND-TEST-DATA.md:2218:Every variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.
specs/03-ADMIN-UI.md:371:| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |
specs/03-ADMIN-UI.md:401:| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |
specs/03-ADMIN-UI.md:488:| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |
specs/03-ADMIN-UI.md:489:| `newMedia` | `array` | Temporary uploaded files |
specs/03-ADMIN-UI.md:494:| `save()` | Validates and saves/creates the product, variants, options, and media |
specs/03-ADMIN-UI.md:500:| `uploadMedia()` | Handles file upload via Livewire temporary upload |
specs/03-ADMIN-UI.md:501:| `removeMedia(int $mediaId)` | Removes a media item |
specs/03-ADMIN-UI.md:502:| `reorderMedia(array $order)` | Updates media positions |
specs/03-ADMIN-UI.md:503:| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |
specs/03-ADMIN-UI.md:594:**Upload area:** A dashed-border drop zone with upload icon and text "Drag and drop images or click to upload". A hidden file input (multiple, accept images) triggered by clicking the zone.
specs/03-ADMIN-UI.md:596:**Upload progress:** A progress bar shown during file upload.
specs/03-ADMIN-UI.md:693:| Loading (upload) | Progress bar shown in media section |
specs/03-ADMIN-UI.md:1685:| | [preview image]  | | [preview image]  | | [preview image]  |   |
specs/03-ADMIN-UI.md:1699:- **Thumbnail area:** Aspect-video container with theme preview image or placeholder
specs/03-ADMIN-UI.md:2334:- Intermediate items: parent section (e.g., "Products" linking to products index)
specs/09-IMPLEMENTATION-ROADMAP.md:16:| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |
specs/09-IMPLEMENTATION-ROADMAP.md:261:9. `create_product_media_table` - FK: `product_id`
specs/09-IMPLEMENTATION-ROADMAP.md:275:| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |
specs/09-IMPLEMENTATION-ROADMAP.md:281:| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |
specs/09-IMPLEMENTATION-ROADMAP.md:287:| `ProductMedia` | `belongsTo(Product)` |
specs/09-IMPLEMENTATION-ROADMAP.md:342:- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)
specs/09-IMPLEMENTATION-ROADMAP.md:343:- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)
specs/09-IMPLEMENTATION-ROADMAP.md:345:Livewire file upload via `WithFileUploads` trait. Stored on local `public` disk.
specs/09-IMPLEMENTATION-ROADMAP.md:422:| `Storefront\Products\Show` | Variant selection, image gallery, add-to-cart |
specs/09-IMPLEMENTATION-ROADMAP.md:808:| `Admin\Products\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |
specs/09-IMPLEMENTATION-ROADMAP.md:814:Media upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.
specs/09-IMPLEMENTATION-ROADMAP.md:1269:| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1270:| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1271:| rejects non-image file types | attempt to upload a .txt file | validation error | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1272:| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1273:| reorders media positions | upload 3 images, reorder | position values updated | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1274:| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1521:| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1708:| `ProductMedia` | Product images and videos |
specs/09-IMPLEMENTATION-ROADMAP.md:1846:| `ProcessMediaUpload` | Resize images, update media status |
specs/09-IMPLEMENTATION-ROADMAP.md:1893:| `Products/MediaUploadTest` | Media upload feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1937:| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |
specs/09-IMPLEMENTATION-ROADMAP.md:2055:10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations


Output:
Chunk ID: d154ac
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1730
Output:
specs/01-DATABASE-SCHEMA.md:340:### product_media
specs/01-DATABASE-SCHEMA.md:348:| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |
specs/01-DATABASE-SCHEMA.md:353:| mime_type | TEXT | Yes | NULL | - | e.g. "image/jpeg" |
specs/01-DATABASE-SCHEMA.md:361:- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product
specs/01-DATABASE-SCHEMA.md:362:- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing
specs/01-DATABASE-SCHEMA.md:363:- `idx_product_media_status` on (`status`) - Finding media by processing status
specs/01-DATABASE-SCHEMA.md:1264:    products ||--o{ product_media : "has"
specs/01-DATABASE-SCHEMA.md:1308:    product_media {
specs/01-DATABASE-SCHEMA.md:1492:| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |
specs/01-DATABASE-SCHEMA.md:1544:        product_media
specs/01-DATABASE-SCHEMA.md:1600:    products --> product_media
specs/01-DATABASE-SCHEMA.md:1646:- **Batch 4** (depends on Batch 3): `product_options`, `product_variants`, `product_media`, `collection_products`, `customer_addresses`, `carts`, `navigation_items`, `theme_files`, `theme_settings`, `search_queries`, `shipping_rates`, `oauth_tokens`, `webhook_subscriptions`
specs/01-DATABASE-SCHEMA.md:1674:| 2 - Catalog, Collections, Inventory, Media | products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media | 9 |
specs/07-SEEDERS-AND-TEST-DATA.md:1431:**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.
specs/07-SEEDERS-AND-TEST-DATA.md:2218:Every variant must have a corresponding `inventory_items` row. Create these immediately after variant creation with the specified quantity_on_hand and policy values.
specs/03-ADMIN-UI.md:371:| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |
specs/03-ADMIN-UI.md:401:| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |
specs/03-ADMIN-UI.md:488:| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |
specs/03-ADMIN-UI.md:489:| `newMedia` | `array` | Temporary uploaded files |
specs/03-ADMIN-UI.md:494:| `save()` | Validates and saves/creates the product, variants, options, and media |
specs/03-ADMIN-UI.md:500:| `uploadMedia()` | Handles file upload via Livewire temporary upload |
specs/03-ADMIN-UI.md:501:| `removeMedia(int $mediaId)` | Removes a media item |
specs/03-ADMIN-UI.md:502:| `reorderMedia(array $order)` | Updates media positions |
specs/03-ADMIN-UI.md:503:| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |
specs/03-ADMIN-UI.md:594:**Upload area:** A dashed-border drop zone with upload icon and text "Drag and drop images or click to upload". A hidden file input (multiple, accept images) triggered by clicking the zone.
specs/03-ADMIN-UI.md:596:**Upload progress:** A progress bar shown during file upload.
specs/03-ADMIN-UI.md:693:| Loading (upload) | Progress bar shown in media section |
specs/03-ADMIN-UI.md:1685:| | [preview image]  | | [preview image]  | | [preview image]  |   |
specs/03-ADMIN-UI.md:1699:- **Thumbnail area:** Aspect-video container with theme preview image or placeholder
specs/03-ADMIN-UI.md:2334:- Intermediate items: parent section (e.g., "Products" linking to products index)
specs/09-IMPLEMENTATION-ROADMAP.md:16:| Queue | Redis + Horizon | Sync (dev) / Database (prod) | No Redis dependency, sync for immediate feedback |
specs/09-IMPLEMENTATION-ROADMAP.md:261:9. `create_product_media_table` - FK: `product_id`
specs/09-IMPLEMENTATION-ROADMAP.md:275:| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |
specs/09-IMPLEMENTATION-ROADMAP.md:281:| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |
specs/09-IMPLEMENTATION-ROADMAP.md:287:| `ProductMedia` | `belongsTo(Product)` |
specs/09-IMPLEMENTATION-ROADMAP.md:342:- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)
specs/09-IMPLEMENTATION-ROADMAP.md:343:- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)
specs/09-IMPLEMENTATION-ROADMAP.md:345:Livewire file upload via `WithFileUploads` trait. Stored on local `public` disk.
specs/09-IMPLEMENTATION-ROADMAP.md:422:| `Storefront\Products\Show` | Variant selection, image gallery, add-to-cart |
specs/09-IMPLEMENTATION-ROADMAP.md:808:| `Admin\Products\Form` | Shared product form component used by both create and edit routes (create vs edit mode). Title, description (rich text), status, vendor, type, tags, variants builder, media upload. |
specs/09-IMPLEMENTATION-ROADMAP.md:814:Media upload: Livewire `WithFileUploads`, drag-and-drop, reorder, alt text editing.
specs/09-IMPLEMENTATION-ROADMAP.md:1269:| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1270:| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1271:| rejects non-image file types | attempt to upload a .txt file | validation error | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1272:| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1273:| reorders media positions | upload 3 images, reorder | position values updated | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1274:| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1521:| uploads media from the product form | Livewire file upload on product edit | media attached to product | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1708:| `ProductMedia` | Product images and videos |
specs/09-IMPLEMENTATION-ROADMAP.md:1846:| `ProcessMediaUpload` | Resize images, update media status |
specs/09-IMPLEMENTATION-ROADMAP.md:1893:| `Products/MediaUploadTest` | Media upload feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1937:| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |
specs/09-IMPLEMENTATION-ROADMAP.md:2055:10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations


sed -n '1,260p' app/Jobs/ProcessProductMedia.php

sed: app/Jobs/ProcessProductMedia.php: No such file or directory


sed -n '1,360p' app/Livewire/Admin/Products/Form.php

<?php

namespace App\Livewire\Admin\Products;

use App\Enums\ProductStatus;
use App\Models\Collection;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\ProductService;
use App\Support\Money;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;

class Form extends Component
{
    public ?Product $product = null;

    public string $title = '';

    public string $descriptionHtml = '';

    public string $status = 'active';

    public string $vendor = '';

    public string $productType = '';

    public string $tags = '';

    public string $handle = '';

    public ?string $publishedAt = null;

    /**
     * @var array<int, int>
     */
    public array $collectionIds = [];

    /**
     * @var array<int, array{name: string, values: string}>
     */
    public array $options = [];

    /**
     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool}>
     */
    public array $variants = [];

    public function mount(?Product $product = null): void
    {
        if ($product?->exists) {
            $store = app('current_store');

            abort_unless($store instanceof Store && (int) $product->store_id === $store->getKey(), 404);

            $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections']);
            $this->fillFromProduct($this->product);

            return;
        }

        $this->variants = [[
            'id' => null,
            'label' => 'Default',
            'sku' => '',
            'price' => '0.00',
            'compareAtPrice' => '',
            'quantity' => 0,
            'requiresShipping' => true,
        ]];
    }

    public function updatedTitle(): void
    {
        if ($this->product === null && $this->handle === '') {
            $this->handle = Str::slug($this->title);
        }
    }

    public function addOption(): void
    {
        $this->options[] = [
            'name' => '',
            'values' => '',
        ];
    }

    public function removeOption(int $index): void
    {
        unset($this->options[$index]);
        $this->options = array_values($this->options);
    }

    public function save(): void
    {
        $store = app('current_store');
        abort_unless($store instanceof Store, 404);

        $this->validate([
            'title' => ['required', 'string', 'max:255'],
            'descriptionHtml' => ['nullable', 'string', 'max:65535'],
            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
            'vendor' => ['nullable', 'string', 'max:255'],
            'productType' => ['nullable', 'string', 'max:255'],
            'tags' => ['nullable', 'string'],
            'handle' => [
                'required',
                'string',
                'max:255',
                Rule::unique('products', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($this->product?->getKey()),
            ],
            'variants' => ['required', 'array', 'min:1'],
            'variants.*.sku' => ['nullable', 'string', 'max:255'],
            'variants.*.price' => ['required', 'numeric', 'min:0'],
            'variants.*.compareAtPrice' => ['nullable', 'numeric', 'min:0'],
            'variants.*.quantity' => ['required', 'integer', 'min:0'],
            'variants.*.requiresShipping' => ['boolean'],
        ]);

        if (! $this->validateActiveVariantPricing() || ! $this->validateVariantSkus($store)) {
            return;
        }

        $product = DB::transaction(function () use ($store): Product {
            $productService = app(ProductService::class);

            if ($this->product === null) {
                $product = $productService->create($store, [
                    ...$this->productPayload(),
                    'options' => $this->optionPayload(),
                    'variants' => $this->variantPayload($store),
                ]);
            } else {
                $payload = $this->productPayload();
                $requestedStatus = ProductStatus::from((string) $payload['status']);
                unset($payload['status']);

                $product = $productService->update($this->product, $payload);
                $this->syncExistingVariants($product, $store);

                if ($requestedStatus !== $product->refresh()->status) {
                    $productService->transitionStatus($product, $requestedStatus);
                }
            }

            $product->collections()->sync($this->collectionIds);

            return $product->refresh();
        });

        $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections']);
        …2899 chars truncated…vendor ?: null,
            'product_type' => $this->productType ?: null,
            'tags' => collect(explode(',', $this->tags))
                ->map(fn (string $tag): string => trim($tag))
                ->filter()
                ->values()
                ->all(),
            'handle' => Str::slug($this->handle),
        ];
    }

    /**
     * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
     */
    private function optionPayload(): array
    {
        return collect($this->options)
            ->map(function (array $option, int $position): array {
                return [
                    'name' => $option['name'],
                    'position' => $position,
                    'values' => collect(explode(',', $option['values']))
                        ->map(fn (string $value): string => trim($value))
                        ->filter()
                        ->values()
                        ->map(fn (string $value, int $valuePosition): array => [
                            'value' => $value,
                            'position' => $valuePosition,
                        ])
                        ->all(),
                ];
            })
            ->filter(fn (array $option): bool => $option['name'] !== '' && $option['values'] !== [])
            ->values()
            ->all();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function variantPayload(Store $store): array
    {
        return collect($this->variants)
            ->map(fn (array $variant, int $position): array => [
                'sku' => $variant['sku'] ?: null,
                'price_amount' => Money::fromDecimalString($variant['price']),
                'compare_at_amount' => $variant['compareAtPrice'] === '' ? null : Money::fromDecimalString($variant['compareAtPrice']),
                'currency' => $store->default_currency,
                'quantity_on_hand' => (int) $variant['quantity'],
                'requires_shipping' => (bool) $variant['requiresShipping'],
                'is_default' => $position === 0,
                'position' => $position,
            ])
            ->all();
    }

    private function syncExistingVariants(Product $product, Store $store): void
    {
        foreach ($this->variantPayload($store) as $position => $variantData) {
            $variantId = $this->variants[$position]['id'] ?? null;
            $variant = $variantId
                ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId)
                : $product->variants()->create(['position' => $position]);

            $variant->forceFill([
                'sku' => $variantData['sku'],
                'price_amount' => $variantData['price_amount'],
                'compare_at_amount' => $variantData['compare_at_amount'],
                'currency' => $variantData['currency'],
                'requires_shipping' => $variantData['requires_shipping'],
                'is_default' => $position === 0,
                'position' => $position,
            ])->save();

            InventoryItem::withoutGlobalScopes()->updateOrCreate(
                ['variant_id' => $variant->getKey()],
                [
                    'store_id' => $store->getKey(),
                    'quantity_on_hand' => $variantData['quantity_on_hand'],
                    'quantity_reserved' => 0,
                    'policy' => 'deny',
                ],
            );
        }
    }

    private function validateActiveVariantPricing(): bool
    {
        if ($this->status !== ProductStatus::Active->value) {
            return true;
        }

        $hasPricedVariant = collect($this->variants)
            ->contains(fn (array $variant): bool => Money::fromDecimalString($variant['price']) > 0);

        if ($hasPricedVariant) {
            return true;
        }

        $this->addError('variants.0.price', __('At least one priced variant is required before activation.'));

        return false;
    }

    private function validateVariantSkus(Store $store): bool
    {
        $skus = collect($this->variants)
            ->pluck('sku')
            ->map(fn (?string $sku): string => trim((string) $sku))
            ->filter()
            ->values();

        if ($skus->isEmpty()) {
            return true;
        }

        if ($skus->duplicates()->isNotEmpty()) {
            $this->addError('variants.0.sku', __('Each variant SKU must be unique for this store.'));

            return false;
        }

        $variantIds = collect($this->variants)
            ->pluck('id')
            ->filter()
            ->map(fn (mixed $variantId): int => (int) $variantId)
            ->values()
            ->all();

        $conflictingSku = ProductVariant::withoutGlobalScopes()
            ->whereIn('sku', $skus->all())
            ->whereHas('product', function ($query) use ($store): void {
                $query


rg -n "ProductMedia|Media|media" app database tests resources routes -g '!vendor'

rg -n 'ProductMedia|Media|media' app database tests resources routes -g '!vendor'

Question: ProductMedia|Media|media

resources/views/welcome.blade.php:20:            @layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-green-600:oklch(.627 .194 149.214);--color-gray-900:oklch(.21 .034 264.665);--color-zinc-50:oklch(.985 0 0);--color-zinc-200:oklch(.92 .004 286.32);--color-zinc-400:oklch(.705 .015 286.067);--color-zinc-500:oklch(.552 .016 285.938);--color-zinc-600:oklch(.442 .017 285.786);--color-zinc-700:oklch(.37 .013 285.805);--color-zinc-800:oklch(.274 .006 286.033);--color-zinc-900:oklch(.21 .006 285.885);--color-neutral-100:oklch(.97 0 0);--color-neutral-200:oklch(.922 0 0);--color-neutral-700:oklch(.371 0 0);--color-neutral-800:oklch(.269 0 0);--color-neutral-900:oklch(.205 0 0);--color-neutral-950:oklch(.145 0 0);--color-stone-800:oklch(.268 .007 34.298);--color-stone-950:oklch(.147 .004 49.25);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--leading-tight:1.25;--leading-normal:1.5;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--aspect-video:16/9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-font-feature-settings:var(--font-sans--font-feature-settings);--default-font-variation-settings:var(--font-sans--font-variation-settings);--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:var(--font-mono--font-feature-settings);--default-mono-font-variation-settings:var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:color-mix(in oklab,currentColor 50%,transparent)}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block…24888 chars truncated…ase/seeders/ProductSeeder.php:68:        ProductMedia::withoutGlobalScopes()
app/Services/OrderService.php:75:                $paidImmediately = $paymentResult->status === PaymentStatus::Captured;
app/Services/OrderService.php:84:                    'status' => $paidImmediately ? OrderStatus::Paid : OrderStatus::Pending,
app/Services/OrderService.php:85:                    'financial_status' => $paidImmediately ? FinancialStatus::Paid : FinancialStatus::Pending,
app/Services/OrderService.php:111:                if ($paidImmediately) {
app/Services/OrderService.php:126:                if ($paidImmediately) {
app/Services/OrderService.php:134:                if ($paidImmediately) {
app/Jobs/ProcessMediaUpload.php:5:use App\Enums\MediaStatus;
app/Jobs/ProcessMediaUpload.php:6:use App\Enums\MediaType;
app/Jobs/ProcessMediaUpload.php:7:use App\Models\ProductMedia;
app/Jobs/ProcessMediaUpload.php:16:class ProcessMediaUpload implements ShouldQueue
app/Jobs/ProcessMediaUpload.php:33:        public int $productMediaId,
app/Jobs/ProcessMediaUpload.php:39:        $media = $this->media();
app/Jobs/ProcessMediaUpload.php:42:            $this->process($media);
app/Jobs/ProcessMediaUpload.php:44:            $media->forceFill([
app/Jobs/ProcessMediaUpload.php:45:                'status' => MediaStatus::Failed,
app/Jobs/ProcessMediaUpload.php:48:            Log::error('Media processing failed.', [
app/Jobs/ProcessMediaUpload.php:49:                'product_media_id' => $media->getKey(),
app/Jobs/ProcessMediaUpload.php:50:                'storage_key' => $media->storage_key,
app/Jobs/ProcessMediaUpload.php:58:    private function media(): ProductMedia
app/Jobs/ProcessMediaUpload.php:60:        $query = ProductMedia::withoutGlobalScopes()->whereKey($this->productMediaId);
app/Jobs/ProcessMediaUpload.php:73:    private function process(ProductMedia $media): void
app/Jobs/ProcessMediaUpload.php:77:        if (! $disk->exists($media->storage_key)) {
app/Jobs/ProcessMediaUpload.php:78:            throw new RuntimeException('Media file is missing from public storage.');
app/Jobs/ProcessMediaUpload.php:81:        $contents = $disk->get($media->storage_key);
app/Jobs/ProcessMediaUpload.php:83:        $byteSize = $disk->size($media->storage_key);
app/Jobs/ProcessMediaUpload.php:85:        $dimensions = match ($media->type) {
app/Jobs/ProcessMediaUpload.php:86:            MediaType::Image => $this->processImage($media, $contents, $mimeType),
app/Jobs/ProcessMediaUpload.php:87:            MediaType::Video => $this->validateVideo($mimeType),
app/Jobs/ProcessMediaUpload.php:90:        $media->forceFill([
app/Jobs/ProcessMediaUpload.php:95:            'status' => MediaStatus::Ready,
app/Jobs/ProcessMediaUpload.php:102:    private function processImage(ProductMedia $media, string $contents, string $mimeType): array
app/Jobs/ProcessMediaUpload.php:105:            throw new RuntimeException('Media file is not a supported image.');
app/Jobs/ProcessMediaUpload.php:111:            throw new RuntimeException('Media image could not be decoded.');
app/Jobs/ProcessMediaUpload.php:135:            $basePath = "media/{$media->product_id}/{$media->getKey()}/{$size}";
app/Jobs/ProcessMediaUpload.php:160:            throw new RuntimeException('Media file is not a supported video.');
app/Jobs/ProcessMediaUpload.php:197:            throw new RuntimeException('Unable to encode resized media image.');
app/Jobs/ProcessMediaUpload.php:208:            throw new RuntimeException('Unable to determine media MIME type.');
app/Enums/MediaType.php:5:enum MediaType: string
app/Enums/MediaStatus.php:5:enum MediaStatus: string
app/Models/ProductMedia.php:5:use App\Enums\MediaStatus;
app/Models/ProductMedia.php:6:use App\Enums\MediaType;
app/Models/ProductMedia.php:13:class ProductMedia extends Model
app/Models/ProductMedia.php:15:    /** @use HasFactory<\Database\Factories\ProductMediaFactory> */
app/Models/ProductMedia.php:65:        static::deleted(function (ProductMedia $media): void {
app/Models/ProductMedia.php:68:            $disk->delete($media->storage_key);
app/Models/ProductMedia.php:69:            $disk->deleteDirectory("media/{$media->product_id}/{$media->getKey()}");
app/Models/ProductMedia.php:87:            'type' => MediaType::class,
app/Models/ProductMedia.php:92:            'status' => MediaStatus::class,
app/Models/Product.php:66:     * @return HasMany<ProductMedia, $this>
app/Models/Product.php:68:    public function media(): HasMany
app/Models/Product.php:70:        return $this->hasMany(ProductMedia::class)->orderBy('position');
database/migrations/2026_05_03_213904_create_product_media_table.php:14:        Schema::create('product_media', function (Blueprint $table) {
database/migrations/2026_05_03_213904_create_product_media_table.php:39:        Schema::dropIfExists('product_media');
app/Http/Resources/Storefront/V1/SearchProductResource.php:32:            'image_url' => $this->relationLoaded('media') ? $this->media->first()?->storage_key : null,