Class Importer
PandaPanel\Actions\Imports\Importer mendefinisikan apa itu sebuah import: model tujuan, sekumpulan kolom, dan cara sebuah row diubah menjadi record. Anda membuat satu importer untuk setiap jenis data yang akan diimport, kemudian memberikan nama class-nya ke ImportAction.
Importer dibuat sebagai class dengan alasan yang sama seperti exporter: queued import berjalan di proses yang berbeda dari request yang meng-upload file, dan hanya nama class yang dapat dibawa melewati batas tersebut. Seluruh method pada importer bersifat static.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Users\Imports;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Imports\ImportColumn;
use PandaPanel\Actions\Imports\Importer;
final class UserImporter extends Importer
{
/**
* @return class-string<Model>
*/
public static function model(): string
{
return User::class;
}
/**
* @return list<ImportColumn>
*/
public static function columns(): array
{
return [
ImportColumn::make('name')->required()->rules(['string', 'max:255']),
ImportColumn::make('email')->required()->rules(['email', 'max:255']),
];
}
}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
28
29
30
31
32
Dua abstract method tersebut sudah cukup untuk membuat import yang berfungsi: setiap row dengan nama dan alamat email yang valid menjadi User baru; row yang tidak memenuhi ketentuan masuk ke failure report.
Semua method
| Method | Signature | Default |
|---|---|---|
model | abstract public static function model(): string | — wajib, class-string<Model> |
columns | abstract public static function columns(): array | — wajib, list<ImportColumn> |
resolve | public static function resolve(array $data): ?Model | new $model — insert |
rules | public static function rules(): array | [] |
chunkSize | public static function chunkSize(): int | 200 — dideklarasikan, tetapi tidak dibaca oleh apa pun |
queueAfter | public static function queueAfter(): int | 500 |
disk | public static function disk(): string | 'local' |
directory | public static function directory(): string | 'panel-imports' |
completedMessage | public static function completedMessage(int $imported, int $failed): string | Imported {n} rows., atau pesan yang menyebut jumlah kegagalan |
model()
public static function model(): string
{
return User::class;
}2
3
4
Model tempat row akan ditulis. Digunakan dua kali: untuk membuat instance kosong yang dipakai ImportRun saat me-resolve relation dan nama attribute, serta sebagai return default dari resolve(). Record yang menerima forceFill() adalah apa pun yang dikembalikan resolve(), sehingga secara teknis record tersebut tidak harus merupakan instance dari class yang dikembalikan model().
columns()
Menentukan ke mana setiap cell ditulis dan syarat yang harus dipenuhinya. Seluruh setter ImportColumn dibahas di Kolom dan mapping; versi singkatnya:
use PandaPanel\Actions\Imports\ImportColumn;
public static function columns(): array
{
return [
ImportColumn::make('name')
->guess(['full name', 'user'])
->required()
->rules(['string', 'max:255']),
ImportColumn::make('email')
->guess(['e-mail', 'email address'])
->required()
->rules(['email', 'max:255'])
->castUsing(static fn (string $value): string => mb_strtolower(trim($value))),
ImportColumn::make('company')
->relationship('company', 'name')
->createRelated(),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Daftar kolom sekaligus menjadi write whitelist: row ditulis dengan forceFill(), sehingga kolom yang tidak Anda deklarasikan tidak dapat diisi dari file, dan $fillable tidak menentukan apakah kolom yang dideklarasikan boleh ditulis. Password adalah contoh paling jelas dari kolom yang sebaiknya tidak disertakan — password yang datang melalui spreadsheet berarti password tersebut pernah berada di dalam spreadsheet.
resolve()
Method yang mengubah proses import menjadi re-import yang aman.
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @param array<string, mixed> $data the row, already cast and with relations resolved
*/
public static function resolve(array $data): ?Model
{
$email = $data['email'] ?? null;
if (! is_string($email) || $email === '') {
return null;
}
$user = User::query()->where('email', $email)->first();
if ($user !== null) {
return $user; // an update
}
// Only for a new account: an existing one keeps the password it has,
// which a re-upload must not reset.
return (new User)->forceFill([
'password' => Hash::make(Str::random(32)),
'email_verified_at' => null,
]);
}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
28
| Return | Arti |
|---|---|
| model yang sudah ada | row menjadi update |
| model baru | row menjadi insert |
null | row dilewati — tidak ada data yang ditulis, dan row dihitung ke imported, bukan failed |
$data menggunakan nama kolom sebagai key dan berisi nilai setelah cast() serta setelah relation column diubah menjadi foreign key. Artinya, kolom company sampai ke resolve() sebagai integer key, bukan teks yang semula berada di cell.
Return null tersedia untuk file yang memang sah berisi row yang bukan bagian dari importer ini, misalnya mixed export dengan beberapa jenis record. Ini bukan mekanisme untuk menolak row yang buruk — row yang buruk seharusnya masuk ke report, dan validation yang menempatkannya di sana.
Mencocokkan record yang sudah ada membuat failure report benar-benar berguna: perbaiki empat row yang disebut report, upload ulang file yang sama, dan row yang sebelumnya sudah berhasil akan di-update, bukan dibuat duplikat.
rules()
Rule yang diterapkan pada keseluruhan row, di atas rule milik masing-masing kolom.
public static function rules(): array
{
return [
'sku' => ['required_without:barcode'],
'barcode' => ['required_without:sku'],
];
}2
3
4
5
6
7
Gunakan untuk validasi yang menyangkut hubungan antar-cell dalam satu row — misalnya "salah satu dari dua field ini harus ada" atau uniqueness rule yang melibatkan beberapa kolom. Rule ini di-merge setelah per-column rules, sehingga key yang sama di sini menggantikan entry rule milik kolom tersebut.
Rule yang digunakan adalah rule Laravel biasa dan dijalankan per row dengan Validator::make(). Itu adalah keseluruhan security model sebuah import: file tetap merupakan request input seperti input lainnya, dan fakta bahwa data datang dalam bentuk spreadsheet tidak membuatnya dapat dipercaya.
chunkSize()
public static function chunkSize(): int
{
return 500;
}2
3
4
Method ini dideklarasikan dengan default 200, tetapi saat ini tidak ada bagian package yang membacanya. ImportRun men-stream file satu row pada satu waktu melalui generator dan menyimpan setiap row dalam transaction terpisah, sehingga import memang hanya menahan satu row pada satu waktu apa pun nilai method ini. Mengoverride-nya tidak mengubah behavior. Exporter::chunkSize() adalah versi yang benar-benar digunakan oleh ->lazy().
queueAfter()
public static function queueAfter(): int
{
return 0; // always queue this one
}2
3
4
Jika jumlah row melebihi nilai ini, import didispatch ke PandaPanel\Jobs\RunPanelImport daripada dijalankan dalam request. File benar-benar dibaca untuk menghitung row terlebih dahulu, bukan diperkirakan — estimasi dapat menempatkan file besar di request atau file kecil di balik queue yang tidak sedang diawasi.
| Nilai | Behavior |
|---|---|
0 | selalu masuk queue |
500 | default — masuk queue jika lebih dari 500 row |
| angka negatif apa pun | tidak pernah masuk queue |
disk() dan directory()
public static function disk(): string
{
return 'local';
}
public static function directory(): string
{
return 'panel-imports';
}2
3
4
5
6
7
8
9
File upload dan failure report sama-sama berada di sini, tetapi disimpan dengan struktur berbeda:
| File | Path |
|---|---|
| upload | {directory}/{random}.{ext} |
| failure report | {directory}/{ownerKey}/failed-rows-{Y-m-d-His}.csv |
Disk harus berupa disk local. Reader menerima Storage::disk($importer::disk())->path($stored) lalu membukanya menggunakan fopen() atau ZipArchive; driver yang path()-nya tidak menghasilkan filesystem path yang dapat dibaca tidak dapat digunakan untuk import.
completedMessage()
public static function completedMessage(int $imported, int $failed): string
{
if ($failed === 0) {
return sprintf('%d products updated.', $imported);
}
return sprintf('%d products updated, %d rows rejected.', $imported, $failed);
}2
3
4
5
6
7
8
Menjadi title notification sekaligus teks toast, baik untuk jalur inline maupun queued. Default-nya sudah menyampaikan informasi yang berguna ketika ada row gagal: "Imported 998 rows. 2 could not be imported — download the report to see why."
Apa yang dilakukan setiap row
PandaPanel\Actions\Imports\ImportRun::run() membaca file dan, untuk setiap row, menjalankan langkah berikut di dalam transaction milik row itu sendiri:
- membaca cell yang sudah dimapping untuk setiap kolom yang dideklarasikan, atau
''jika kolom tidak dimapping; - melakukan cast melalui
ImportColumn::cast(); - me-resolve relation column menjadi foreign key melalui
ImportColumn::resolveRelated(); - memvalidasi row hasil assembly menggunakan
validationRules()setiap kolom ditambahImporter::rules(); - memanggil
Importer::resolve(), lalu melanjutkan ke row berikutnya jika return-nyanull; - melakukan
forceFill()attribute ke record — menggunakanImportColumn::attribute(), sehingga relation column menuliscompany_id— kemudian menyimpannya.
Row yang gagal validation dicatat bersama seluruh pesan validator yang digabungkan. Row yang melempar exception dicatat menggunakan pesan exception. Keduanya tidak menghentikan import: file seribu row yang memiliki tanggal rusak pada row ke-400 tetap mengimpor 999 row dan menulis sisanya ke failure report.
Setiap row memiliki transaction sendiri agar satu row yang gagal tidak membatalkan row yang sebelumnya berhasil, dan tidak meninggalkan related record yang sempat dibuat sebelum row tersebut gagal di tengah proses.
Menjalankan importer tanpa action
use App\Panels\Admin\Resources\Users\Imports\UserImporter;
use PandaPanel\Actions\Imports\ImportRun;
$path = storage_path('app/private/panel-imports/people.csv');
$result = ImportRun::run(
UserImporter::class,
$path, // an absolute filesystem path
ImportRun::guessMapping(UserImporter::class, ImportRun::headings($path)),
$user->getKey(), // whose report directory
);
// ['imported' => 998, 'failed' => 2, 'report' => 'failed-rows-2026-08-15-114233.csv']2
3
4
5
6
7
8
9
10
11
12
13
Helper public yang tersedia di sekitarnya:
ImportRun::headings(string $path): array; // list<string>, the first row only
ImportRun::countRows(string $path): int; // rows, not counting the header
ImportRun::guessMapping(string $importer, array $headings): array; // column name => position
ImportRun::unmappedRequiredColumns(string $importer, array $mapping): array; // list<string>
ImportRun::missingColumnsMessage(array $missing, array $headings): string;
ImportRun::run(string $importer, string $path, array $mapping, int|string $owner): array;2
3
4
5
6
run() adalah kode yang sama dengan yang dipanggil queued job. Import yang tiba-tiba berperilaku berbeda hanya karena ukuran file membesar adalah bug yang baru akan ditemukan ketika dampaknya sudah penting.
Catatan
resolve()menerima row, bukan record. Method dipanggil setelah validation, sehingga semua nilai yang dibacanya sudah melewati rule kolom.- Row yang dilewati tetap dihitung sebagai imported.
run()menaikkanimporteduntuk setiap row yang tidak menghasilkan alasan untuk ditolak, termasuknulldariresolve(). Jadi counter berarti "row yang diterima importer", bukan "record yang ditulis". File 500 row dengan 300 row yang bukan bagian importer tetap dapat melaporkan 500 imported. SesuaikancompletedMessage()jika itu dapat menyesatkan. - Relation column hanya mendukung
BelongsTo. Hanya relation jenis ini yang nilainya merupakan kolom pada row yang sedang diimport.hasManytidak dapat ditetapkan dari satu cell. - Importer tidak pernah menerima heading file. Mapping adalah tanggung jawab action;
run()hanya menerima posisi. - File upload dihapus setelah proses selesai, baik saat berhasil maupun gagal. File tersebut adalah sarana proses, bukan record yang perlu disimpan.