Advanced file uploads

Here are a few examples of very customized file/image uploads, so that you can get an idea of how to work with that field's (relatively complex) API.

Multiple uploads

To make a file/image support multiple images, use the ->multiple() method to the chain:

File::make('attachments')
    ->multiple()

If you wish to restrict the amount of files, just add a rule:

File::make('attachments')
    ->multiple()
    ->rules('max:3')

Consideration for multiple uploads

The S3 driver does not support multiple files. So multiple file uploads will only work with the webserver's local/public disks.

When you're storing multiple files or images, keep in mind that the database column still has the same type. Meaning, you still need to store text in the database. That can be dealt with by using JSON or relations. Both are explained below.

Multiple files

Before we talk about the different storage strategies, let's take a look at this method chain:

File::make('image')
    ->multiple()
    ->rules('max:2')
    ->storeFileUsing(fn (File $field, array $values) => 'abc') // result: abc
    ->resolveDisplayValueUsing(fn (File $field, string $storedValue) => strtoupper($storedValue)) // result: ABC
    ->deleteFileUsing(fn (File $field, string $value) => null), // $value = abc, return value is null, making the value null

Notice that:

  1. storeFileUsing() argument 2 is an array, not a TemporaryUploadedFile
  2. resolveDisplayValueUsing() argument 2 is still a string — it's the value from the database column (or rather the value from resolveValue() which in the case of files is not changed by default)
  3. deleteFileUsing() argument 2 is a string, it's the value from storage (directly, does not get affected by resolveValueUsing())

In practical terms: when you're storing new files, they will be in an array, but the value in the database and the value for your delete file callback will still be strings.

JSON string with file paths

One way to deal with this is to json_encode() the file names when storing:

->storeFileUsing(function (File $field, array $files) {
    /* @var TemporaryUploadedFile[] $files */

    $paths = [];

    foreach ($files as $file) {
        $paths[] = $file->store('images', ['disk' => 'public']);
    }

    return json_encode($paths);
})

To explain what's happening above:

  1. The callback gets an array of uploaded files, array $files
  2. It loops through each file and stores it on the public disk
  3. The store() method returns the path to the stored file. We add this path to array $paths
  4. We json_encode($paths) and return the string. As the return value of the storeFileUsing() callback, this is what will be stored in the database.

The delete counterpart would be:

->deleteFileUsing(function (File $field, string $name) {
    $paths = json_decode($field->storedValue, true);

    foreach ($paths as $path) {
        Storage::disk('public')->delete($path);
    }

    return null;
}),

(Also be aware that you may run into string length limits, depending on the data type for your database column.)

For ->resolveDisplayValueUsing(), choose what works for you. You may want to display all of the paths merged into some readable string. Or you may want to only display the first one. Or maybe you want to display the count. Completely depends on what types of files you're storing. That said, going with the file count might be the wisest option.

->resolveDisplayValueUsing(function (File $field, string $value) {
    $paths = json_decode($field->storedValue, true);

    return count($paths) . ' attachments';
});

Multiple images

Storing multiple images is slightly different from storing multiple files, but most things are the same.

The key difference is that images have previews (a bit like file "display values"), but in the case of images, they're displayed on all actions.

On show, edit, and create, all images will be shown. They'll be stacked below each other.

On index, only one image can be displayed.

->resolveValueUsing(function (Image $field, $model, $value) {
    if (Str::of($value)->startsWith('["')) {
        return ($field->resolveUrlCallback)($field, (collect(json_decode($value, true))->random()));
    }

    // This keeps thumbnails functional
    return ($field->resolveUrlCallback)($field, $value);
})

In this example, if the value is a JSON string (= if there are images stored and we're not working with the thumbnail), we simply pick a random image.

You can replace the random() call with first() to display the first image.

And another option would be combining the images into a single collage. Again, completely depends on what these files represent in your case.

Deleting individual images

You cannot delete individual images with multiple. It would add a lot of complexity and (as explained below), using multiple files is often the wrong solution.

So for that reason, it's strongly recommended to only use multiple files for simple things where files are either freshly uploaded, or replaced altogether.

Relations to files

A better solution to storing multiple files is to create a new resource (with a model and a migration file, of course) and use the HasMany relation.

This gives you more features than the multiple file field (e.g. you can delete files individually) and you don't have to deal with storing JSON with file paths in the database.

For example, if you have a PageResource, you could create an ImageResource and connect them like this:

// PageResource
public static function fields(): array
{
    return [
        // ....

        HasMany::make('images')->of(ImageResource::class),

        // ...
    ];
}

// ImageResource
public static function fields(): array
{
    return [
        ID::make('id'),

        ImageField::make('path')
            ->storeFileUsing(fn (ImageField $field, TemporaryUploadedFile $file) => ...)
            ->deleteFileUsing(fn (ImageField $field, string $path) => ...),

        BelongsTo::make('page')->parent(PageResource::class),
    ];
}

And it just works. You see images on show and edit screens, you can easily change them, and you can use an unlimited amount of them.

(To see how to specify the store & delete behavior correctly, see the Fields page of the documentation.)

Relation proxy files

A very complex solution, but potentially useful, is using a multiple file field combined with a relation. The data gets stored in another table (= no need to store JSON), but we get an actual input.

If you decide to do this, you can use the code below as inspiration (it's more of a proof of concept than a copypaste solution that's guaranteed to work, so code with care).

use Illuminate\Database\Eloquent\Collection;

Image::make('images')
    ->multiple()
    ->resolveValueUsing(function (Image $field, $model, Collection $images) {
        if ($field->action->write() || $field->action->is('show')) {
            return $images->pluck('path')->map(fn ($path) => ($field->resolveUrlCallback)($field, $path))->toArray() ?: null;
        }

        return $images->pluck('path')->map(fn ($path) => ($field->resolveUrlCallback)($field, $path))->first();
    })
    ->stored(function (Image $field) {
        // We're at the point in the lifecycle where the file would get stored
        if ($field->value && is_iterable($field->value) && count($field->value) && is_object($field->value[0])) {
            ($field->storeFileCallback)($field, $field->value);
        }

        // But we don't want to store the file in a column
        return false;
    })
    ->storeFileUsing(function (Image $field, array $files) {
        $field->model->images()->delete();
        $field->model->images()->createMany(collect($files)->map(fn (TemporaryUploadedFile $file) => [
            'path' => $file->store('images'),
        ]));
    })
    ->deleteFileUsing(fn (Image $field) => collect(json_decode($field->storedValue, true))
        ->each(fn (string $path) => Storage::disk('public')->delete($path))
    )

Let's go over what's happening there:

  1. We accept a Collection as the value — since we're using an Eloquent relationship's name for the field, the value will be an Eloquent Collection.
  2. We're converting the models to a simple array of string paths. The Image view can deal with these (they get stacked below each other).
  3. We're hijacking the stored() callback. We don't want to store the field, it has no real column. But if we set stored(false), the field would be completely skipped in Lean's actions and our file storage logic wouldn't even run.
  4. We're assuming that the model exists, so this won't work with create actions. When there's no parent model, there can't be any child models. This can be solved in (at least) two ways, depending on what makes sense for your use case:
    1. Don't use this approach on create actions. Sometimes, it doesn't make sense to store files on create, so there's no need to deal with this at all.
    2. Register an event to create the files after the parent model is saved. Ugly, but it works. Only use if you absolutely need this.
    ->storeFileUsing(function (Image $field, array $files) {
        $store = function () use ($field, $files) {
            $field->model->images()->delete();
            $field->model->images()->createMany(collect($files)->map(fn (TemporaryUploadedFile $file) => [
                'path' => $file->store('images'),
            ]));
        }
    
        if (! $field->model->exists()) {
            $field->model::created($store);
        } else {
            $store();
        }
    })
    

Custom file fields

And the final suggestion is to just create a custom field. Some of the solutions above are painfully complex. As are all generic solutions.

For this reason, our recommendation is yet again to build a custom component that works with your code, makes sense to your users, and saves you potential complexity of dealing with generic file upload mechanisms. That's the point of Lean — if generic solutions detached from your business concepts bring complexity and confusion: we let you build a custom component.