Struk pesanan, faktur PDF, laporan CSV — email transaksional sering kali harus membawa berkas. Laravel menyediakan class Attachment dengan tiga sumber lampiran: fromPath() untuk berkas di filesystem server, fromStorageDisk() untuk berkas di disk storage aplikasi, dan fromData() untuk data mentah yang baru dibuat di memori. Ketiganya dikembalikan dari method attachments() di Mailable, masing-masing bisa diberi nama tampilan dan MIME type sendiri. Pada chapter ini kita melampirkan ketiga jenis sekaligus ke satu email, membuktikan struktur MIME-nya di log, lalu menyematkan gambar inline langsung di badan pesan.

Mailable dengan Tiga Sumber Lampiran #

Class OrderDocuments membawa konfigurasi lampiran lengkap:

php
namespace App\Mail;

use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class OrderDocuments extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(
        public string $orderId,
        public string $csvPath,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "Dokumen Pesanan #{$this->orderId}",
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.order-documents',
        );
    }

    public function attachments(): array
    {
        return [
            Attachment::fromPath($this->csvPath)
                ->as('faktur-'.$this->orderId.'.csv')
                ->withMime('text/csv'),

            Attachment::fromStorageDisk('local', 'invoices/inv-'.$this->orderId.'.csv')
                ->as('salinan-storage.csv'),

            Attachment::fromData(fn () => 'ARSIP-DATA-MENTAH-'.$this->orderId,
                'arsip-'.$this->orderId.'.txt'),
        ];
    }
}

Mari kita bedah ketiga entri array:

  1. Attachment::fromPath($path) — membaca berkas dari path absolut filesystem. Rantai ->as() mengganti nama file yang dilihat penerima, ->withMime() menyetel Content-Type header.
  2. Attachment::fromStorageDisk('local', ...) — membaca berkas dari disk local milik Storage (defaultnya storage/app/private). Nama file diambil otomatis dari basename bila as() tidak diisi, dan MIME dideteksi otomatis lewat $storage->mimeType($path).
  3. Attachment::fromData(closure, $nama) — closure menyiapkan isi di memori, bukan dari disk. Cocok untuk PDF yang baru dirender atau arsip yang dibuat saat itu juga. Argument kedua wajib karena data mentah tidak punya nama file.

Struktur MIME Hasil Kirim #

Pengiriman dijalankan dengan berkas faktur yang sudah ada di kedua lokasi:

php
use App\Mail\OrderDocuments;
use Illuminate\Support\Facades\Mail;

Mail::to('[email protected]')->send(new OrderDocuments('777', '/tmp/faktur.csv'));
echo "email_dengan_lampiran_terkirim\n";

Output yang Dihasilkan:

text
email_dengan_lampiran_terkirim
text
[2026-10-13 19:27:37] local.DEBUG: From: Laravel <[email protected]>
To: [email protected]
Subject: Dokumen Pesanan #777
MIME-Version: 1.0
Date: Tue, 13 Oct 2026 19:27:37 +0000
Message-ID: <[email protected]>
Content-Type: multipart/mixed; boundary=N8858y7V

--N8858y7V
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable

Halo,

Dokumen pesanan #777 kami lampirkan pada email ini:
- Faktur (CSV)
- Salinan dari storage
- Arsip data mentah

Salam,
Toko Latihan

--N8858y7V
Content-Type: text/csv; name=faktur-777.csv
Content-Transfer-Encoding: base64
Content-Disposition: attachment; name=faktur-777.csv;
 filename=faktur-777.csv

SU5WLTc3NyxUb2tvIExhdGloYW4KQnVrdSBMYXJhdmVsLDIsMzUwMDAwClN0aWtlciBFbGVwaGFu
dCw1LDE1MDAwClRPVEFMLDc3NTAwMAo=
--N8858y7V
Content-Type: text/csv; name=salinan-storage.csv
Content-Transfer-Encoding: base64
Content-Disposition: attachment; name=salinan-storage.csv;
 filename=salinan-storage.csv

SU5WLTc3NyxUb2tvIExhdGloYW4KQnVrdSBMYXJhdmVsLDIsMzUwMDAwClN0aWtlciBFbGVwaGFu
dCw1LDE1MDAwClRPVEFMLDc3NTAwMAo=
--N8858y7V
Content-Type: application/octet-stream; name=arsip-777.txt
Content-Transfer-Encoding: base64
Content-Disposition: attachment; name=arsip-777.txt; filename=arsip-777.txt

QVJTSVAtREFUQS1NRU5UQUgtNzc3
--N8858y7V--

Log ini memperlihatkan anatomi email berlampiran: Content-Type: multipart/mixed membungkus bagian isi (text/html) plus tiga bagian lampiran. Setiap lampiran membawa header Content-Disposition: attachment dengan nama file, Content-Type sesuai sumbernya (text/csv dari withMime(), application/octet-stream bawaan untuk data mentah), dan isi yang di-encode base64. Dua lampiran CSV berisi base64 identik karena sumbernya file yang sama — hanya nama tampilannya yang berbeda.

Gambar Inline di Badan Email #

Lampiran klasik harus diunduh dulu; logo atau banner butuh tampil langsung di badan pesan. Variabel $message tersedia otomatis di view, dan embed() menyisipkan gambar dengan Content-ID:

blade
{{-- resources/views/emails/banner.blade.php --}}
<img src="{{ $message->embed($pathLogo) }}" alt="Logo">
<p>Email dengan logo tersemat.</p>

Pengiriman menggunakan view tersebut dengan path logo:

php
Mail::send('emails.banner', ['pathLogo' => storage_path('app/private/logo.png')], function ($message) {
    $message->to('[email protected]')->subject('Tes Logo');
});

Log membuktikan gambar terkirim sebagai bagian inline:

text
Content-ID: <a4459f1071a5ef2d64b30460b40da566@symfony>
Content-Disposition: inline; name=logo.png; filename=logo.png

Header Content-Disposition: inline plus Content-ID inilah yang membuat <img> merender gambar tanpa lampiran terpisah. Untuk data mentah, $message->embedData($data, $nama, $mime) bekerja dengan cara yang sama.

Inspeksi Lampiran di Test #

Feature test memeriksa definisi lampiran tanpa mengirim email — array dari attachments() bisa dipetakan langsung:

php
use App\Mail\OrderDocuments;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;

public function test_email_memiliki_tiga_lampiran_dengan_nama_yang_benar(): void
{
    Mail::fake();
    Storage::fake('local');
    Storage::disk('local')->put('invoices/inv-777.csv', "INV-777,Toko Latihan\nBuku Laravel,2,350000\n");

    Mail::to('[email protected]')->send(new OrderDocuments('777', '/tmp/faktur.csv'));

    Mail::assertSent(OrderDocuments::class, function (OrderDocuments $mail) {
        $nama = collect($mail->attachments())
            ->map(fn ($a) => $a->as)
            ->implode(', ');
        fwrite(STDERR, 'lampiran: ' . $nama . "\n");

        return $mail->hasTo('[email protected]');
    });
}

Output yang Dihasilkan:

text
lampiran: faktur-777.csv, salinan-storage.csv, arsip-777.txt
attachment_test: lulus

  PASS  Tests\Feature\AttachmentTest
  ✓  email memiliki tiga lampiran dengan nama yang benar                 0.09s

  Tests:    1 passed (1 assertions)

Storage::fake('local') mengganti disk dengan storage virtual per-test, sehingga berkas inv-777.csv tidak mengotori disk sungguhan.

Ukuran Lampiran yang Waras #

Email punya batas praktis yang lebih ketat dari web. Runner kecil mengukur overhead encoding:

php
$csv = storage_path('app/private/invoices/inv-777.csv');
echo 'ukuran_csv: ' . filesize($csv) . " bytes\n";
echo 'base64_csv: ' . strlen(base64_encode(file_get_contents($csv))) . " bytes\n";

Output yang Dihasilkan:

text
ukuran_csv: 80 bytes
base64_csv: 108 bytes
kenaikan_persen: 35%

Encoding base64 menambah sekitar sepertiga ukuran — berkas 15 MB memakan kuota ~20 MB. Penyedia umum membatasi 25 MB per pesan, jadi berkas besar lebih baik diunggah dan dikirim sebagai tautan unduhan. Batas ini juga alasan mengapa layanan email modern memakai tautan berbagi untuk berkas raksasa: email hanya pengantar, sedangkan isinya menetap di object storage.

Sebelum menutup, satu catatan praktis tentang keamanan: jangan pernah melampirkan berkas yang namanya dikendalikan input user tanpa validasi. Nama seperti ../../.env mungkin lolos ke header filename dan membingungkan klien email; sanitizer sederhana Str::slug($namaUser) plus ekstensi yang diizinkan sudah cukup menutup celah tersebut.

Dari email berisi data, kita bergeser ke sistem yang lebih luas: notifikasi multi-channel yang menargetkan user lewat database, mail, sekaligus channel lain. Chapter Notification Channels memulainya. Detail seluruh API lampiran ada di dokumentasi Mail .