Storage dan Cleanup
Proses import dan export menulis empat jenis file ke disk: spreadsheet hasil export, spreadsheet yang di-upload pengguna, failure report yang dibuat import, serta temporary file yang digunakan saat menyusun hasil. Sebagian file dihapus otomatis dan sebagian tidak. Perbedaan ini penting karena export adalah salinan record yang sebelumnya hanya boleh dilihat pengguna tertentu, sedangkan failure report adalah salinan data yang dicoba seseorang untuk diimport.
Gunakan halaman ini ketika memilih disk, melakukan hardening deployment, atau menulis pruning job yang memang sengaja tidak disediakan package.
Contoh minimal yang berfungsi
Default-nya, dituliskan secara eksplisit:
use PandaPanel\Actions\Exports\Exporter;
use PandaPanel\Actions\Imports\Importer;
Exporter::disk(); // 'local'
Exporter::directory(); // 'panel-exports'
Importer::disk(); // 'local'
Importer::directory(); // 'panel-imports'2
3
4
5
6
7
8
Pada aplikasi Laravel 12/13 standar, disk local berakar di storage_path('app/private'), sehingga export selesai untuk user 7 berada di:
storage/app/private/panel-exports/7/users-2026-08-15.csvTidak ada file yang disajikan dari web root dan tidak ada yang memiliki public URL.
Lokasi setiap file
| File | Path | Ditulis oleh |
|---|---|---|
| export | {Exporter::directory()}/{ownerKey}/{fileName}.{ext} | ExportRun::write() |
| upload import | {Importer::directory()}/{random}.{ext} | upload endpoint panel |
| failure report | {Importer::directory()}/{ownerKey}/failed-rows-{Y-m-d-His}.csv | ImportRun::run() |
| temporary export | sys_get_temp_dir()/panel-export-* | ExportRun::write() |
| temporary report | sys_get_temp_dir()/panel-import-failures-* | ImportRun::run() |
Segmen owner adalah getAuthIdentifier() dari authenticated user. Ini bukan sekadar organisasi folder: download endpoint membangun segmen tersebut dari pengguna yang sedang meminta, bukan dari request, sehingga seorang user tidak dapat menamai file milik user lain melalui path. Upload tidak memiliki owner segment karena bersifat sementara dan tidak pernah disajikan kembali secara langsung.
Mengapa disk harus private
Exporter::disk() menggunakan local, bukan public, dan default tersebut sebaiknya dipertahankan. Export adalah salinan record yang hanya boleh dilihat oleh pihak tertentu. Public disk akan menempatkan file tersebut pada URL yang dapat ditebak, padahal export justru termasuk file yang layak dicoba untuk ditebak. Download harus melalui panel agar authorization dapat diperiksa kembali:
// PanelExportController, in outline
abort_if($user === null, 403);
abort_if($file === '' || str_contains($file, '/') || str_contains($file, '\\') || str_contains($file, '..'), 404);
abort_unless(is_subclass_of($exporter, Exporter::class), 404);
$path = $exporter::directory().'/'.$user->getAuthIdentifier().'/'.$file;
abort_unless($disk->exists($path), 404);
return $disk->download($path, $file);2
3
4
5
6
7
8
9
10
Caller hanya menamai file, tidak pernah path, sehingga path traversal tidak memiliki ruang untuk bekerja. PanelImportController menerapkan aturan yang sama pada failure report.
Memilih disk lain
// config/filesystems.php
'disks' => [
'panel-files' => [
'driver' => 'local',
'root' => storage_path('app/panel-files'),
'throw' => false,
],
],2
3
4
5
6
7
8
final class OrderExporter extends Exporter
{
public static function disk(): string
{
return 'panel-files';
}
public static function directory(): string
{
return 'exports/orders';
}
}2
3
4
5
6
7
8
9
10
11
12
Ada dua constraint, dan hanya salah satunya berupa preferensi:
- Exporter boleh menggunakan remote disk.
ExportRunmenulis ke temporary file local, lalu men-stream hasilnya ke target menggunakanStorage::disk(...)->put(). Download endpoint memakai$disk->download(). Keduanya dapat bekerja dengan S3. Pastikan bucket tetap private. - Importer tidak boleh menggunakan remote disk. Reader menerima
Storage::disk($importer::disk())->path($stored)lalu membukanya denganfopen()atauZipArchive, sehinggapath()harus mengembalikan filesystem path yang dapat dibaca. Remote driver tidak memenuhi kebutuhan tersebut.
Jangan arahkan exporter maupun importer ke disk public.
File yang dihapus otomatis
| File | Waktu dihapus |
|---|---|
| upload import, inline run | setelah ImportRun::run() selesai |
| upload import, required column hilang | segera, sebelum ValidationException dilempar |
| upload import, queued run | oleh RunPanelImport::handle() saat success |
| upload import, queued failure | oleh RunPanelImport::failed() |
| temporary export file | setelah di-stream ke disk tujuan |
| temporary report file | setelah di-stream ke disk tujuan |
Upload dihapus pada seluruh kasus tersebut karena file adalah sarana proses, bukan record. Menyimpannya akan mengumpulkan salinan data pelanggan yang tidak pernah diminta untuk dipertahankan, dan kondisi failure justru merupakan saat file paling mudah terlupakan.
File yang tidak dihapus otomatis
| File | Lifetime |
|---|---|
| setiap export yang selesai | selamanya |
| setiap failure report | selamanya |
| upload yang dialog-nya ditutup setelah file dipilih | selamanya |
| temporary file yang tertinggal karena write melempar exception di tengah proses | sampai sistem membersihkan temp directory |
Tidak ada command panel:prune dan tidak ada schedule bawaan. Ini merupakan gap yang harus diisi aplikasi yang melakukan export secara rutin: admin panel yang membuat user list setiap hari akan menulis 365 file per tahun untuk setiap orang yang menjalankannya.
Menulis pruning job
Gunakan command biasa terhadap disk. Struktur directory sudah tetap dan dangkal, sehingga implementasinya cukup sederhana:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use PandaPanel\Actions\Exports\Exporter;
use PandaPanel\Actions\Imports\Importer;
final class PrunePanelFiles extends Command
{
protected $signature = 'panel:prune-files {--days=7}';
protected $description = 'Delete panel exports, failure reports and abandoned uploads older than N days.';
public function handle(): int
{
$cutoff = now()->subDays((int) $this->option('days'))->getTimestamp();
$targets = [
[Exporter::disk(), Exporter::directory()],
[Importer::disk(), Importer::directory()],
];
$deleted = 0;
foreach ($targets as [$disk, $directory]) {
$filesystem = Storage::disk($disk);
// allFiles() is recursive, which covers both the per-user
// directories and the uploads sitting at the top level.
foreach ($filesystem->allFiles($directory) as $path) {
if ($filesystem->lastModified($path) >= $cutoff) {
continue;
}
$filesystem->delete($path);
$deleted++;
}
}
$this->info(sprintf('Deleted %d file(s).', $deleted));
return self::SUCCESS;
}
}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
Gunakan class exporter dan importer milik aplikasi sendiri jika keduanya mengoverride disk() atau directory() — abstract class pada contoh di atas hanya memberikan default.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('panel:prune-files --days=7')->daily();2
3
4
Tujuh hari adalah titik awal yang masuk akal: cukup lama agar link pada notification minggu lalu masih bekerja, tetapi cukup pendek untuk mencegah akumulasi tidak terbatas. Notification yang file-nya sudah dipruning akan mendapat 404 dari download endpoint. Itu merupakan hasil yang benar — notification adalah history, tetapi file tidak harus disimpan selamanya.
Retention dan privacy
Export berisi apa pun yang dideklarasikan exporter, untuk record yang sesuai list saat export dilakukan. Failure report berisi row yang dicoba seseorang untuk diimport, termasuk data yang salah. Di sebagian besar aplikasi, keduanya termasuk personal data dan tetap ada bahkan setelah record database dihapus. Retention file seharusnya tidak lebih panjang daripada retention data utama aplikasi — dengan kata lain pruning job di atas bukan sesuatu yang sebaiknya diabaikan.
Dua hal dapat memperkecil surface bahkan sebelum membicarakan retention:
- Deklarasikan lebih sedikit kolom. Daftar kolom exporter adalah keseluruhan surface data yang dapat diexport; kolom yang tidak dideklarasikan tidak dapat diexport siapa pun.
- Jangan import data yang tidak diperlukan. Menambahkan password column pada importer berarti password tersebut berada dalam spreadsheet di laptop seseorang.
Testing
use Illuminate\Support\Facades\Storage;
it('files an export under the user it belongs to', function (): void {
Storage::fake('local');
$result = ExportRun::write(UserExporter::class, User::query(), [], SpreadsheetFormat::Csv, 42);
expect($result['path'])->toStartWith('panel-exports/42/');
});2
3
4
5
6
7
8
9
Storage::fake() mengganti disk dengan temporary disk sehingga test yang memeriksa file tidak menyentuh storage/app. Failure report dapat diuji dengan cara yang sama:
$report = (string) Storage::disk('local')->get('panel-imports/5/'.$result['report']);
expect($report)->toContain('Error');2
3
Catatan
- Owner directory hanya dibuat ketika ada write. User yang belum pernah export tidak memiliki directory, dan pemeriksaan
exists()di download endpoint menjawab 404 tanpa error lain. - Dua exporter boleh berbagi directory. Nama file yang membedakannya, sehingga
Exporter::fileName()sebaiknya cukup distinctive jika menggunakan directory bersama. Exporter::fileName()tanpa timestamp akan overwrite. Ini valid untuk konsep "current list", tetapi buruk untuk data yang membutuhkan history.ExportRunmembutuhkan system temp directory yang writable. Jikatempnam()gagal, exceptionRuntimeException('Cannot create a temporary file for the export.')dilempar; penyebab umum adalah/tmpread-only atau penuh.- Write yang gagal dapat meninggalkan temporary file.
unlink()dijalankan setelah stream berhasil dipindahkan ke disk, sehingga exception di tengah proses dapat meninggalkan filepanel-export-*sampai sistem membersihkannya. - Tidak ada konfigurasi ini di
config/panda-panel.php. Disk dan directory berada pada class exporter/importer agar queued job dapat membacanya tanpa container-dependent state.
Lihat juga
- Class exporter —
disk(),directory(),fileName() - Class importer
- Failure report
- ExportAction dan ImportAction
- Queued import — alasan upload dihapus saat failure
- File upload
- Setup storage
- Production checklist