Kegagalan import dan export
Export yang tidak pernah selesai, import dengan seluruh row gagal, download yang menghasilkan 404, atau file yang tidak dapat dibuka spreadsheet. Setiap kondisi tersebut memiliki penyebab dan perbaikan yang berbeda, dan sebagian besar ditentukan oleh static method pada Exporter atau Importer milik aplikasi Anda. Gunakan halaman ini ketika file tidak muncul, tidak berhasil diimpor, atau hasilnya tidak berisi data yang Anda harapkan.
Mulai dari sini
Sebagian besar masalah dapat dipersempit menjadi tiga pertanyaan: apakah proses masuk queue, apakah worker berjalan, dan row mana yang gagal.
use App\Panels\Admin\Resources\Users\UserExporter;
use App\Panels\Admin\Resources\Users\UserImporter;
UserExporter::queueAfter(); // 2000 by default — above this, the export is a job
UserImporter::queueAfter(); // 500
UserExporter::disk(); // 'local'
UserExporter::directory(); // 'panel-exports'2
3
4
5
6
7
php artisan queue:work # a queued export or import needs one
php artisan queue:failed # what died, with the exception
ls storage/app/private/panel-exports # or wherever the disk points2
3
Jalur eksekusi yang digunakan
| Record / row | Perilaku | Hasil diterima user sebagai |
|---|---|---|
≤ queueAfter() | ditulis di dalam request | flash toast dengan link Download, serta persisted notification |
> queueAfter() | di-dispatch sebagai job | persisted notification ketika job selesai |
queueAfter() mengembalikan 0 | selalu masuk queue | sama seperti di atas |
queueAfter() mengembalikan angka negatif | tidak pernah masuk queue | diproses di request, berapa pun ukurannya |
Jumlah dihitung sebelum apa pun ditulis — $query->count() untuk export list, atau ImportRun::countRows() untuk import — karena jumlah tersebut yang menentukan apakah request harus menunggu atau pekerjaan dialihkan ke worker.
Export kecil yang dikirim ke background job dapat memberikan pengalaman lebih buruk daripada menunggu beberapa saat. Sebaliknya, export besar di dalam request berisiko timeout. Karena itu threshold menggunakan angka, bukan sekadar flag boolean.
Tidak ada hasil yang pernah tiba
Gejala. Dialog tertutup, toast mengatakan import sudah dimulai, lalu tidak terjadi apa pun.
Penyebab yang hampir selalu terjadi. Queue worker tidak berjalan. RunPanelExport dan RunPanelImport adalah queued job biasa.
php artisan queue:workKedua job memang dikonfigurasi berbeda:
| Job | $tries | backoff() | Alasan |
|---|---|---|---|
PandaPanel\Jobs\RunPanelExport | 3 | [10, 60] | export hanya membaca row dan menulis file, sehingga run yang berhenti di tengah belum mengubah data aplikasi |
PandaPanel\Jobs\RunPanelImport | 1 | — | import menulis row; jika gagal di tengah, sebagian data mungkin sudah tertulis dan retry otomatis dapat menggandakan hasil import yang buruk |
Keduanya mengimplementasikan failed(), sehingga kegagalan sebenarnya dikirim sebagai notification dan tidak berakhir sebagai proses yang diam:
Export failed — The file could not be written.
Import failed — That file is not a readable spreadsheet.2
RunPanelImport::failed() juga menghapus file yang di-upload. Tanpa cleanup tersebut, file berisi data customer dapat tertinggal selamanya di disk walaupun tidak ada lagi proses yang akan membacanya.
Semua row gagal dengan pesan yang sama
Gejala. Pesan seperti "The email field is required" muncul sepuluh ribu kali.
Penyebab. File tidak memiliki kolom untuk field yang wajib, sehingga setiap row gagal dengan alasan identik. Itu menghasilkan ribuan pesan yang secara teknis benar tetapi tidak menjelaskan masalah sebenarnya dengan baik.
Sekarang import berhenti sebelum membaca satu row pun dan mengembalikan validation error pada file field:
This file has no column for [email], and it is required. Its headings are: name, e-mail,
company. Rename the column in the file, or map it by hand before importing.2
use PandaPanel\Actions\Imports\ImportRun;
$headings = ImportRun::headings($localPath);
$mapping = ImportRun::guessMapping(UserImporter::class, $headings);
ImportRun::unmappedRequiredColumns(UserImporter::class, $mapping);
// ['email']
ImportRun::missingColumnsMessage(['email'], $headings);
// 'This file has no column for [email], and it is required. …'2
3
4
5
6
7
8
9
10
Perbaikannya: ubah nama heading di file agar dapat dikenali, tambahkan alternatif heading pada deklarasi column, atau pilih mapping secara manual sebelum import.
use PandaPanel\Actions\Imports\ImportColumn;
ImportColumn::make('email')
->label('Email address')
->guess(['e-mail', 'e-mail address', 'mail'])
->required()
->rules(['email', 'unique:users,email']);2
3
4
5
6
7
headings() pada sebuah column menghasilkan [name, label, ...guesses] yang sudah di-lowercase dan trim. Artinya, column bernama email dengan label Email address otomatis dapat mengenali kedua heading tersebut bahkan tanpa guess() tambahan.
Hanya beberapa row yang gagal
Ini adalah hasil yang memang diharapkan, bukan kegagalan import secara keseluruhan. Jika file memiliki seribu row dan row ke-400 memiliki tanggal tidak valid, seharusnya 999 row lain tetap dapat diimpor dan satu kegagalan tersebut dilaporkan.
- Setiap row memiliki transaction sendiri. Satu row yang buruk tidak boleh membatalkan row valid sebelumnya. Jika sebuah row membuat related record lalu gagal, related record tersebut juga tidak boleh tertinggal.
- Exception pada satu row adalah failed row, bukan failed import. Pesan exception disimpan di report bersama data yang menyebabkannya.
- Failure report selalu CSV, apa pun format upload-nya. Report dibuat untuk diperbaiki lalu di-upload ulang, dan CSV dapat dibuka hampir di semua spreadsheet tool.
- Report disimpan sebagai
{importer::directory()}/{user key}/failed-rows-{date}.csvdan ditautkan dari notification, karena informasi "412 dari 500 row berhasil" tanpa link ke 88 row yang gagal belum benar-benar menyelesaikan masalah user.
use PandaPanel\Actions\Imports\ImportRun;
ImportRun::run(UserImporter::class, $localPath, $mapping, $owner);
// ['imported' => 412, 'failed' => 88, 'report' => 'failed-rows-2026-08-16-101500.csv']2
3
4
Row masuk ke kolom yang salah
Mapping berbentuk column name => zero-based position in the file. Ada dua sumber mapping dan urutannya penting:
ImportRun::guessMapping()mencocokkan setiapheadings()milik column terhadap baris pertama file.- Pilihan mapping yang dipilih user di dialog menimpa hasil tebakan untuk column yang dipilih secara eksplisit.
Pilihan manual tidak pernah ditimpa oleh heading yang kebetulan cocok. Column tanpa kecocokan tidak dimasukkan ke mapping, bukan diarahkan ke posisi nol. Inilah yang mencegah kolom pertama file masuk ke setiap field yang tidak memiliki mapping.
Option pada select ditampilkan sebagai huruf kolom spreadsheet (A, B, … Z, AA) daripada angka, karena "C" mudah ditemukan secara visual di file sedangkan "2" tidak. Daftar manual dibatasi sampai 200 kolom; file yang lebih lebar tetap dapat diimpor karena heading matching tidak memiliki batas tersebut.
Download menghasilkan 404
Kedua endpoint download menerima nama file, bukan path. Directory selalu dibangun berdasarkan user yang sedang meminta:
GET /{panel}/exports/{file}?exporter=App\Panels\Admin\Resources\Users\UserExporter
GET /{panel}/imports/{file}?importer=App\Panels\Admin\Resources\Users\UserImporter2
| Route name | Controller | Menghasilkan 404 ketika |
|---|---|---|
panel.{id}.export-file | PanelExportController | nama mengandung /, \, atau ..; query parameter exporter hilang atau bukan subclass Exporter; file tidak berada di directory milik user ini |
panel.{id}.import-file | PanelImportController | aturan yang sama, dengan importer |
route($panel->routeName('export-file'), [
'file' => 'users-2026-08-16-101500.csv',
'exporter' => UserExporter::class,
], absolute: false);2
3
4
Path sebenarnya adalah {directory()}/{$user->getAuthIdentifier()}/{file}, sehingga caller tidak pernah dapat menentukan directory sendiri. Desain ini sekaligus mencegah path traversal dan mencegah satu user mendownload export milik user lain hanya dengan menebak filename.
Guest mendapatkan 403. Kondisi lain yang tidak valid menghasilkan 404.
File terbuka sebagai teks rusak atau menjalankan formula
| Gejala | Penyebab | Perbaikan |
|---|---|---|
| Karakter beraksen rusak di Excel | BOM hilang | seharusnya tidak perlu — Csv::open() menulis BOM; periksa apakah file diubah setelah dibuat |
Cell menampilkan '=SUM(A1) dengan apostrophe di depan | formula neutralisation bekerja sesuai desain | Exporter::escapesFormulas() dapat mengembalikan false hanya untuk feed yang akan dibaca program |
| Spreadsheet menjalankan sesuatu dari text field | export dibuat dengan formula escaping dimatikan | aktifkan kembali |
Cell CSV yang dimulai dengan =, +, -, @, tab, atau carriage return dianggap formula oleh Excel, LibreOffice, dan Google Sheets, lalu dievaluasi saat file dibuka. Penyerang cukup seseorang yang dapat menulis nilai text field; korbannya adalah administrator yang membuka export. Escaping menambahkan apostrophe di depan value, yang dipahami spreadsheet sebagai "ini text" dan tidak ditampilkan sebagai bagian dari content.
public static function escapesFormulas(): bool
{
return false; // only for a file another program parses
}2
3
4
Jangan mematikannya untuk file yang akan dibuka manusia. XLSX tidak terpengaruh oleh konfigurasi ini karena writer menulis cell sebagai t="inlineStr". Formula pada format XLSX berada di element <f>, dan writer PandaBear tidak pernah menghasilkan element tersebut.
API exporter
Semua method bersifat static karena export tidak membutuhkan state antar-row yang tidak dapat direkonstruksi dari query. Exporter menggunakan class, bukan closure, karena queued job berjalan pada process berbeda dan hanya membawa nama class.
use Illuminate\Database\Eloquent\Builder;
use PandaPanel\Actions\Enums\SpreadsheetFormat;
use PandaPanel\Actions\Exports\ExportColumn;
use PandaPanel\Actions\Exports\Exporter;
final class UserExporter extends Exporter
{
/** @return list<ExportColumn> */
public static function columns(): array
{
return [
ExportColumn::make('name')->label('Name'),
ExportColumn::make('email')->label('Email'),
ExportColumn::make('company.name')->label('Company')->enabledByDefault(false),
];
}
public static function query(Builder $query): Builder
{
return $query->with('company');
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| Method | Signature | Default |
|---|---|---|
columns | abstract static columns(): array | — |
query | static query(Builder $query): Builder | query tidak diubah |
fileName | static fileName(): string | basename class dalam kebab-case + -Y-m-d-His |
disk | static disk(): string | 'local' |
directory | static directory(): string | 'panel-exports' |
formats | static formats(): array | [SpreadsheetFormat::Csv, SpreadsheetFormat::Xlsx] |
escapesFormulas | static escapesFormulas(): bool | true |
chunkSize | static chunkSize(): int | 500 |
queueAfter | static queueAfter(): int | 2000 |
completedMessage | static completedMessage(int $records): string | 'Your export of N records is ready.' |
Eager load harus diletakkan di query(). Export sepuluh ribu row dengan relation column dapat menghasilkan sepuluh ribu query tambahan jika relation tidak di-eager-load. Berbeda dengan list screen, tidak ada table renderer yang akan membantu menutupi N+1 tersebut.
disk() default menggunakan local, bukan public, secara sengaja. Export adalah salinan record yang hanya boleh dilihat user tertentu. Menaruhnya di public disk akan menghasilkan URL yang dapat ditebak siapa pun. Download selalu melewati panel agar authorization diperiksa kembali.
API importer
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Imports\ImportColumn;
use PandaPanel\Actions\Imports\Importer;
final class UserImporter extends Importer
{
public static function model(): string
{
return App\Models\User::class;
}
/** @return list<ImportColumn> */
public static function columns(): array
{
return [
ImportColumn::make('name')->required(),
ImportColumn::make('email')->required()->rules(['email']),
ImportColumn::make('company')->relationship('company', 'name')->createRelated(),
];
}
/** @param array<string, mixed> $data */
public static function resolve(array $data): ?Model
{
return App\Models\User::query()->firstOrNew(['email' => $data['email']]);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| Method | Signature | Default |
|---|---|---|
model | abstract static model(): string | — |
columns | abstract static columns(): array | — |
resolve | static resolve(array $data): ?Model | new $model — insert baru |
rules | static rules(): array | [] — rule level row di atas rule masing-masing column |
chunkSize | static chunkSize(): int | 200 |
queueAfter | static queueAfter(): int | 500 |
disk | static disk(): string | 'local' |
directory | static directory(): string | 'panel-imports' |
completedMessage | static completedMessage(int $imported, int $failed): string | 'Imported N rows.', atau pesan yang sama ditambah kalimat mengenai report |
resolve() menentukan apakah sebuah row melakukan insert atau update. Mengembalikan existing record berarti update, mengembalikan model baru berarti insert, dan null melewati row tanpa menghitungnya sebagai failure — berguna untuk file yang memang memuat row yang tidak menjadi tanggung jawab importer ini.
ImportColumn
| Method | Signature | Catatan |
|---|---|---|
make | static make(string $name): self | nama menjadi data key sekaligus heading default |
label | label(string $label): self | juga dianggap sebagai heading yang dapat dikenali |
guess | guess(array $guesses): self | heading tambahan, dibandingkan setelah lowercase dan trim |
rules | rules(array $rules): self | ditambahkan setelah required/nullable |
required | required(bool $required = true): self | digunakan oleh unmappedRequiredColumns() |
castUsing | castUsing(Closure $callback): self | mengubah text cell yang sudah di-trim menjadi value |
relationship | relationship(string $relationship, string $column = 'name'): self | me-resolve belongsTo berdasarkan named column |
createRelated | createRelated(bool $create = true): self | membuat related record jika tidak ada yang cocok |
headings | headings(): array | [name, label, ...guesses], lowercase |
validationRules | validationRules(): array | [required|nullable, ...rules] |
attribute | attribute(Model $model): string | relation column menulis foreign key |
Relation column yang tidak menemukan related record dan tidak diberi createRelated() menghasilkan null. Row kemudian gagal validation pada key yang tidak tersedia. Ini lebih baik daripada menyimpan record yang diam-diam tidak terhubung ke relation apa pun.
Membaca file secara langsung
use PandaPanel\Actions\Imports\ImportRun;
ImportRun::headings('/path/to/file.csv'); // list<string> — the first row only
ImportRun::countRows('/path/to/file.xlsx'); // int, not counting the header2
3
4
| Method | Signature |
|---|---|
headings | static headings(string $path): array |
countRows | static countRows(string $path): int |
guessMapping | static guessMapping(string $importer, array $headings): array |
unmappedRequiredColumns | static unmappedRequiredColumns(string $importer, array $mapping): array |
missingColumnsMessage | static missingColumnsMessage(array $missing, array $headings): string |
run | static run(string $importer, string $path, array $mapping, int|string $owner): array |
Untuk sisi penulisan export:
use PandaPanel\Actions\Enums\SpreadsheetFormat;
use PandaPanel\Actions\Exports\ExportRun;
ExportRun::write(UserExporter::class, UserResource::query(), ['name', 'email'], SpreadsheetFormat::Csv, $userKey);
// ['path' => 'panel-exports/1/users-….csv', 'file' => 'users-….csv', 'records' => 1204]2
3
4
5
SpreadsheetFormat adalah closed enum — Csv dan Xlsx — karena setiap case dipetakan ke reader dan writer yang memang disediakan package:
| Member | Signature | Nilai |
|---|---|---|
label | label(): string | CSV, Excel (XLSX) |
extension | extension(): string | csv, xlsx |
mimeTypes | mimeTypes(): array | daftar MIME yang digunakan untuk validasi upload |
fromPath | static fromPath(string $path): self | berdasarkan extension — merupakan klaim dari filename, bukan pembuktian content |
Exception yang mungkin muncul
| Exception | Message | Penyebab |
|---|---|---|
PandaPanel\Support\Spreadsheet\SpreadsheetException | That file is not a readable spreadsheet. | XLSX tidak dapat dibuka oleh ZipArchive |
That spreadsheet is too large to read safely. | bagian XML XLSX melampaui safety cap reader | |
That spreadsheet could not be read. | bagian di dalam archive bukan XML yang dapat diparse | |
That workbook has no readable sheet. | tidak ada worksheet part yang dapat dibaca | |
Cannot write to /tmp/… / Cannot read … | masalah filesystem pada export atau report writer | |
PandaPanel\Exceptions\PanelSchemaException | An exporter declares more than one column named [email]… | dua ExportColumn menggunakan nama yang sama; column picker menggunakan nama sebagai key sehingga memilih satu berarti memilih keduanya |
Illuminate\Validation\ValidationException | This file has no column for [email]… | required column tidak memiliki heading yang cocok di file |
Catatan
- Disk milik exporter dan importer harus dapat menjawab
path(). Reader membutuhkan real filesystem path: CSV membaca stream dari handle dan XLSX membuka zip berdasarkan path. Import membaca file melaluiStorage::disk(...)->path($stored). Disk yang tidak dapat menyediakan local path tidak dapat digunakan untuk import. - File upload dihapus setelah import, baik saat berhasil maupun gagal. Upload adalah sarana pemrosesan, bukan record permanen.
- Queued export membangun ulang query dari table state melalui
TableQueryyang sama dengan list page, sehingga file berisi row yang sedang terfilter/search di screen, bukan semua row yang dapat dilihat resource. Bulk export membawa key secara eksplisit. - Urutan column mengikuti exporter, bukan urutan request. File yang urutan kolomnya berubah hanya karena checkbox diklik dalam urutan berbeda akan sulit dibandingkan dengan export sebelumnya.
- Pilihan column kosong berarti export semua column, bukan menghasilkan file tanpa kolom.
- Queued export dan import membawa panel id tetapi tidak membawa tenant. Pada single-database tenancy dengan resource yang di-scope, proses queued akan melempar exception. Kembalikan nilai negatif dari
queueAfter()agar tetap di request, atau dispatch job Anda sendiri. Lihat Kebocoran tenant scope. Auth::getProvider()->retrieveById()digunakan kedua job untuk menemukan recipient. Jika user dihapus saat job masih menunggu di queue, notification tidak dikirim tetapi pekerjaan tetap dapat selesai.- Dukungan XLSX membutuhkan
ext-zip, dan extension ini merupakan hard requirement dicomposer.jsonkarena file XLSX memang berupa zip archive.