- Keep the design in Blade
- Give every certificate a verifiable identity
- Render it as a vector PDF
- Email it to the learner
- Wire it to course completion
- Batches and tamper evidence
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. A PNG is fine for sharing on LinkedIn, and we covered that route in how to generate course completion certificates from HTML in Laravel. This article produces the document version: a vector PDF with selectable text, a QR code that resolves to a verification page and an email that delivers it automatically.
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>
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();
});
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');
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
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, and we have written before about what that takes in production. 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;
}
}
Add the key to config/services.php so it stays out of the codebase:
'html2img' => [
'key' => env('HTML2IMG_API_KEY'),
],
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. If your design is fixed-width rather than flowing, pass scale_to_fit and it will be scaled onto the page instead of cropped.
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'),
];
}
}
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));
}
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. For badge-shaped credentials that travel between platforms, Open Badges solve a different half of the same problem and pair well with a PDF for the human-facing copy.
From here the work is design polish: your logo, a signature block, brand colours in the Blade view. The pipeline itself is done. Completion fires an event, the service renders a vector PDF with a QR code pointing at your verify route, and the mailable delivers it.
Interested in proving your knowledge of this topic? Take the PHP Fundamentals certification.
PHP Fundamentals
Covering the required knowledge to create and build web applications in PHP.
$99
Related articles
Tutorials WordPress PHP Tooling
How to Build an Image Carousel in Gutenberg
Create an image carousel directly within the Gutenberg editor in WordPress. This step-by-step guide provides all the necessary insights and tips to enhance your posts and pages with visually captivating carousels.