Import dan Export
Recipe ini membahas alur memasukkan dan mengeluarkan Product melalui spreadsheet: exporter, importer, dua Action yang memasangnya pada table, serta endpoint yang mengembalikan file kepada user. Gunakan halaman ini ketika sebuah resource membutuhkan tombol "download as CSV" atau bulk upload. Contoh dibangun di atas Product Resource. UserExporter / UserImporter yang disertakan di examples/ memiliki bentuk yang sama tetapi menggunakan model yang lebih sederhana.
Tidak ada generator untuk exporter maupun importer. Keduanya hanya berupa satu class dengan dua atau tiga static method, sehingga stub generator justru akan lebih panjang daripada implementasi sebenarnya.
Contoh minimal yang berfungsi
// app/Panels/Admin/Resources/Products/Exports/ProductExporter.php
namespace App\Panels\Admin\Resources\Products\Exports;
use PandaPanel\Actions\Exports\ExportColumn;
use PandaPanel\Actions\Exports\Exporter;
final class ProductExporter extends Exporter
{
/**
* @return list<ExportColumn>
*/
public static function columns(): array
{
return [
ExportColumn::make('sku')->label('SKU'),
ExportColumn::make('name'),
ExportColumn::make('stock'),
];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/Panels/Admin/Resources/Products/Tables/ProductsTable.php
use App\Panels\Admin\Resources\Products\Exports\ProductExporter;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Actions\ExportAction;
->headerActions([
ExportAction::make(ProductExporter::class, ProductResource::class),
])2
3
4
5
6
7
8
9
Itu sudah menjadi export yang berfungsi: dialog menawarkan tiga column dan dua format, file ditulis ke disk local, toast menampilkan link download, dan persistent notification membawa link yang sama.
Mengapa exporter dan importer berupa class
Export atau import yang melewati threshold akan diserahkan ke queued job dan dijalankan pada process berbeda dari request yang memintanya. Yang dapat melewati boundary queue dengan aman adalah nama class, bukan closure yang dipasang pada Action. Semua konfigurasi pada exporter/importer bersifat static karena tidak ada state antar-row yang perlu disimpan di luar query atau file.
Exporter
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Exports;
use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Actions\Enums\SpreadsheetFormat;
use PandaPanel\Actions\Exports\ExportColumn;
use PandaPanel\Actions\Exports\Exporter;
final class ProductExporter extends Exporter
{
/**
* The columns offered, in the order they are written.
*
* @return list<ExportColumn>
*/
public static function columns(): array
{
return [
ExportColumn::make('id')->label('ID'),
ExportColumn::make('sku')->label('SKU'),
ExportColumn::make('name'),
// Dot notation reads through relations, so this is a column
// rather than a reason to write a formatter.
ExportColumn::make('category.name')->label('Category'),
ExportColumn::make('price_cents')
->label('Price')
->formatUsing(static fn (mixed $value): string
=> number_format((int) $value / 100, 2, '.', '')),
ExportColumn::make('stock'),
ExportColumn::make('is_published')->label('Published'),
ExportColumn::make('created_at')->label('Added'),
// Offered but unticked: useful occasionally, noise the rest of
// the time.
ExportColumn::make('description')->enabledByDefault(false),
];
}
/**
* Shapes the query the export runs.
*
* The action hands over whatever the table was showing — the resource's
* scope, and the current filters when the export was started from a
* filtered list.
*
* @param Builder<covariant Model> $query
* @return Builder<covariant Model>
*/
public static function query(Builder $query): Builder
{
// Eager load, or a relation column is one query per row. Reordered
// so two exports of the same records can be compared line by line
// regardless of how the list was sorted.
return $query->with('category')->reorder('id');
}
public static function fileName(): string
{
return 'products-'.date('Y-m-d');
}
}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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
Exporter
| Method | Signature | Default |
|---|---|---|
columns() | abstract static columns(): array | wajib |
query() | static query(Builder $query): Builder | query dikembalikan tanpa perubahan |
fileName() | static fileName(): string | basename class dalam kebab-case ditambah -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." |
disk() default ke local, bukan public, secara sengaja. Export merupakan salinan data yang sebelumnya hanya boleh dilihat oleh user tertentu. Menaruhnya pada public disk akan membuat file tersedia melalui URL yang mungkin dapat ditebak. Download endpoint Panel meminta authorization/security boundary kembali sebelum mengembalikan file.
escapesFormulas() menetralkan cell CSV yang jika dibuka oleh spreadsheet dapat dieksekusi sebagai formula. Biarkan aktif untuk file yang akan dibuka manusia. Matikan hanya jika file dibaca program lain yang tidak pernah mengevaluasi formula dan leading apostrophe justru dianggap sebagai corruption.
queueAfter() menggunakan angka, bukan boolean. Export kecil yang dipindahkan ke background dapat menghasilkan UX lebih buruk daripada menunggu sebentar; export besar yang dipaksa selesai dalam request dapat timeout. Nilai 0 selalu queue, sedangkan nilai negatif tidak pernah queue.
ExportColumn
| Method | Signature | Default |
|---|---|---|
make() | static make(string $name): self | melempar jika nama kosong |
label() | label(string $label): self | headline dari nama, dot diubah menjadi spasi |
formatUsing() | formatUsing(Closure $callback): self — Closure(mixed, Model): mixed | tidak ada |
enabledByDefault() | enabledByDefault(bool $enabled = true): self | true |
toCell() | toCell(Model $record): string | seluruh value berubah menjadi string di sini |
Export column sengaja bukan table column. Table column memahami sort, search, rendering, badge color, icon, dan presentation. Export column hanya mengubah satu record menjadi satu string. Menggunakan table column untuk export dapat menaruh informasi presentation seperti badge color atau icon registry key ke spreadsheet.
toCell() adalah satu titik tempat semua value menjadi string, sehingga CSV dan XLSX tidak dapat menghasilkan representasi boolean/date yang berbeda: null menjadi '', boolean menjadi Yes/No, DateTimeInterface menjadi Y-m-d H:i:s, scalar di-cast ke string, dan object/array lain menjadi JSON sehingga setidaknya masih reversible.
Jangan memasukkan data sensitif sebagai export column. tests/Feature/Panel/ImportExportTest.php, misalnya, memastikan exporter user bawaan tidak pernah memiliki column password. Hal seperti ini lebih baik dikunci dengan test daripada hanya komentar.
Importer
<?php
declare(strict_types=1);
namespace App\Panels\Admin\Resources\Products\Imports;
use App\Models\Product;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use PandaPanel\Actions\Imports\ImportColumn;
use PandaPanel\Actions\Imports\Importer;
/**
* Loads products from a spreadsheet.
*
* Matched on the SKU, so re-uploading a corrected file updates the products
* it describes rather than creating second copies of them. That is what makes
* a failure report worth downloading: fix the rows it names, upload the same
* file again, and the others are updated in place.
*/
final class ProductImporter extends Importer
{
/**
* @return class-string<Model>
*/
public static function model(): string
{
return Product::class;
}
/**
* @return list<ImportColumn>
*/
public static function columns(): array
{
return [
ImportColumn::make('sku')
->label('SKU')
->guess(['code', 'product code', 'item number'])
->required()
->rules(['string', 'max:64'])
->castUsing(static fn (string $value): string => Str::upper(trim($value))),
ImportColumn::make('name')
->guess(['title', 'product', 'product name'])
->required()
->rules(['string', 'max:255']),
// A cell says "Peripherals"; this column says that is a
// `category` matched by `name`, and the row gets a foreign key.
ImportColumn::make('category')
->guess(['category name', 'group'])
->relationship('category', 'name'),
ImportColumn::make('price_cents')
->label('Price')
->guess(['price', 'unit price'])
->required()
->rules(['integer', 'min:0'])
->castUsing(static fn (string $value): int
=> (int) round(((float) str_replace([',', '$'], '', $value)) * 100)),
ImportColumn::make('stock')
->guess(['quantity', 'qty', 'on hand'])
->rules(['integer', 'min:0'])
->castUsing(static fn (string $value): int => (int) $value),
ImportColumn::make('is_published')
->label('Published')
->guess(['published', 'active', 'visible'])
->castUsing(static fn (string $value): bool => in_array(
mb_strtolower($value),
['1', 'yes', 'y', 'true', 'published', 'active'],
true,
)),
];
}
/**
* Finds the record a row belongs to, or a new one.
*
* Returning an existing record makes the import an update; returning a
* new one makes it an insert. Returning null skips the row without
* counting it as a failure.
*
* @param array<string, mixed> $data
*/
public static function resolve(array $data): ?Model
{
$sku = $data['sku'] ?? null;
if (! is_string($sku) || $sku === '') {
return null;
}
$product = Product::query()->where('sku', $sku)->first();
if ($product !== null) {
return $product;
}
$product = new Product;
// Only for a new product: an existing one keeps the slug it has,
// which a re-upload must not change.
$product->forceFill([
'slug' => Str::slug($sku).'-'.Str::lower(Str::random(6)),
]);
return $product;
}
/**
* Rules about the row rather than about a cell.
*
* @return array<string, mixed>
*/
public static function rules(): array
{
return [];
}
}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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
Importer
| Method | Signature | Default |
|---|---|---|
model() | abstract static model(): class-string<Model> | wajib |
columns() | abstract static columns(): array | wajib |
resolve() | static resolve(array $data): ?Model | instance baru dari model() — insert |
rules() | static rules(): array | [] |
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." ditambah kalimat failure jika ada |
resolve() menentukan arti "import". Default membuat record baru sehingga setiap row menjadi insert. Override seperti contoh melakukan lookup lebih dulu sehingga upload file koreksi meng-update record yang sama, bukan membuat duplicate.
ImportColumn
| Method | Signature | Default |
|---|---|---|
make() | static make(string $name): self | melempar jika nama kosong |
label() | label(string $label): self | headline dari nama |
guess() | guess(array $guesses): self | [] |
rules() | rules(array $rules): self | [] |
required() | required(bool $required = true): self | false |
castUsing() | castUsing(Closure $callback): self — Closure(string): mixed | trim; '' menjadi null |
relationship() | relationship(string $relationship, string $column = 'name'): self | tidak ada |
createRelated() | createRelated(bool $create = true): self | false |
headings() | headings(): array | name, label, dan guess dalam lowercase |
validationRules() | validationRules(): array | required atau nullable, lalu isi rules() |
Tiga method paling penting:
guess() memungkinkan file dari sistem lain langsung dipakai tanpa user harus mengganti heading terlebih dahulu. Jika file menyebut column "E-mail Address", meminta manusia rename column sebenarnya meminta manusia melakukan pekerjaan yang bisa dilakukan komputer. Mapping UI tetap memperbolehkan koreksi manual, dan pilihan eksplisit user tidak pernah ditimpa oleh guess otomatis.
castUsing() diperlukan karena spreadsheet tidak benar-benar membawa type PHP. Cell dibaca sebagai string. 1, yes, dan TRUE mungkin berarti hal yang sama bagi manusia tetapi belum tentu bagi database boolean.
relationship() me-resolve cell melalui relation dan menyimpan foreign key. Importer tidak perlu tahu bahwa field konseptual category berarti category_id. Fitur ini hanya berlaku pada BelongsTo, yaitu relation yang value-nya memang tersimpan pada column row yang sedang diimport. hasMany tidak dapat di-set dari satu cell karena foreign key berada pada table lain.
createRelated() default-nya off. Salah ketik pada spreadsheet tidak seharusnya diam-diam membuat row baru pada table relation dan mengubah satu kesalahan menjadi dua. Tanpa fitur ini, lookup relation yang tidak menemukan record menghasilkan foreign key null lalu validation menentukan apakah row gagal — jawaban yang lebih aman daripada record yang terhubung ke data baru tak sengaja.
Validation tetap memakai Laravel rules per row. File spreadsheet adalah request input seperti input lainnya; asalnya dari file tidak membuatnya trusted.
Memasang import dan export pada table
use App\Panels\Admin\Resources\Products\Exports\ProductExporter;
use App\Panels\Admin\Resources\Products\Imports\ProductImporter;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Actions\ExportAction;
use PandaPanel\Actions\ImportAction;
->headerActions([
CreateAction::make(ProductResource::class),
ImportAction::make(ProductImporter::class, ProductResource::class),
ExportAction::make(ProductExporter::class, ProductResource::class),
])
->bulkActions([
DeleteBulkAction::make(ProductResource::class),
// The selection as a spreadsheet, through the same dialog.
ExportAction::bulk(ProductExporter::class, ProductResource::class),
])2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ExportAction::make(string $exporter, string $resource): Action // the list, as currently filtered
ExportAction::bulk(string $exporter, string $resource): Action // the selection only
ImportAction::make(string $importer, string $resource): Action2
3
Kedua bentuk export menggunakan dialog yang sama — pilih column lalu pilih format — karena pertanyaan "column mana yang masuk file" adalah keputusan tentang output file, bukan tentang cara record dipilih.
Ability yang diperiksa:
| Action | Ability |
|---|---|
ExportAction::make() / ::bulk() | $resource::canViewAny() |
ImportAction::make() | $resource::canCreate() |
Import memerlukan create walaupun resolve() pada implementation tertentu hanya meng-update existing record. File import secara capability tetap dapat membuat record jika implementation mengizinkannya; meminta ability yang lebih lemah akan menjadi boundary yang salah.
Yang dilakukan masing-masing dialog
Export. Dialog menampilkan checkbox list dari column, tercentang sesuai enabledByDefault(), dan radio format berdasarkan formats(). Urutan column pada file selalu mengikuti deklarasi schema, bukan urutan user mencentang checkbox. File yang berubah urutan hanya karena urutan click tidak dapat dibandingkan dengan export minggu lalu.
Import. Dialog berisi FileUpload dan satu searchable select untuk setiap declared column. File di-upload lebih dulu melalui upload endpoint biasa. Saat submit, request mengirim mapping antara column spreadsheet dengan import column. Urutan ini diperlukan karena heading file baru dapat ditawarkan setelah file tersedia.
Select menampilkan huruf column spreadsheet — A, B, … Z, AA, AB — karena itulah identitas yang terlihat user pada spreadsheet. "C" mudah ditemukan di file; index numerik 2 tidak. UI mapping dibatasi sampai 200 column, jauh melebihi spreadsheet yang biasanya dipetakan manual. Heading matching sendiri tidak dibatasi, jadi column ke-300 tetap dapat di-map otomatis jika heading-nya dikenali.
Select kosong berarti "coba guess dari heading", bukan "skip". Column yang sudah dipilih manual tidak pernah ditimpa oleh guess.
Sebelum membaca satu row pun, required column yang tidak mendapat mapping akan menggagalkan seluruh upload:
This file has no column for [sku], and it is required. Its headings are:
Item Number, Title, Qty. Rename the column in the file, or map it by hand
before importing.2
3
Tanpa preflight ini, required column yang tidak memiliki heading akan membuat seluruh row gagal dengan error yang sama — ribuan error yang semuanya benar tetapi menjelaskan masalah yang salah. File upload juga dihapus ketika preflight gagal, sehingga retry menggunakan upload baru, bukan stale path.
Dijalankan langsung atau melalui queue
| Export | Import | |
|---|---|---|
| Threshold | Exporter::queueAfter(), default 2000 record | Importer::queueAfter(), default 500 row |
| Cara menghitung | sebelum file ditulis | dengan membaca row count file |
| Di bawah threshold | berjalan di request, toast dengan link | berjalan di request, toast dan report link jika ada failure |
| Di atas threshold | PandaPanel\Jobs\RunPanelExport | PandaPanel\Jobs\RunPanelImport |
| Hasil | persistent notification dengan action download | persistent notification dengan failure report |
Queued job membawa nama class, column/mapping terpilih, format, owner key, table state, dan Panel ID — bukan closure dan bukan model instance.
public static function queueAfter(): int
{
return 0; // always queue
}2
3
4
Pastikan queue worker berjalan. Tanpa worker, queued export tidak menghasilkan notification apa pun. Lihat Queues.
Lokasi file dan siapa yang boleh membaca
Export disimpan sebagai:
{disk}/{Exporter::directory()}/{user key}/{Exporter::fileName()}.{csv|xlsx}Segmen per-user bukan dekorasi. Dua route yang mengembalikan file selalu membangun directory berdasarkan authenticated user, bukan request parameter:
| Route name | Path | Controller |
|---|---|---|
panel.admin.export-file | GET /admin/exports/{file} | PandaPanel\Http\Controllers\PanelExportController |
panel.admin.import-file | GET /admin/imports/{file} | PandaPanel\Http\Controllers\PanelImportController |
Request hanya boleh menyebut nama file, tidak pernah full path. Separator, backslash, dan dot-segment ditolak:
abort_if(
$file === '' || str_contains($file, '/') || str_contains($file, '\\') || str_contains($file, '..'),
404,
);2
3
4
Traversal tidak memiliki directory yang dapat dikendalikan request. User yang mencoba menyebut filename milik user lain juga tidak menemukan file karena lookup selalu dilakukan di directory miliknya sendiri. tests/Feature/Panel/Negative/FileAndDataAccessTest.php menguji kedua penolakan tersebut sekaligus memastikan user tetap dapat mendownload file miliknya sendiri.
Nama class exporter atau importer dikirim melalui query string dan diverifikasi dengan is_subclass_of() terhadap base class yang sesuai sebelum framework membaca disk() dan directory().
Failure adalah bagian dari desain import
Partial import adalah outcome yang memang diharapkan, bukan exception. Satu tanggal buruk pada row ke-400 tidak seharusnya membatalkan 999 row lain.
[
'imported' => 998,
'failed' => 2,
'report' => 'failed-rows-2026-08-16-120000.csv', // null when nothing failed
]2
3
4
5
report hanya berupa nama file, ditulis ke directory per-user milik importer. Download endpoint sendiri yang membangun directory sehingga request tidak pernah membawa full path.
Failure report berisi row gagal dalam bentuk aslinya, mempertahankan heading file dan menambahkan column Error yang menjelaskan penyebab. User dapat mengoreksi file lalu mengupload ulang. Jika resolve() menggunakan stable key seperti SKU, row yang sebelumnya berhasil akan di-update, bukan dibuat duplicate.
Import bersih cukup dijawab melalui toast. Bell yang terus terisi "imported 40 rows" akan menjadi notification center yang diabaikan user.
Test
<?php
declare(strict_types=1);
use App\Models\Category;
use App\Models\Product;
use App\Models\User;
use App\Panels\Admin\Resources\Products\Exports\ProductExporter;
use App\Panels\Admin\Resources\Products\Imports\ProductImporter;
use Illuminate\Support\Facades\Storage;
use PandaPanel\Actions\Enums\SpreadsheetFormat;
use PandaPanel\Actions\Exports\ExportRun;
use PandaPanel\Actions\Imports\ImportRun;
use PandaPanel\Support\Spreadsheet\Csv;
beforeEach(function (): void {
Storage::fake('local');
$this->admin = User::factory()->admin()->create();
$this->actingAs($this->admin);
});
function productFile(string $extension): string
{
return tempnam(sys_get_temp_dir(), 'product-io-').'.'.$extension;
}
it('writes the chosen columns in the order the exporter declared them', function (): void {
Product::factory()->create(['name' => 'Keyboard', 'sku' => 'KB-001']);
// Requested backwards on purpose: a file whose columns move with the
// order of a checkbox list cannot be diffed against last week's.
$result = ExportRun::write(
ProductExporter::class,
Product::query(),
['name', 'sku'],
SpreadsheetFormat::Csv,
$this->admin->getKey(),
);
$local = productFile('csv');
file_put_contents($local, Storage::disk('local')->get($result['path']));
// Read back rather than string-matched: the writer quotes a field with a
// space in it, and asserting on the raw line would assert on that.
$rows = iterator_to_array(Csv::read($local), false);
expect($result['records'])->toBe(1)
->and($rows[0])->toBe(['SKU', 'Name'])
->and($rows[1])->toBe(['KB-001', 'Keyboard']);
});
it('files an export under the user it belongs to', function (): void {
Product::factory()->create();
$result = ExportRun::write(
ProductExporter::class,
Product::query(),
[],
SpreadsheetFormat::Csv,
$this->admin->getKey(),
);
expect($result['path'])->toStartWith('panel-exports/'.$this->admin->getKey().'/');
});
it('does not let one user download another user\'s export', function (): void {
$other = User::factory()->admin()->create();
Storage::disk('local')->put(
ProductExporter::directory().'/'.$other->getKey().'/products.csv',
'sku,name',
);
// The name is right; the directory it lives in is not this user's.
$this->get('/admin/exports/products.csv?exporter='.urlencode(ProductExporter::class))
->assertNotFound();
});
it('imports the rows it can and reports the ones it cannot', function (): void {
Category::query()->create(['name' => 'Peripherals']);
$path = productFile('csv');
$handle = Csv::open($path);
Csv::write($handle, ['sku', 'name', 'category', 'price', 'qty', 'published']);
Csv::write($handle, ['KB-001', 'Keyboard', 'Peripherals', '129.00', '4', 'yes']);
Csv::write($handle, ['MS-001', 'Mouse', 'Peripherals', 'not-a-price', '2', 'no']);
fclose($handle);
$result = ImportRun::run(
ProductImporter::class,
$path,
// The same guess the dialog makes when a select is left blank.
ImportRun::guessMapping(ProductImporter::class, ImportRun::headings($path)),
$this->admin->getKey(),
);
expect($result['imported'])->toBe(1)
->and($result['failed'])->toBe(1)
->and(Product::query()->where('sku', 'KB-001')->exists())->toBeTrue()
->and(Product::query()->where('sku', 'MS-001')->exists())->toBeFalse();
$report = (string) Storage::disk('local')
->get('panel-imports/'.$this->admin->getKey().'/'.$result['report']);
expect($report)->toContain('not-a-price');
});
it('updates the product a re-uploaded row describes rather than duplicating it', function (): void {
Product::factory()->create(['sku' => 'KB-001', 'name' => 'Old name']);
$path = productFile('csv');
$handle = Csv::open($path);
Csv::write($handle, ['sku', 'name', 'price']);
Csv::write($handle, ['KB-001', 'New name', '99.00']);
fclose($handle);
ImportRun::run(
ProductImporter::class,
$path,
ImportRun::guessMapping(ProductImporter::class, ImportRun::headings($path)),
$this->admin->getKey(),
);
expect(Product::query()->where('sku', 'KB-001')->count())->toBe(1)
->and(Product::query()->firstWhere('sku', 'KB-001')?->name)->toBe('New name');
});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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
Kedua runner merupakan public API dan menjalankan logic yang sama baik di request maupun queued job. Karena itu keduanya dapat diuji tanpa HTTP:
ExportRun::write(string $exporter, Builder $query, array $columns, SpreadsheetFormat $format, int|string $owner): array
ImportRun::run(string $importer, string $path, array $mapping, int|string $owner): array
ImportRun::headings(string $path): array
ImportRun::countRows(string $path): int
ImportRun::guessMapping(string $importer, array $headings): array
ImportRun::unmappedRequiredColumns(string $importer, array $mapping): array2
3
4
5
6
php artisan test --compact --filter=ImportExportHal yang perlu diperhatikan
- Kedua reader membutuhkan real local path. CSV melakukan stream dari file handle dan XLSX adalah zip yang dibuka berdasarkan nama file. File pada remote disk disalin sementara ke local selama proses read.
- Gunakan
local, bukanpublic. Memindahkan export ke public disk membuat salinan records tersedia pada URL yang mungkin dapat ditebak. Download endpoint ada agar authorization boundary diperiksa lagi. - Eager load relation pada
query(). Export 10.000 row dengan relation column tanpa eager load berarti 10.000 query tambahan. - Queued export tanpa worker terlihat seperti diam. Tidak ada exception pada browser; notification hanya tidak pernah tiba.
relationship()hanya mendukungBelongsTo. Relation lain diabaikan dan cell ditulis ke attribute dengan nama import column sendiri.castUsing()selalu menerima trimmed string. Tidak ada branch "cell ini sebenarnya number" dari reader.- Mapping select kosong berarti guess, bukan skip. Jika column benar-benar tidak ingin diimport, jangan deklarasikan column tersebut.
- Formula escaping aktif. Cell CSV yang diawali
=,+,-, atau@dinetralkan.tests/Feature/Panel/Negative/SpreadsheetFormulaTest.phpmengunci jaminan ini. - Urutan column exporter adalah urutan column file. Checkbox hanya menentukan column mana yang ikut, bukan urutannya.
Lihat juga
- Product Resource — resource yang diproses
- User Resource —
UserExporterdanUserImporterbawaan - Export Action, Import Action
- Exporters, Importers
- Column Mapping
- CSV and XLSX
- Queued Exports, Queued Imports
- Failure Reports
- Storage and Cleanup
- Notifications
- Import and Export Troubleshooting
- Security