Kolom dan Mapping
Kolom adalah unit terkecil dalam proses import atau export: saat data keluar, kolom mengubah sebuah record menjadi satu string; saat data masuk, kolom menentukan ke mana sebuah cell akan ditempatkan dan aturan apa yang harus dipenuhinya. Mapping adalah langkah penghubung pada sisi import — menentukan kolom mana dari file ini yang akan mengisi kolom mana pada importer.
Halaman ini membahas PandaPanel\Actions\Exports\ExportColumn dan PandaPanel\Actions\Imports\ImportColumn secara lengkap, kemudian menjelaskan langkah mapping yang menghubungkan file yang di-upload dengan ImportColumn.
Contoh minimal yang berfungsi
use PandaPanel\Actions\Exports\ExportColumn;
use PandaPanel\Actions\Imports\ImportColumn;
// Out: the value at `author.name`, headed "Author".
ExportColumn::make('author.name')->label('Author');
// In: column "E-mail Address" of the file, lowercased, required, validated.
ImportColumn::make('email')
->label('Email')
->guess(['e-mail', 'email address'])
->required()
->rules(['email', 'max:255'])
->castUsing(static fn (string $value): string => mb_strtolower(trim($value)));2
3
4
5
6
7
8
9
10
11
12
13
Keduanya merupakan class yang terpisah karena melakukan pekerjaan yang berlawanan, dan keduanya juga bukan table column. Table column mengetahui cara melakukan sorting, searching, dan merender HTML; menggunakannya kembali untuk spreadsheet justru akan membawa detail seperti warna badge dan registry key icon ke dalam file spreadsheet.
ExportColumn
use Closure;
use Illuminate\Database\Eloquent\Model;
ExportColumn::make(string $name): self;
ExportColumn::label(string $label): self;
ExportColumn::formatUsing(Closure $callback): self; // fn (mixed $value, Model $record): mixed
ExportColumn::enabledByDefault(bool $enabled = true): self;
ExportColumn::getName(): string;
ExportColumn::getLabel(): string;
ExportColumn::isEnabledByDefault(): bool;
ExportColumn::toCell(Model $record): string;2
3
4
5
6
7
8
9
10
11
12
make()
ExportColumn::make('email');
ExportColumn::make('company.name'); // read with data_get(), so dot notation walks relations2
Nama kosong akan melempar PanelSchemaException::emptyName('export column'). Nama digunakan untuk menemukan nilai sekaligus menjadi key pilihan pada dialog, sehingga nilainya tidak boleh kosong.
label()
ExportColumn::make('created_at')->label('Joined');Label menjadi heading yang ditulis ke file sekaligus teks di samping checkbox pada dialog. Jika tidak ditentukan, nilainya berasal dari Str::headline() terhadap nama kolom dengan titik diubah menjadi spasi — company.name menjadi Company Name.
formatUsing()
use Illuminate\Database\Eloquent\Model;
ExportColumn::make('total')
->formatUsing(static fn (mixed $value, Model $record): string => number_format((float) $value, 2));
ExportColumn::make('status')
->formatUsing(static fn (mixed $value, Model $record): string => $record->isLate() ? 'Late' : (string) $value);2
3
4
5
6
7
Callback menerima nilai mentah dan record, lalu dijalankan sebelum nilai tersebut diubah menjadi cell. Callback boleh mengembalikan nilai selain string — proses konversi di bawah tetap akan diterapkan.
enabledByDefault()
ExportColumn::make('internal_note')->enabledByDefault(false);Kolom tetap ditawarkan pada dialog, tetapi tidak dicentang secara default. Ini cocok untuk kolom yang biasanya tidak dibutuhkan saat export, seperti catatan internal, blob besar, atau data yang hanya sesekali diperlukan. Pengaturan ini hanya memengaruhi keadaan awal dialog; pengguna tetap dapat mencentangnya, dan export yang dijalankan melalui ExportRun::write() dengan columns: [] tetap menulis seluruh kolom.
toCell()
Semua nilai diubah menjadi string di sini, bukan di writer, sehingga CSV dan XLSX tidak mungkin menghasilkan representasi boolean atau tanggal yang berbeda.
| Nilai | Cell |
|---|---|
null | '' |
bool | Yes / No |
DateTimeInterface | Y-m-d H:i:s |
| scalar lainnya | di-cast menjadi string |
| array atau object | json_encode() |
$column = ExportColumn::make('is_admin');
$column->toCell($user); // 'Yes'2
3
Jika tanggal memerlukan format lain, gunakan formatUsing():
use Illuminate\Support\Carbon;
ExportColumn::make('created_at')
->formatUsing(static fn (mixed $value): string => $value instanceof Carbon ? $value->toDateString() : '');2
3
4
ImportColumn
use Closure;
use Illuminate\Database\Eloquent\Model;
ImportColumn::make(string $name): self;
ImportColumn::label(string $label): self;
ImportColumn::guess(array $guesses): self; // list<string>
ImportColumn::rules(array $rules): self; // list<mixed>
ImportColumn::required(bool $required = true): self;
ImportColumn::castUsing(Closure $callback): self; // fn (string $value): mixed
ImportColumn::relationship(string $relationship, string $column = 'name'): self;
ImportColumn::createRelated(bool $create = true): self;
ImportColumn::getName(): string;
ImportColumn::getLabel(): string;
ImportColumn::isRequired(): bool;
ImportColumn::getRelationship(): ?string;
ImportColumn::headings(): array; // list<string>, lowercased
ImportColumn::validationRules(): array;
ImportColumn::cast(string $value): mixed;
ImportColumn::resolveRelated(Model $model, mixed $value): ?int;
ImportColumn::attribute(Model $model): string;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
make() dan label()
ImportColumn::make('email')->label('Email address');Nama menjadi key pada array row yang diterima resolve() dan, kecuali kolom tersebut adalah relation, menjadi attribute yang ditulis ke model. Nama kosong akan melempar PanelSchemaException::emptyName('import column'). Jika label tidak diberikan, nilainya berasal dari Str::headline() terhadap nama — perhatikan bahwa berbeda dari export column, titik tidak diubah menjadi spasi.
guess()
ImportColumn::make('email')->guess(['e-mail', 'email address', 'e-mail address']);Daftar heading tambahan yang dikenali oleh kolom ini. Sebuah file dari sistem lain mungkin memberi nama kolom "E-mail Address"; meminta pengguna mengganti nama tersebut sebelum import berarti meminta manusia mengerjakan sesuatu yang seharusnya dilakukan komputer.
headings() mengembalikan seluruh nama yang dikenali kolom, semuanya diubah ke lowercase, di-trim, dan dihapus duplikasinya: nama kolom, label, dan seluruh nilai dari guess().
ImportColumn::make('email')->label('Email')->guess(['e-mail'])->headings();
// ['email', 'e-mail']2
required() dan rules()
ImportColumn::make('name')->required()->rules(['string', 'max:255']);
ImportColumn::make('notes')->rules(['string', 'max:2000']);2
validationRules() berisi required atau nullable, kemudian diikuti seluruh rule yang diberikan melalui rules():
ImportColumn::make('name')->required()->rules(['string'])->validationRules();
// ['required', 'string']2
required() juga memiliki satu konsekuensi tambahan: kolom required yang akhirnya tidak memiliki mapping akan menghentikan import sebelum satu row pun dibaca. Lihat bagian mapping di bawah.
castUsing()
ImportColumn::make('is_admin')
->castUsing(static fn (string $value): bool => in_array(
mb_strtolower($value),
['1', 'yes', 'y', 'true', 'admin'],
true,
));
ImportColumn::make('starts_at')
->castUsing(static fn (string $value): ?string => $value === ''
? null
: Carbon::parse($value)->toDateTimeString());2
3
4
5
6
7
8
9
10
11
Spreadsheet tidak memiliki tipe data: setiap cell dibaca sebagai string, dan 1, yes, serta TRUE dapat memiliki arti yang sama bagi manusia tetapi tidak otomatis berarti boolean bagi database. Callback menerima nilai cell yang sudah di-trim.
Tanpa callback, cast() akan melakukan trim lalu mengubah string kosong menjadi null:
ImportColumn::make('name')->cast(' Grace '); // 'Grace'
ImportColumn::make('name')->cast(' '); // null2
Casting dijalankan sebelum validation, sehingga rule memeriksa nilai yang benar-benar akan dimiliki kolom.
relationship() dan createRelated()
ImportColumn::make('company')->relationship('company', 'name');
ImportColumn::make('company')->relationship('company', 'name')->createRelated();2
File berisi "Acme Ltd", kolom mendefinisikan bahwa nilai tersebut berarti relation company yang dicocokkan melalui name, kemudian row menerima foreign key hasil pencarian. Deklarasi dilakukan pada kolom agar lookup hanya ditulis sekali dan kegagalan yang sama — company tidak ditemukan — dapat dilaporkan per row, bukan melempar exception di tengah file.
| Method | Signature | Default |
|---|---|---|
relationship | relationship(string $relationship, string $column = 'name'): self | tidak ada; $column default ke name |
createRelated | createRelated(bool $create = true): self | false |
Yang terjadi pada setiap row:
ImportColumn::make('company')->relationship('company')->resolveRelated($blankUser, 'Acme Ltd');
// the company's key as an int, or null2
- Relation harus tersedia pada model dan merupakan
BelongsTo. Relation lain —hasMany, method yang bukan relation, atau nama yang tidak memiliki method — akan menghasilkannulldan kolom kembali menulis menggunakan namanya sendiri sebagai attribute. Hanya relation yang nilainya dapat direpresentasikan sebagai satu kolom pada row import yang sesuai untuk mekanisme ini;hasManytidak dapat ditetapkan dari satu cell. - Nilai
nullatau kosong langsung menghasilkannulltanpa query. - Lookup menggunakan
$related->newQuery()->where($column, $value)->first(). - Dengan
createRelated(), jika tidak ditemukan maka related record dibuat menggunakan[$column => $value]. Fitur ini nonaktif secara default: membuat row baru di tabel lain hanya karena ada typo pada cell adalah cara mengubah satu kesalahan menjadi dua. - Key yang bukan numerik menghasilkan
null. Karena itu relation column mengasumsikan key bertipe integer.
Jika lookup tidak menemukan data dan tidak membuat record baru, nilainya menjadi null dan row akan gagal validation pada key yang tidak diperoleh. Ini lebih baik daripada membuat record yang diam-diam tidak terhubung ke mana pun. Agar pesan kesalahan lebih jelas, tandai kolom sebagai required:
ImportColumn::make('company')->relationship('company')->required();
// "The company field is required." on the rows whose company does not exist2
attribute() menentukan tempat nilai hasil resolve akan ditulis: nama foreign key dari relation jika relation tersebut BelongsTo, atau nama kolom itu sendiri jika bukan. Importer tidak perlu mengetahui bahwa company sebenarnya berarti company_id.
Langkah mapping
Import perlu mengetahui posisi mana dari setiap row yang mengisi setiap kolom. Bentuknya adalah array<string, int> — nama kolom ke posisi zero-based — dan dibangun dari dua sumber.
Menebak dari heading
use PandaPanel\Actions\Imports\ImportRun;
ImportRun::headings(string $path): array; // the first row only
ImportRun::guessMapping(string $importer, array $headings): array; // name => position2
3
4
$headings = ImportRun::headings($path); // ['Full Name', 'E-Mail Address', 'unused']
$mapping = ImportRun::guessMapping(UserImporter::class, $headings);
// ['name' => 0, 'email' => 1]2
3
Setiap heading diubah ke lowercase dan di-trim, lalu setiap kolom mengambil heading pertama dari daftar headings() miliknya yang cocok. Kolom yang tidak menemukan kecocokan tidak dimasukkan ke hasil, bukan diarahkan ke posisi nol — sehingga dialog akan menampilkannya sebagai belum dimapping, bukan salah mengimpor kolom pertama:
ImportRun::guessMapping(UserImporter::class, ['nothing', 'like it']); // []Hanya row pertama dari file yang dibaca, sehingga pertanyaan "kolom apa saja yang ada di file ini" cukup murah untuk dilakukan saat pengguna menunggu.
Mengoreksi mapping secara manual
Dialog import merender satu select untuk setiap kolom dengan nama map_{column}:
Select::make('map_email')->label('Email')->options(/* positions */)->searchable();Options menggunakan posisi spreadsheet, bukan heading dari file, karena form dibangun sebelum file di-upload:
| Key option | Label |
|---|---|
col0 | A |
col25 | Z |
col26 | AA |
col51 | AZ |
col52 | BA |
col199 | GR — posisi terakhir yang ditawarkan |
Dua ratus posisi disediakan. Jumlah ini sudah melewati lebar spreadsheet yang lazim di-import secara manual dan tetap murah untuk dirender melalui searchable select. Label menggunakan bijective base-26, sehingga urutannya A–Z lalu AA — sama seperti header kolom pada spreadsheet, karena "C" mudah ditemukan di file sedangkan "2" tidak.
Nilai submit dibaca dari key: col29 menjadi posisi 29, terlepas dari label yang ditampilkan.
Cara guess dan pilihan manual digabungkan
$mapping = ImportRun::guessMapping($importer, $headings);
foreach ($importer::columns() as $column) {
$chosen = $data['map_'.$column->getName()] ?? null;
if (is_string($chosen) && str_starts_with($chosen, 'col')) {
$mapping[$column->getName()] = (int) mb_substr($chosen, 3);
}
}2
3
4
5
6
7
8
9
Guess mengisi mapping terlebih dahulu, lalu pilihan eksplisit dari pengguna menggantikannya. Pilihan manual tidak pernah ditimpa hanya karena ada heading yang terlihat cocok, dan select kosong tidak pernah diperlakukan sebagai "posisi nol".
Kolom required yang tidak memiliki sumber
ImportRun::unmappedRequiredColumns(string $importer, array $mapping): array;
ImportRun::missingColumnsMessage(array $missing, array $headings): string;2
Sebelum satu row pun dibaca, action memastikan setiap kolom required memiliki posisi. Jika ada yang tidak ditemukan, file upload dihapus dan ValidationException dilempar pada field file:
This file has no column for [email], and it is required. Its headings are: Full Name, Address.
Rename the column in the file, or map it by hand before importing.2
Kolom required tanpa heading yang sesuai seharusnya tidak dibiarkan gagal identik pada setiap row. Jika ada sepuluh ribu row, itu akan menghasilkan sepuluh ribu pernyataan benar tentang masalah yang salah sasaran — masalah sebenarnya adalah file memang tidak memiliki kolom tersebut.
Apa yang terjadi pada kolom optional yang tidak dimapping
Tidak ada sesuatu yang dramatis. Reader mengambil '' untuk kolom tersebut, cast() mengubahnya menjadi null, lalu rules milik kolom menentukan hasil selanjutnya. Secara semantik sama seperti file yang memang tidak memiliki kolom tersebut.
Catatan
- Posisi adalah posisi, bukan heading. File yang urutan kolomnya berubah antar-export perlu dicek mapping-nya lagi; file yang hanya berubah nama heading dapat ditangani dengan
guess(). - Kolom setelah posisi 200 tidak dapat dimapping secara manual, tetapi tetap dapat dimapping otomatis selama heading-nya dikenali — matching heading tidak memiliki batas posisi.
- Cell kosong tidak menggeser posisi row. XLSX reader mengisi gap berdasarkan referensi
rpada setiap cell, sehinggaA1danC1tetap berada pada kolom 0 dan 2. CSV reader melewati baris kosong tetapi tetap mempertahankan cell kosong. - Byte-order mark dihapus dari heading pertama, jika tidak maka kolom pertama akan bernama
\u{FEFF}iddan tidak cocok dengan apa pun. - Dialog export dan mapping import sama-sama menggunakan form field biasa, sehingga seluruh input yang diterimanya divalidasi dengan rule yang sama seperti choice field lainnya.
Lihat juga
- Class exporter dan class importer
- ExportAction dan ImportAction
- CSV dan XLSX — bagaimana sebuah row dibaca sejak awal
- Failure report
- Table columns — jenis kolom yang lain, dan alasan mengapa tidak digunakan di sini
- Validation form