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:
-
storeFileUsing()
argument 2 is anarray
, not aTemporaryUploadedFile
-
resolveDisplayValueUsing()
argument 2 is still a string — it's the value from the database column (or rather the value fromresolveValue()
which in the case of files is not changed by default) -
deleteFileUsing()
argument 2 is a string, it's the value from storage (directly, does not get affected byresolveValueUsing()
)
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:
- The callback gets an array of uploaded files,
array $files
- It loops through each file and stores it on the
public
disk - The
store()
method returns the path to the stored file. We add this path toarray $paths
- We
json_encode($paths)
and return the string. As the return value of thestoreFileUsing()
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:
- 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. - 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). - We're hijacking the
stored()
callback. We don't want to store the field, it has no real column. But if we setstored(false)
, the field would be completely skipped in Lean's actions and our file storage logic wouldn't even run. - 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:
- 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.
- 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.