When a learner finishes a course they want a certificate that behaves like a document: something they can print at full quality, attach to a job application and that an employer can check is genuine. In this tutorial you’ll build exactly that in Laravel: a Blade-designed certificate rendered as a vector PDF, with a QR code that resolves to a verification page and an email that delivers it automatically. This post originally appeared on Accreditly as How to generate verifiable PDF certificates in Laravel.
Keep the design in Blade
The certificate is an ordinary Blade view. Everything lives in one file, styles inline, so the markup you preview in the browser is exactly what gets rendered. If you want a designed starting point rather than a blank page, the certificate of completion template is a good base to adapt.
{{-- resources/views/certificates/template.blade.php --}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Georgia, serif; color: #1a2233; margin: 0; }
.certificate { padding: 60px; border: 6px double #b28a2f; margin: 24px; text-align: center; }
.heading { font-size: 15px; letter-spacing: 4px; text-transform: uppercase; color: #b28a2f; }
h1 { font-size: 44px; margin: 24px 0 8px; }
.course { font-size: 22px; margin: 4px 0 28px; }
.issued { font-size: 15px; color: #5a6478; }
.qr { margin-top: 36px; }
.verify-url { font-size: 12px; color: #5a6478; }
</style>
</head>
<body>
<div class="certificate">
<p class="heading">Certificate of Completion</p>
<h1>{{ $certificate->user->name }}</h1>
<p class="course">has completed {{ $certificate->course }}</p>
<p class="issued">Issued {{ $certificate->issued_at->format('j F Y') }}</p>
<div class="qr">
{!! QrCode::size(110)->generate(route('certificates.verify', $certificate)) !!}
</div>
<p class="verify-url">{{ route('certificates.verify', $certificate) }}</p>
</div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode
Two things are doing quiet work here. The design flows like a document rather than being pinned to fixed pixel dimensions, because the PDF renders onto A4 portrait pages. And the QR code is generated as inline SVG, so the renderer has nothing external to fetch.
Give every certificate a verifiable identity
Verification only needs a stable public identifier and a page that resolves it. A UUID on the certificate row is enough.
Schema::create('certificates', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('user_id')->constrained();
$table->string('course');
$table->timestamp('issued_at');
$table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode
The verification route binds on the UUID, so guessing sequential ids gets nobody anywhere:
Route::get('/verify/{certificate:uuid}', function (Certificate $certificate) {
return view('certificates.verify', ['certificate' => $certificate]);
})->name('certificates.verify');
Enter fullscreen mode Exit fullscreen mode
The verify view shows the learner’s name, the course and the issue date. Anyone scanning the QR code on a printed certificate lands on your domain and sees the record, which is the whole trust story: the paper claims it, your database confirms it.
For the QR code itself, pull in the standard package. The default renderer outputs SVG, which is what you want:
composer require simplesoftwareio/simple-qrcode
Enter fullscreen mode Exit fullscreen mode
Render it as a vector PDF
You have three ways to turn that Blade view into a PDF. DomPDF is pure PHP but supports a narrow slice of CSS, and a certificate is exactly the kind of designed layout it mangles. Browsershot renders correctly because it drives real Chrome, but now you are installing and babysitting Chrome on your server. The third route is an HTML to PDF API: you POST the rendered HTML with format: "pdf" and get back a URL to a finished document, rendered by a browser engine you never have to operate.
The service is short:
namespace App\Services;
use App\Models\Certificate;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class CertificateService
{
public function issue(Certificate $certificate): string
{
$html = view('certificates.template', [
'certificate' => $certificate,
])->render();
$response = Http::withHeaders([
'X-API-Key' => config('services.html2img.key'),
])->post('https://app.html2img.com/api/html', [
'html' => $html,
'format' => 'pdf',
])->throw()->json();
$path = "certificates/{$certificate->uuid}.pdf";
Storage::put($path, Http::get($response['url'])->body());
return $path;
}
}
Enter fullscreen mode Exit fullscreen mode
Add the key to config/services.php so it stays out of the codebase:
'html2img' => [
'key' => env('HTML2IMG_API_KEY'),
],
Enter fullscreen mode Exit fullscreen mode
The returned file is a real vector PDF. Text stays selectable and searchable, fonts are embedded and it prints sharp at any size, which is precisely what a certificate needs and what a PNG cannot give you. The page renders with your normal screen CSS, so there is no separate print stylesheet to maintain.
Email it to the learner
Store the PDF once, then attach it from storage in a mailable:
namespace App\Mail;
use App\Models\Certificate;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class CertificateIssued extends Mailable
{
public function __construct(public Certificate $certificate)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your certificate for '.$this->certificate->course,
);
}
public function content(): Content
{
return new Content(markdown: 'emails.certificate-issued');
}
public function attachments(): array
{
return [
Attachment::fromStorage("certificates/{$this->certificate->uuid}.pdf")
->as('certificate.pdf')
->withMime('application/pdf'),
];
}
}
Enter fullscreen mode Exit fullscreen mode
Wire it to course completion
Issue and deliver in the listener for whatever event marks a course as finished:
public function handle(CourseCompleted $event): void
{
$certificate = Certificate::create([
'uuid' => (string) Str::uuid(),
'user_id' => $event->user->id,
'course' => $event->course->title,
'issued_at' => now(),
]);
app(CertificateService::class)->issue($certificate);
Mail::to($event->user)->send(new CertificateIssued($certificate));
}
Enter fullscreen mode Exit fullscreen mode
Push the listener onto a queue and the learner has a verifiable document in their inbox seconds after finishing, with no one on your team touching anything.
Batches and tamper evidence
Two upgrades when volume grows. For bulk issuing, historic cohorts or end-of-term runs, pass a webhook_url with each request and collect the PDFs as they finish instead of holding connections open for the whole batch. And if verification needs to survive scrutiny beyond a database lookup, content hashes and signed identifiers make the document itself tamper-evident; the approach is covered in how to generate signed digital certificates at scale.
In this tutorial you’ve designed a certificate in Blade, rendered it as a vector PDF, given it a QR verification link and delivered it by email on course completion. The full version, including the fixed-width scaling notes and the Open Badges pairing, lives on Accreditly.
How are you issuing certificates in your Laravel apps at the moment? Share your approach in the comments below.
답글 남기기