File Upload
PandaPanel\Forms\Components\FileUpload menyimpan file sebelum form disubmit dan field hanya menyimpan path yang dikembalikan endpoint upload. Field tidak pernah membawa isi file: browser mengirim file ke upload endpoint milik Panel, endpoint menyimpannya lalu mengembalikan path, dan form akhirnya mengirim path tersebut seperti string biasa. Gunakan field ini ketika sebuah record mereferensikan file pada filesystem disk — misalnya avatar, attachment, atau gallery.
Contoh minimal
use PandaPanel\Forms\Components\FileUpload;
use PandaPanel\Forms\FormSchema;
FormSchema::make()->schema([
FileUpload::make('avatar')
->disk('public')
->directory('avatars')
->image()
->maxSize(1024),
]);2
3
4
5
6
7
8
9
10
Column menyimpan path seperti avatars/9f2c….png, sehingga column string biasa sudah cukup. Field dengan multiple() menyimpan list path dan membutuhkan array cast.
Mengapa menggunakan dua request
Jika file dibawa bersama submit form, browser perlu diberi tahu ke mana file harus disimpan — dan informasi semacam itu justru tidak boleh ditentukan client. Dengan memisahkan proses upload, seluruh keputusan mengenai lokasi file dibuat oleh server berdasarkan deklarasi field:
- disk, sehingga path tidak dapat diarahkan ke disk lain;
- directory, sehingga path yang berada di luar directory ditolak dan tidak ditempelkan ke record;
- accepted types dan size, karena “browser hanya menawarkan gambar” hanyalah control UX, bukan security constraint.
Ketiganya diberlakukan dua kali: pertama oleh upload endpoint terhadap file asli, lalu pada submit form terhadap path yang dikembalikan. Keduanya adalah request berbeda, dan hanya request kedua yang benar-benar mengaitkan file dengan record.
Method
public function disk(string $disk): self // default: 'public'
public function directory(string $directory): self // default: 'uploads'
public function multiple(bool $multiple = true): self // default: false
public function maxSize(int $kilobytes): self // default: 5120, clamped to >= 1
public function maxFiles(int $max): self // default: null, clamped to >= 1
public function acceptedTypes(array $types): self // default: []
public function image(bool $image = true): self // default: false2
3
4
5
6
7
| Method | Default | Yang dibatasi |
|---|---|---|
disk() | 'public' | Laravel filesystem disk tempat file disimpan dan dibaca kembali |
directory() | 'uploads' | directory tujuan file dan prefix yang wajib dimiliki submitted path |
multiple() | false | apakah value berupa satu string atau list of strings |
maxSize() | 5120 kilobytes | rule max: terhadap uploaded file asli |
maxFiles() | null | rule max: pada array untuk field multiple() |
acceptedTypes() | [] | rule mimetypes: pada file asli sekaligus filter accept pada picker |
image() | false | merender preview dan mengisi acceptedTypes() jika list masih kosong |
use PandaPanel\Forms\Components\FileUpload;
FileUpload::make('gallery')
->disk('s3')
->directory('products/gallery')
->multiple()
->maxFiles(8)
->maxSize(4096)
->acceptedTypes(['image/jpeg', 'image/png', 'image/webp'])
->image()
->columnSpanFull();2
3
4
5
6
7
8
9
10
11
directory() dinormalisasi
$this->directory = trim(str_replace('..', '', $directory), '/');Seluruh jaminan path bergantung pada prefix comparison. Directory yang memiliki trailing slash atau .. akan menghasilkan perbandingan terhadap format path yang berbeda. Dalam field ini, '/avatars/' dan 'avatars' dianggap directory yang sama.
image() mengisi accepted types satu kali
use PandaPanel\Forms\Components\FileUpload;
FileUpload::make('avatar')->image()->getAcceptedTypes();
// ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif']
FileUpload::make('avatar')->acceptedTypes(['image/png'])->image()->getAcceptedTypes();
// ['image/png'] — image() only fills an empty list2
3
4
5
6
7
Urutan pemanggilan penting. ->image()->acceptedTypes([...]) mengganti default image types secara penuh.
Membaca deklarasi field
Lima getter dibuat public karena upload endpoint membacanya dari field yang berhasil di-resolve dari schema:
public function getDisk(): string
public function getDirectory(): string
public function getMaxSize(): int
public function isMultiple(): bool
public function getAcceptedTypes(): array2
3
4
5
use PandaPanel\Forms\Components\FileUpload;
$field = FileUpload::make('avatar')->disk('local')->directory('avatars');
$field->getDisk(); // 'local'
$field->getDirectory(); // 'avatars'
$field->getMaxSize(); // 51202
3
4
5
6
7
accepts() — pemeriksaan saat value masuk
public function accepts(string $path): boolMethod ini menjawab apakah submitted path merupakan path yang memang mungkin dihasilkan field tersebut. Tiga kondisi harus terpenuhi:
- path tidak kosong dan tidak mengandung
..; - path dimulai dengan
directory/; - file benar-benar ada pada disk yang dideklarasikan.
use Illuminate\Support\Facades\Storage;
use PandaPanel\Forms\Components\FileUpload;
Storage::fake('local');
Storage::disk('local')->put('avatars/one.png', 'x');
Storage::disk('local')->put('elsewhere/two.png', 'x');
$field = FileUpload::make('avatar')->disk('local')->directory('avatars');
$field->accepts('avatars/one.png'); // true
$field->accepts('elsewhere/two.png'); // false — outside the directory
$field->accepts('avatars/missing.png'); // false — not there
$field->accepts('avatars/../elsewhere/two.png'); // false — climbing out2
3
4
5
6
7
8
9
10
11
12
13
mutate() menerapkan check tersebut sebelum value mencapai record, dan value yang gagal akan dibuang tanpa error. Submitted path bukan value yang user ketik secara manual; jika tidak dapat dihasilkan oleh field, framework memperlakukannya sebagai input yang tidak valid untuk persistence:
$field->mutate('avatars/never-uploaded.png', null); // null
FileUpload::make('gallery')->disk('local')->directory('avatars')->multiple()
->mutate(['avatars/one.png', 'avatars/fake.png'], null);
// ['avatars/one.png']2
3
4
5
Validasi
use PandaPanel\Forms\Components\FileUpload;
use PandaPanel\Forms\FormSchema;
FormSchema::make()
->schema([
FileUpload::make('avatar'),
FileUpload::make('gallery')->multiple()->maxFiles(5),
])
->validationRules();
// [
// 'avatar' => ['nullable', 'string'],
// 'gallery' => ['nullable', 'array', 'max:5'],
// 'gallery.*' => ['string'],
// ]2
3
4
5
6
7
8
9
10
11
12
13
14
15
Rules tersebut mendeskripsikan path, karena itulah value yang disubmit form. Size dan MIME type tidak berada di rules ini; keduanya diperiksa upload endpoint terhadap file asli, kemudian keberadaan/path diperiksa lagi oleh accepts() pada proses dehydration.
Upload endpoint
URL upload dibangun oleh server dan dikirim bersama form. Vue tidak pernah menyusun Panel URL sendiri.
| Builder | Digunakan oleh |
|---|---|
FormEndpoints::upload(string $resource, string $page, ?Model $record = null) | CreateRecord, EditRecord |
FormEndpoints::uploadForRelation(string $resource, string $manager, Model $owner, string $operation, Model|int|string|null $related = null) | relation forms |
FormEndpoints::uploadForAction(string $resource, string $action, string $scope, Model|int|string|null $record = null) | action modals |
Ketiganya menunjuk route Panel bernama uploads, yaitu $panel->routeName('uploads'), yang diregistrasikan sebagai POST {panel prefix}/uploads.
Request body hanya membawa dua value: field dan file. Seluruh informasi tentang form context berada di query string. Ini penting karena body adalah bagian dari form values; field yang kebetulan bernama resource tidak boleh dapat mengarahkan upload ke Resource lain.
Permission yang dibutuhkan upload
Upload membutuhkan permission yang sama dengan submit form tempat field berada — tidak boleh lebih lemah:
| Context dalam URL | Schema yang dibangun | Ability yang diperiksa |
|---|---|---|
page=create | create form milik Resource | canCreate() |
page=edit + record | edit form milik Resource | canEdit($record) |
relation + operation | relation form | canView($owner), canViewAny($owner), dan ability operation terkait |
action + scope | action form | isAuthorizedFor($record) milik Action |
Hak untuk membaca Resource saja tidak cukup. Upload melakukan write ke filesystem, dan ability untuk melihat list bukan berarti ability untuk menambahkan file ke context tersebut.
Yang dilakukan endpoint
- memastikan
fielddanfiletersedia; - me-resolve Resource dari query string dan mengotorisasi context di atas;
- me-resolve field bernama tersebut dari schema — nama tidak dikenal menghasilkan 404, field yang bukan
FileUploadmenghasilkan 400; - memvalidasi real file menggunakan
max:{maxSize}dan, jika types dideklarasikan,mimetypes:{types}; - menyimpan file melalui
$file->store($directory, $disk); - mengembalikan
{"path": "...", "name": "..."}.
Rule mimetypes: membaca isi file, bukan hanya extension. Mengganti nama .php menjadi .png tidak membuat file lolos pemeriksaan.
Data yang dikirim ke frontend
interface FileUploadFieldDefinition extends BaseFieldDefinition {
type: 'file_upload';
multiple: boolean;
/** Kilobytes, matching Laravel's `max:` rule. */
maxSize: number;
maxFiles: number | null;
acceptedTypes: string[];
image: boolean;
/** Null when the disk serves no public URL; the name is shown instead. */
previewBase: string | null;
}2
3
4
5
6
7
8
9
10
11
previewBase berasal dari Storage::disk($disk)->url('/') dengan trailing slash dihapus. Value di-resolve server-side sehingga browser tidak pernah membuat URL berdasarkan nama disk. Disk yang tidak memiliki public URL — misalnya private disk atau driver yang tidak melayani file langsung — menghasilkan null, sehingga field menampilkan nama file alih-alih broken image link.
Hal yang perlu diperhatikan
Menghapus file dari form tidak menghapus file fisiknya. Form belum tentu disubmit dan record bisa saja masih menggunakan file tersebut. Pembersihan orphan menjadi responsibility application, misalnya model observer pada deleting atau scheduled sweep pada directory.
maxSize() menggunakan kilobytes. Value diteruskan langsung ke Laravel rule max: untuk file. maxSize(1024) berarti satu megabyte.
Path yang ditolak menghilang tanpa pesan. mutate() membuang value yang gagal accepts() alih-alih menghasilkan validation error. Jika path legitimate hilang, periksa disk dan directory prefix terlebih dahulu.
Mengubah directory() dapat membuat stored value lama menjadi orphan. accepts() selalu membandingkan dengan deklarasi directory saat ini. Path lama kemudian gagal prefix check dan akan dibuang pada save berikutnya. Pindahkan file terlebih dahulu atau pertahankan directory lama.
Jangan menggunakan directory kosong. Prefix test adalah str_starts_with($path, $directory.'/'); directory kosong menghasilkan '/', sementara stored path normal tidak dimulai dengan slash, sehingga seluruh value ditolak.
File field membutuhkan form context. Upload URL hanya tersedia pada Resource create/edit page, relation form, dan action modal. Form yang dibangun di luar context tersebut tidak memiliki URL upload. FileUploadField.vue menampilkan kondisi tersebut daripada gagal saat file di-drop.
multiple() mengubah tipe value. Mengaktifkannya setelah record lama sudah tersimpan sebagai string membuat field sekarang mengharapkan array; castForForm() akan menghasilkan [] untuk string lama.
Flag image adalah presentation plus default types. Flag ini merender preview dan mengisi acceptedTypes() jika masih kosong. Ia tidak secara independen memverifikasi file sebagai gambar; verification dilakukan oleh rule mimetypes: dari accepted types tersebut.
Lihat juga
- File Uploads — endpoint dalam request lifecycle yang lebih luas
- Builder — media block biasanya berisi field ini
- Repeater
- Action Forms — upload dari modal
- Relation Forms
- Authorization
- Forms and Schemas