· Admin

Laravel'de Fatura PDF Oluşturma

E-ticaret, SaaS, freelance faturalandırma — PDF oluşturma ihtiyacı her yerde karşımıza çıkıyor. Laravel'de barryvdh/laravel-dompdf paketi ile Blade template'inden PDF oluşturmak birkaç dakikalık iş.

Kurulum

composer require barryvdh/laravel-dompdf

Config dosyasını publish etmek isterseniz:

php artisan vendor:publish --provider="Barryvdh\DomPdf\ServiceProvider"

config/dompdf.php dosyasında kağıt boyutu, font dizini gibi ayarları değiştirebilirsiniz. Çoğu projede default ayarlar yeterli.

Fatura Modeli

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Invoice extends Model
{
    protected $fillable = [
        'invoice_number', 'customer_name', 'customer_email',
        'customer_address', 'subtotal', 'tax_rate', 'tax_amount',
        'total', 'status', 'due_date', 'notes',
    ];

    protected function casts(): array
    {
        return [
            'subtotal' => 'decimal:2',
            'tax_amount' => 'decimal:2',
            'total' => 'decimal:2',
            'tax_rate' => 'integer',
            'due_date' => 'date',
        ];
    }

    public function items()
    {
        return $this->hasMany(InvoiceItem::class);
    }

    // Otomatik fatura numarası
    public static function generateNumber(): string
    {
        $year = date('Y');
        $last = static::whereYear('created_at', $year)->max('id') ?? 0;

        return sprintf('INV-%s-%04d', $year, $last + 1);
    }
}

Blade Template

PDF'in görünümünü Blade ile tasarlıyoruz. HTML + inline CSS — dompdf harici CSS dosyalarını destekler ama inline CSS ile sorun yaşamazsınız.

resources/views/invoices/pdf.blade.php:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: DejaVu Sans, sans-serif;
            font-size: 12px;
            color: #333;
            line-height: 1.6;
        }
        .header {
            display: flex;
            justify-content: space-between;
            margin-bottom: 30px;
            border-bottom: 2px solid #3b82f6;
            padding-bottom: 20px;
        }
        .company-name {
            font-size: 24px;
            font-weight: bold;
            color: #1e40af;
        }
        .invoice-info {
            text-align: right;
        }
        .invoice-title {
            font-size: 20px;
            color: #1e40af;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin: 20px 0;
        }
        th {
            background-color: #3b82f6;
            color: white;
            padding: 10px;
            text-align: left;
        }
        td {
            padding: 8px 10px;
            border-bottom: 1px solid #e5e7eb;
        }
        tr:nth-child(even) {
            background-color: #f9fafb;
        }
        .totals {
            float: right;
            width: 250px;
        }
        .totals table td {
            border: none;
        }
        .total-row {
            font-size: 16px;
            font-weight: bold;
            color: #1e40af;
        }
        .footer {
            position: fixed;
            bottom: 0;
            width: 100%;
            text-align: center;
            font-size: 10px;
            color: #9ca3af;
            border-top: 1px solid #e5e7eb;
            padding-top: 10px;
        }
    </style>
</head>
<body>
    <table width="100%">
        <tr>
            <td>
                <div class="company-name">ProjeMan</div>
                <div>[email protected]</div>
                <div>projeman.net</div>
            </td>
            <td style="text-align: right;">
                <div class="invoice-title">FATURA</div>
                <div><strong>No:</strong> {{ $invoice->invoice_number }}</div>
                <div><strong>Tarih:</strong> {{ $invoice->created_at->format('d.m.Y') }}</div>
                <div><strong>Vade:</strong> {{ $invoice->due_date->format('d.m.Y') }}</div>
            </td>
        </tr>
    </table>

    <div style="margin: 20px 0; padding: 15px; background-color: #f3f4f6; border-radius: 5px;">
        <strong>Müşteri:</strong><br>
        {{ $invoice->customer_name }}<br>
        {{ $invoice->customer_email }}<br>
        {!! nl2br(e($invoice->customer_address)) !!}
    </div>

    <table>
        <thead>
            <tr>
                <th>#</th>
                <th>Açıklama</th>
                <th style="text-align: center;">Miktar</th>
                <th style="text-align: right;">Birim Fiyat</th>
                <th style="text-align: right;">Toplam</th>
            </tr>
        </thead>
        <tbody>
            @foreach ($invoice->items as $index => $item)
                <tr>
                    <td>{{ $index + 1 }}</td>
                    <td>{{ $item->description }}</td>
                    <td style="text-align: center;">{{ $item->quantity }}</td>
                    <td style="text-align: right;">{{ number_format($item->unit_price, 2, ',', '.') }} ₺</td>
                    <td style="text-align: right;">{{ number_format($item->total, 2, ',', '.') }} ₺</td>
                </tr>
            @endforeach
        </tbody>
    </table>

    <div class="totals">
        <table>
            <tr>
                <td>Ara Toplam:</td>
                <td style="text-align: right;">{{ number_format($invoice->subtotal, 2, ',', '.') }} ₺</td>
            </tr>
            <tr>
                <td>KDV (%{{ $invoice->tax_rate }}):</td>
                <td style="text-align: right;">{{ number_format($invoice->tax_amount, 2, ',', '.') }} ₺</td>
            </tr>
            <tr class="total-row">
                <td>Genel Toplam:</td>
                <td style="text-align: right;">{{ number_format($invoice->total, 2, ',', '.') }} ₺</td>
            </tr>
        </table>
    </div>

    <div style="clear: both;"></div>

    @if ($invoice->notes)
        <div style="margin-top: 30px; padding: 10px; background-color: #fef3c7; border-radius: 5px;">
            <strong>Notlar:</strong><br>
            {{ $invoice->notes }}
        </div>
    @endif

    <div class="footer">
        Bu fatura ProjeMan tarafından oluşturulmuştur. | projeman.net
    </div>
</body>
</html>

Birkaç dikkat noktası:

  • Font olarak DejaVu Sans kullanıyoruz — Türkçe karakterleri (ş, ğ, ı, ö, ü, ç) doğru gösterir. Arial veya Helvetica Türkçe karakterlerde sorun çıkarır.
  • flexbox dompdf'te sınırlı destek görür, tablo layout daha güvenli.
  • position: fixed footer'ı her sayfanın altına sabitler.

Controller

namespace App\Http\Controllers;

use App\Models\Invoice;
use Barryvdh\DomPdf\Facade\Pdf;

class InvoiceController extends Controller
{
    public function download(Invoice $invoice)
    {
        $pdf = Pdf::loadView('invoices.pdf', compact('invoice'))
            ->setPaper('a4')
            ->setOption('isRemoteEnabled', true);

        return $pdf->download("fatura-{$invoice->invoice_number}.pdf");
    }

    public function preview(Invoice $invoice)
    {
        $pdf = Pdf::loadView('invoices.pdf', compact('invoice'))
            ->setPaper('a4');

        return $pdf->stream("fatura-{$invoice->invoice_number}.pdf");
    }

    public function sendByEmail(Invoice $invoice)
    {
        $pdf = Pdf::loadView('invoices.pdf', compact('invoice'))
            ->setPaper('a4')
            ->output();

        Mail::to($invoice->customer_email)->send(
            new InvoiceMail($invoice, $pdf)
        );

        return back()->with('success', 'Fatura e-posta ile gönderildi.');
    }
}

Üç farklı kullanım:

  • download() — Dosya olarak indirir
  • stream() — Browser'da önizleme açar (indirmeden)
  • output() — Raw PDF çıktısını alır, mail'e eklemek için ideal

Mail ile PDF Gönderme

namespace App\Mail;

use App\Models\Invoice;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class InvoiceMail extends Mailable
{
    public function __construct(
        public Invoice $invoice,
        private string $pdfContent
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "Fatura #{$this->invoice->invoice_number}",
        );
    }

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

    public function attachments(): array
    {
        return [
            Attachment::fromData(fn () => $this->pdfContent, "fatura-{$this->invoice->invoice_number}.pdf")
                ->withMime('application/pdf'),
        ];
    }
}

Route'lar

Route::get('invoices/{invoice}/download', [InvoiceController::class, 'download'])
    ->name('invoices.download');

Route::get('invoices/{invoice}/preview', [InvoiceController::class, 'preview'])
    ->name('invoices.preview');

Route::post('invoices/{invoice}/send', [InvoiceController::class, 'sendByEmail'])
    ->name('invoices.send');

Performans ve İpuçları

  • Ağır PDF'ler için Queue kullanın — 10+ sayfalık raporlar oluşturmak birkaç saniye sürebilir. Job'a atın, hazır olunca bildirim gönderin.
  • Cache'leyin — Aynı fatura PDF'i tekrar tekrar oluşturmayın. storage/app/invoices/ dizinine kaydedin, varsa direkt serve edin.
  • Logo eklemekisRemoteEnabled true olmalı veya logo dosyasını base64 olarak embed edin:
$logo = base64_encode(file_get_contents(public_path('images/logo.png')));
// Template'de: <img src="data:image/png;base64,{{ $logo }}">
  • Sayfa sonu kontrolü — Uzun tablolarda CSS page-break-inside: avoid kullanın

PDF oluşturmak her projenin bir noktasında ihtiyaç duyduğu bir özellik. Fatura, rapor, sertifika, teklif mektubu — hepsi aynı pattern: Blade template + dompdf. Template'i bir kez güzel tasarlayın, geri kalan sadece veriyi doldurmak.