- Why the pass moment is worth automating
- What goes on the card
- Build the card as a Blade template
- Render the Blade view to a PNG
- Trigger it the instant a learner passes
- Give them a one-click share button
- Render once, then serve from cache
The moment a learner passes your certification is the best marketing moment you will ever get with them, and most platforms waste it on a plain confirmation email. Give them a personalised, share-ready image instead and a good number of them will post it to LinkedIn, which puts your course in front of their whole network. This article shows how to generate that card in Laravel from a Blade template and drop it into the completion flow so it happens automatically.
Why the pass moment is worth automating
A new certificate holder is proud and wants to show it off. That is free reach you do not have to pay for, and it lands on exactly the audience you want: their colleagues, who do similar work and might take the same certification.
The problem is that pride fades within a day. If sharing means the learner has to screenshot a certificate, crop it, and write a caption, almost nobody does it. If instead they finish the exam and immediately see a finished card with a one-click "Share on LinkedIn" button, a real fraction of them will click. The job is to remove every step between passing and posting.
Doing that by hand does not scale. You cannot open a design tool for every learner. So the card has to be generated from data, on the fly, the instant someone passes.
What goes on the card
Keep it to what a viewer takes in during a scroll. A LinkedIn feed image is small, so a busy design loses.
The essentials are the recipient's name, the credential they earned, the issue date, and your brand. A verification link or a small QR code is worth adding if your certificates are verifiable, because it turns the image from a nice graphic into a checkable claim. LinkedIn renders link-preview images at roughly 1200 by 627, so design to that.
Build the card as a Blade template
The trick that keeps this maintainable is to treat the card as a normal Blade view rather than something you draw in code. Designs stay in source control, you can open the view in a browser to preview it, and changing the look is an ordinary pull request.
{{-- resources/views/cards/certified.blade.php --}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;700;800&display=swap');
* { margin: 0; box-sizing: border-box; }
body {
width: 1200px; height: 627px; padding: 72px;
font-family: Manrope, sans-serif; color: #0f172a;
background: linear-gradient(135deg, #4f46e5, #0ea5e9);
display: grid; grid-template-rows: auto 1fr auto;
}
.brand { display: flex; align-items: center; gap: 14px; color: #e0e7ff; font-weight: 700; font-size: 26px; }
.brand img { height: 40px; }
.body { align-self: center; color: #fff; }
.kicker { font-size: 24px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #c7d2fe; }
.name { font-size: 74px; font-weight: 800; line-height: 1.02; margin: 10px 0 18px; }
.cred { font-size: 34px; font-weight: 500; color: #eef2ff; }
.foot { display: flex; justify-content: space-between; align-items: end; color: #dbeafe; font-size: 22px; }
</style>
</head>
<body>
<div class="brand"><img src="{{ asset('img/mark-light.svg') }}"> {{ config('app.name') }}</div>
<div class="body">
<div class="kicker">Certified</div>
<div class="name">{{ $name }}</div>
<div class="cred">{{ $credential }}</div>
</div>
<div class="foot">
<span>Issued {{ $issuedAt->format('j M Y') }}</span>
<span>Verify: {{ $verifyUrl }}</span>
</div>
</body>
</html>
Because a real browser renders this, you can use display: grid, custom fonts, gradients and pseudo-elements freely. That matters, because the two common shortcuts, @vercel/og and its Satori engine, only support a flexbox subset of CSS and will silently drop a grid layout.
Render the Blade view to a PNG
You have two sensible ways to turn that HTML into an image. Self-hosting with Browsershot means installing and maintaining a headless Chromium on every box that renders, which is a real operational cost if you are on serverless or a slim container. The lower-effort option is to POST the rendered HTML to a managed renderer and store the URL it returns.
Wrap that call in a small service so the rest of the app does not care how images are made.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\View;
class ShareCardRenderer
{
public function render(array $data): string
{
$html = View::make('cards.certified', $data)->render();
$response = Http::withHeaders([
'X-API-Key' => config('services.html2img.key'),
])->post('https://app.html2img.com/api/html', [
'html' => $html,
'width' => 1200,
'height' => 627,
])->throw();
return $response->json('url');
}
}
Put the key in config/services.php reading from the environment, never inline, so it stays out of source control:
'html2img' => [
'key' => env('HTML2IMG_KEY'),
],
If you would rather skip writing and maintaining the Blade markup at all, HTML to Image also has a named LinkedIn post template you POST a small JSON payload to, and it returns the styled PNG. That is the fastest route if a tested design suits you; the custom Blade view above is the one to reach for when the card must match your brand exactly.
Trigger it the instant a learner passes
Now wire the renderer into the completion flow so it runs on its own. Fire an event when someone passes, and let a queued listener do the slow work off the request cycle.
// app/Events/CertificationPassed.php
class CertificationPassed
{
public function __construct(public Certificate $certificate) {}
}
// wherever you record a pass
if ($score >= $exam->pass_mark) {
$certificate = Certificate::create([
'user_id' => $user->id,
'exam_id' => $exam->id,
'credential' => $exam->title,
'issued_at' => now(),
'verify_token' => Str::uuid(),
]);
CertificationPassed::dispatch($certificate);
}
// app/Listeners/GenerateShareCard.php
class GenerateShareCard implements ShouldQueue
{
public function __construct(private ShareCardRenderer $renderer) {}
public function handle(CertificationPassed $event): void
{
$cert = $event->certificate;
$url = $this->renderer->render([
'name' => $cert->user->name,
'credential' => $cert->credential,
'issuedAt' => $cert->issued_at,
'verifyUrl' => route('verify', $cert->verify_token),
]);
$cert->update(['share_image_url' => $url]);
}
}
Because the listener implements ShouldQueue, the learner gets their pass result immediately and the image renders in the background. By the time they land on the results page, the URL is usually already stored.
Give them a one-click share button
The card is only useful if posting it takes one tap. LinkedIn shares a URL, not a raw image, so what you share is a page whose Open Graph image is the card you just rendered. Point the share button at the learner's verification page.
@php
$verify = route('verify', $certificate->verify_token);
$share = 'https://www.linkedin.com/sharing/share-offsite/?url=' . urlencode($verify);
@endphp
<a href="{{ $share }}" target="_blank" rel="noopener" class="btn">
Share on LinkedIn
</a>
For LinkedIn to unfurl the card, the verification page has to declare it in its meta tags. Set the generated image as the og:image:
{{-- verify page <head> --}}
<meta property="og:title" content="{{ $certificate->user->name }} is {{ $certificate->credential }} certified">
<meta property="og:description" content="Verified credential issued by {{ config('app.name') }}.">
<meta property="og:image" content="{{ $certificate->share_image_url }}">
<meta name="twitter:card" content="summary_large_image">
Now the learner clicks once, LinkedIn reads the verification page, and their post shows a personalised card that links straight back to a page proving the credential is real. If you want the verification page to have its own distinct share card rather than reusing the certificate image, the same rendering approach works with the Open Graph image template.
Render once, then serve from cache
Rendering is the expensive step, so do it exactly once per certificate. Storing the returned URL on the certificates row, as the listener above does, is most of the caching you need: every later page load and every share reads the stored URL and never triggers another render.
The image itself is served from the renderer's CDN, so your app never proxies the bytes and your own bandwidth is untouched. If a learner corrects their display name, clear share_image_url and re-dispatch the event to render a fresh card. The HTML to Image docs cover webhook delivery if you would rather be notified when a slow render finishes instead of waiting on the response.
That is the whole loop: a learner passes, a card renders itself in the background, and a one-tap button turns their pride into reach for your course. The only ongoing cost is one render per certificate, and every share after that is free.
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