Kegagalan upload
Field file menyimpan file melalui request tersendiri sebelum form utama disubmit, dan browser hanya mengetahui apakah request upload tersebut berhasil. Ketika gagal, field hanya menampilkan X could not be uploaded.—status code dan penyebab sebenarnya berada di network tab. Halaman ini memetakan setiap response yang dapat dikembalikan PandaPanel\Http\Controllers\PanelUploadController ke declaration atau permission yang menyebabkannya. Gunakan halaman ini ketika upload ditolak, file berhasil diupload tetapi hilang setelah save, atau preview menjadi broken image.
Mereproduksi masalah di luar browser
Diagnosis tercepat adalah test yang melakukan POST ke endpoint yang sama dengan field, karena test dapat menunjukkan status dan message yang tidak ditampilkan field:
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
it('stores a file for the avatar field', function (): void {
Storage::fake('public');
$response = $this->actingAs(User::factory()->admin()->create())->postJson(
route('panel.admin.uploads', ['resource' => 'users', 'page' => 'create'], absolute: false),
[
'field' => 'avatar',
'file' => UploadedFile::fake()->image('me.png'),
],
);
expect($response->status())->toBe(200)
->and(Storage::disk('public')->exists((string) $response->json('path')))->toBeTrue();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Gunakan postJson, bukan post. Validation failure pada POST biasa menghasilkan 302 kembali ke halaman sebelumnya; pada JSON request hasilnya 422 beserta message, sama seperti yang diterima fetch milik field.
Request yang dibuat field
Satu POST menuju {panel-path}/uploads, dengan route name panel.{panel_id}.uploads, diregistrasikan oleh PandaPanel\Routing\PanelRouteRegistrar di dalam middleware panel.
| Lokasi | Key | Arti |
|---|---|---|
| Body | field | Nama field. Wajib berupa string. |
| Body | file | File. Wajib berupa upload nyata. |
| Query | resource | Slug resource di panel ini. Wajib. |
| Query | page | create atau edit untuk resource form. |
| Query | record | Record key—wajib untuk page=edit dan relation form, opsional untuk action. |
| Query | relation, operation, related | Form milik relation manager. |
| Query | action, scope | Form milik action. scope adalah salah satu dari record, table, bulk, infolist. |
| Query | parent | Parent record key untuk nested resource. |
Semua informasi yang menjelaskan form ini milik apa berada di query string dan hanya dibaca dari sana. Field bernama resource di body form tidak dapat mengarahkan upload ke resource lain—test reads the resource from the URL and not from the form body di tests/Feature/Panel/FormEndpointTest.php memastikan hal tersebut dan response-nya 422, bukan upload yang dialihkan.
Response sukses berbentuk:
{ "path": "avatars/9f3c.png", "name": "portrait.png" }path adalah value yang nanti disubmit form. name adalah original client name dan hanya digunakan untuk display.
Seluruh response yang dapat dikembalikan endpoint
Pemeriksaan dilakukan berurutan: current panel, field dan file, resource, parent binding, authorization dan schema, field, limit file, lalu penyimpanan. Karena itu field yang hilang tetap menghasilkan 422 meskipun resource juga salah.
| Status | Message | Penyebab |
|---|---|---|
422 | validation errors | field atau file hilang, atau file bukan upload |
422 | Invalid resource. | tidak ada resource di query string |
404 | Unknown resource. | slug tersebut tidak terdaftar di panel ini |
422 | Invalid parent key. | nested resource tidak memiliki parent |
404 | — | parent record tidak berhasil di-resolve |
422 | Invalid page. | page bukan create maupun edit |
403 | — | Resource::canCreate() menolak |
422 | Invalid record key. | page=edit tanpa record |
404 | — | record tidak berada di Resource::query() |
403 | — | Resource::canEdit($record) menolak |
404 | Unknown relation. | relation manager dengan key tersebut tidak ada |
404 | Unknown relation operation. | operation bukan RelationOperation |
403 | — | canView($owner), Manager::canViewAny($owner), atau ability milik operation menolak |
422 | Invalid scope. | scope bukan salah satu dari empat scope yang didukung |
404 | Unknown action. | action name tidak ada pada set yang ditentukan scope |
403 | — | Action::isAuthorizedFor($record) menolak |
400 | This action has no form. | action tidak mendeklarasikan schema |
404 | Unknown field. | schema tidak memiliki field dengan nama tersebut |
400 | That field does not accept files. | field bukan FileUpload |
422 | validation errors pada file | file gagal terhadap max: atau mimetypes: |
422 | No file was uploaded. | request tidak membawa UploadedFile |
500 | The file could not be stored. | $file->store() mengembalikan false |
413 | — | ValidatePostSize milik Laravel, terjadi sebelum controller |
419 | — | session expired sehingga CSRF token tidak lagi cocok |
Tiga message yang dapat ditampilkan field
resources/js/panel/forms/fields/FileUploadField.vue hanya menghasilkan tiga message berikut, dan hanya satu yang berhubungan langsung dengan server.
| Message | Penyebab |
|---|---|
This form cannot store files. | Form dirender tanpa upload URL sehingga tidak ada tujuan POST. |
{name} is larger than {n} KB. | Pemeriksaan browser file.size > field.maxSize * 1024, sebelum request dikirim. |
{name} could not be uploaded. | POST mengembalikan selain 200. |
postForm() di resources/js/panel/forms/http.ts mengembalikan null untuk setiap non-OK response maupun request yang throw. Ini disengaja: side request yang gagal tidak boleh menghancurkan halaman dan menghilangkan form yang sudah setengah terisi. Konsekuensinya message ketiga mewakili lebih dari dua puluh kemungkinan pada table di atas, sehingga periksa status di network tab sebelum menebak penyebab lain.
This form cannot store files.
FormRenderer menyediakan URL dan field menginject-nya:
provideUploadUrl(() => props.uploadUrl ?? null);Empat konteks mengirim upload URL, dan hanya empat ini:
| Form | Prop diset oleh |
|---|---|
| Create page | PandaPanel\Resources\Pages\CreateRecord::render() |
| Edit page | PandaPanel\Resources\Pages\EditRecord::render() |
| Relation dialog | PandaPanel\Http\Controllers\PanelRelationController |
| Action modal | PandaPanel\Http\Controllers\PanelActionFormController |
Custom Vue page yang merender FormRenderer tanpa :upload-url menerima null, dan FileUpload langsung menampilkan message tersebut pada drop pertama sebelum file dikirim. Tidak ada page-level upload endpoint. PandaPanel\Support\FormEndpoints membangun seluruh upload URL dari resource class, sehingga form yang bukan milik resource, relation, atau action memang tidak memiliki tempat untuk menyimpan file:
use PandaPanel\Support\FormEndpoints;
FormEndpoints::upload(PostResource::class, 'create'); // string
FormEndpoints::upload(PostResource::class, 'edit', $post);
FormEndpoints::uploadForRelation(PostResource::class, CommentsRelationManager::class, $post, 'create');
FormEndpoints::uploadForRelation(PostResource::class, CommentsRelationManager::class, $post, 'edit', $comment);
FormEndpoints::uploadForAction(PostResource::class, 'import', 'table');
FormEndpoints::uploadForAction(PostResource::class, 'attach-file', 'record', $post);2
3
4
5
6
7
8
| Method | Signature |
|---|---|
upload | static upload(string $resource, string $page, ?Model $record = null): string |
uploadForRelation | static uploadForRelation(string $resource, string $manager, Model $owner, string $operation, Model|int|string|null $related = null): string |
uploadForAction | static uploadForAction(string $resource, string $action, string $scope, Model|int|string|null $record = null): string |
Ketiganya mengembalikan relative URL dan melempar PandaPanel\Exceptions\PanelRegistrationException::noCurrentPanel() ketika dipanggil di luar panel request. Jika Anda menampilkan resource form pada custom page, berikan hasil tersebut ke FormRenderer sebagai uploadUrl.
Upload yang menghasilkan 403
Upload adalah operasi write. Endpoint meminta ability yang dibutuhkan untuk submit form tempat field tersebut berada, bukan permission yang lebih lemah—sekadar dapat membaca resource tidak pernah cukup.
| Context pada URL | Schema yang dibangun | Ability yang diperiksa |
|---|---|---|
page=create | create form resource | Resource::canCreate() |
page=edit + record | edit form resource | Resource::canEdit($record) |
relation + operation | relation form | canView($owner), Manager::canViewAny($owner), dan RelationOperation::isAuthorized() |
action + scope | form milik action | Action::isAuthorizedFor($record) |
Dua konsekuensi penting sebelum menyalahkan endpoint:
- User yang boleh melihat list tetapi tidak boleh create akan mendapatkan 403 pada upload milik create form. Ini adalah perbaikan keamanan, bukan regression: endpoint sebelumnya menerima
canCreate() || canViewAny(), yang membuat role read-only dapat menulis file ke disk. Lihat changelog. - Upload pada edit form menanyakan permission terhadap satu record. Endpoint tidak meminjam
create, sehingga policy yang mengizinkan create tetapi menolak update juga menolak upload pada edit page.
Jika policy method yang Anda harapkan sama sekali tidak dipanggil, penyebab yang paling umum adalah policy tidak tersedia. Gate::allows() mengembalikan false jika tidak ada policy—aman tetapi tidak dapat dibedakan dari policy yang memang menolak. Ubah ambiguitas tersebut menjadi exception selama diagnosis:
use PandaPanel\Core\Panel;
Panel::make('admin')->strictAuthorization();2
3
PandaPanel\Support\PolicyGate kemudian melempar PandaPanel\Exceptions\PanelAuthorizationException ketika model tidak memiliki policy atau policy tidak memiliki method yang dibutuhkan—termasuk relation ability yang tidak memiliki method can* di resource. Policy dengan method before() dikecualikan karena before memang dapat menjawab seluruh ability.
Untuk nested resource, sertakan parent. bindParentRecord() melakukan bind sebelum query lain berjalan, sehingga upload menggunakan scope yang sama dengan page. Tanpa parent di query string hasilnya 422, dan parent yang tidak dapat di-resolve menghasilkan 404.
422 yang menyebut file
Dua rule dibangun dari declaration field dan diterapkan pada file yang benar-benar diterima:
$rules = ['file' => ['file', 'max:'.$field->getMaxSize()]];
if ($field->getAcceptedTypes() !== []) {
$rules['file'][] = 'mimetypes:'.implode(',', $field->getAcceptedTypes());
}2
3
4
5
mimetypes: membaca byte, bukan nama file. Script PHP yang diganti nama menjadi payload.png tetap ditolak—tests/Feature/Panel/Negative/MalformedInputTest.php mengujinya dengan real file, bukan UploadedFile::fake(), karena fake menentukan type berdasarkan nama dan akan membuat test tersebut tidak benar-benar menguji content.
Atribut accept pada input berasal dari list yang sama, sehingga picker dan endpoint konsisten. Namun accept hanya convenience; endpoint tetap control sebenarnya. Seluruh konfigurasi field:
| Method | Signature | Default |
|---|---|---|
disk | disk(string $disk): self | 'public' |
directory | directory(string $directory): self | 'uploads' — .. dibuang, slash di tepi dipangkas |
multiple | multiple(bool $multiple = true): self | false |
maxSize | maxSize(int $kilobytes): self | 5120, minimum 1 |
maxFiles | maxFiles(int $max): self | null, minimum 1 |
acceptedTypes | acceptedTypes(array $types): self | [] |
image | image(bool $image = true): self | false; mengisi acceptedTypes dengan image/jpeg, image/png, image/gif, image/webp, image/avif jika belum dideklarasikan |
getDisk | getDisk(): string | |
getDirectory | getDirectory(): string | |
getMaxSize | getMaxSize(): int | |
isMultiple | isMultiple(): bool | |
getAcceptedTypes | getAcceptedTypes(): list<string> | |
accepts | accepts(string $path): bool | |
mutate | mutate(mixed $value, ?Model $record): mixed | |
elementRules | elementRules(): list<mixed> | ['string'] jika multiple, [] jika tidak |
use PandaPanel\Forms\Components\FileUpload;
FileUpload::make('attachment')
->disk('public')
->directory('attachments')
->acceptedTypes(['image/png', 'image/jpeg'])
->maxSize(2048)
->required();2
3
4
5
6
7
8
Baca kembali declaration ketika refusal tidak masuk akal—field adalah source of truth, bukan file picker. FormSchema::field(string $name): ?Field mengembalikan null untuk field yang tidak dideklarasikan schema, sama seperti endpoint mengembalikan 404:
use PandaPanel\Forms\Components\FileUpload;
use PandaPanel\Forms\FormSchema;
$field = PostResource::form(FormSchema::make())->field('attachment');
if ($field instanceof FileUpload) {
$field->getDisk(); // 'public'
$field->getDirectory(); // 'attachments'
$field->getMaxSize(); // 2048 (kilobytes)
$field->getAcceptedTypes(); // ['image/png', 'image/jpeg']
$field->isMultiple(); // false
}2
3
4
5
6
7
8
9
10
11
12
Upload berhasil tetapi file hilang setelah save
Ini berarti FileUpload::accepts() menolak path saat submit dan mutate() membuang value tersebut. Perilaku ini disengaja: upload dan submit adalah dua request terpisah, dan hanya request kedua yang benar-benar mengaitkan path dengan record.
use Illuminate\Support\Facades\Storage;
use PandaPanel\Forms\Components\FileUpload;
Storage::fake('local');
Storage::disk('local')->put('avatars/one.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 on the disk
$field->accepts('avatars/../elsewhere/two.png'); // false — climbs out
$field->mutate('avatars/never-uploaded.png', null); // null2
3
4
5
6
7
8
9
10
11
12
13
14
Empat hal dapat membuat path gagal:
directory()berubah setelah file disimpan. Value lama tidak lagi memiliki prefix baru, sehingga save berikutnya pada record yang tidak diubah dapat membuang value tersebut.disk()berubah.accepts()memeriksa keberadaan path pada disk yang sekarang dideklarasikan, sementara file berada di disk sebelumnya.- File dihapus di luar form. Menghapus file dari form tidak langsung menghapus file di storage—form belum disubmit dan record mungkin masih menggunakannya—jadi deletion di sini berasal dari aplikasi Anda.
- Value bukan berasal dari field tersebut. Path dari field lain, form lain, atau request manual memang seharusnya ditolak oleh pemeriksaan ini.
Untuk multiple(), value berupa list path dan model perlu dapat menyimpan array:
protected function casts(): array
{
return ['gallery' => 'array'];
}2
3
4
Tanpa cast, attribute menjadi string dan path setelah item pertama dapat hilang bahkan sebelum accepts() diperiksa.
File berhasil diupload tetapi preview rusak
previewBase di-resolve di server satu kali, sehingga browser tidak menyusun URL berdasarkan disk name:
rtrim(Storage::disk($this->disk)->url('/'), '/');| Gejala | Penyebab |
|---|---|
| Nama file tampil sebagai plain text tanpa link | url() melempar RuntimeException, sehingga previewBase menjadi null—jawaban yang benar untuk driver yang tidak melayani file melalui URL |
Link atau <img> menghasilkan 404 | previewBase berhasil dibuat tetapi tidak ada service yang melayani path tersebut |
Kasus kedua paling umum dan biasanya memiliki dua penyebab. Pada disk public, php artisan storage:link belum dijalankan pada release saat ini—pada deployment berbasis release directory, symlink berada di dalam release sehingga harus dibuat ulang. Pada disk local, tidak selalu ada exception: local disk tanpa config url dapat fallback ke /storage/{path}, yang pada aplikasi standar sebenarnya merupakan URI untuk disk public. Akibatnya upload ke local dapat menghasilkan previewBase yang menunjuk ke lokasi yang tidak memiliki file. Gunakan public untuk upload yang perlu dipreview, atau disk dengan url nyata.
Pada remote disk, periksa visibility. Endpoint menyimpan melalui $file->store($directory, $disk) tanpa menetapkan visibility, sehingga object menggunakan default disk. Private bucket dengan public previewBase menghasilkan preview 403.
File tidak pernah mencapai endpoint
Upload dilakukan satu file per request tanpa chunking, sehingga ukuran file tunggal terbesar harus dapat melewati PHP dan web server terlebih dahulu:
| Limit | Minimal |
|---|---|
upload_max_filesize, post_max_size | setidaknya sebesar maxSize() terbesar yang Anda deklarasikan, dalam KB |
nginx client_max_body_size | setidaknya sama |
PandaPanel\Actions\ImportAction mendeklarasikan file field dengan maxSize(20480), sehingga panel yang menggunakan import membutuhkan headroom 20 MB walaupun field lain lebih kecil. File yang masih di bawah limit field tetapi melebihi post_max_size ditolak oleh ValidatePostSize sebelum controller dijalankan: response 413 dan field hanya melaporkan upload gagal.
Upload untuk import
Import menggunakan FileUpload biasa yang diarahkan ke storage milik importer, dideklarasikan di ImportAction::form():
FileUpload::make('file')
->label('File')
->disk($importer::disk())
->directory($importer::directory())
->acceptedTypes([
'text/csv', 'text/plain', 'application/csv',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/zip',
])
->maxSize(20480)
->required();2
3
4
5
6
7
8
9
10
Tiga kegagalan khusus import:
404That file is no longer there.saat submit. Upload berhasil, kemudian file dihapus—termasuk oleh submit sebelumnya dari dialog yang sama, karena file upload dihapus setelah import sukses.- Validation error pada
fileyang menyebut missing column. Importer membutuhkan column yang heading-nya tidak ada di file. Validasi terjadi sebelum satu row pun dibaca dan upload dihapus, sehingga upload ulang file yang sudah diperbaiki. fopen(...): Failed to open streamdari worker. Disk importer harus dapat memberikan local filesystem path:ImportActionmembacaStorage::disk($importer::disk())->path($stored)lalu memberikannya kefopen()atauZipArchive. Driver yangpath()-nya bukan readable filesystem path tidak dapat digunakan untuk import.
Hal yang perlu diperhatikan
- Single-file field menonaktifkan input setelah memiliki satu file.
atLimittrue ketika field bukanmultiple(), sehingga picker nonaktif sampai Remove ditekan. Hal yang sama berlaku pada fieldmultiple()ketika mencapaimaxFiles(). - Client size check berjalan lebih dulu dan per file. File oversized dalam multi-select dilaporkan dan dilewati, sedangkan file lainnya tetap diupload.
- Input dibersihkan setiap kali berubah, sehingga memilih file yang sama dua kali berturut-turut tetap menghasilkan event change.
accepts()mengakses disk untuk setiap submitted path. Pada remote disk berarti satu round trip per path, per save.419berarti session bermasalah, bukan file. Token berasal dari meta tagcsrf-token, dengan fallback ke cookieXSRF-TOKEN; halaman yang dibiarkan melewati session lifetime tidak lagi memiliki token valid.500darinoCurrentPanel()berarti route dicapai di luar panel. Route milik panel selalu resolve current panel; route manual belum tentu.pageadalah allowlist. Value tidak dikenal menghasilkan422, tidak pernah fallback kecreate; fallback ke create akan menjadi branch tanpa record dan membuka kemungkinan melewati check yang seharusnya.Storage::fake()dapat digunakan untuk seluruh flow ini. Semua path merupakan disk write standar, sehingga test dengan fake disk tidak menyentuhstorage/nyata.