Get started with 33% off your first certification using code: 33OFFNEW

How to generate course completion certificates from HTML in Laravel

5 min read
Published on 14th July 2026

When someone finishes a course in your Laravel app they want proof: a certificate they can download, print and show off on LinkedIn. The usual ways of producing one are awkward. DomPDF mangles any CSS more adventurous than a table, and standing up a headless Chrome on your server is a maintenance job you did not sign up for. There is a cleaner route. You design the certificate as an ordinary Blade view, send the rendered HTML to an image API and store the PNG against the user. By the end of this article you will have a CertificateService that turns a course completion into a branded, verifiable certificate image.

Keep the design in Blade

A certificate is just a styled HTML document. The hard part was never the design, it is turning that design into a file that looks identical everywhere it is opened. So keep the design where you already work, in Blade, and hand the rendering to a service that runs the page through real Chromium and gives you a PNG back. This guide uses the HTML to Image API, which is built for exactly this. If you would rather start from a finished layout than build one, there is a ready-made certificate of completion template you can pass values into instead.

The flow is three steps. Render a Blade view to an HTML string, POST that string to the API, then save the returned image against the user and course.

Step 1: Build the certificate view

Design the certificate as a self-contained HTML document with inline styles, so nothing depends on your app's stylesheet when it renders.

{{-- resources/views/certificates/completion.blade.php --}}
<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;font-family:Georgia,serif;width:1600px;height:1130px;
             display:flex;align-items:center;justify-content:center;
             background:#f7f5ef;color:#1f2937;">
  <div style="text-align:center;border:2px solid #c9a13b;padding:80px 120px;background:#fff;">
    <p style="letter-spacing:.3em;color:#c9a13b;font-size:20px;">CERTIFICATE OF COMPLETION</p>
    <h1 style="font-size:54px;margin:24px 0;">{{ $course }}</h1>
    <p style="font-size:20px;color:#6b7280;">This certifies that</p>
    <p style="font-size:40px;margin:8px 0;">{{ $name }}</p>
    <p style="font-size:18px;color:#6b7280;">completed the course on {{ $date }}</p>
    <p style="margin-top:48px;font-size:13px;color:#9ca3af;">
      Verify at {{ config('app.url') }}/verify/{{ $code }}
    </p>
  </div>
</body>
</html>

Everything is driven by four values: the name, the course title, the date and a verification code. The code matters, and we will wire it up at the end.

Step 2: Render the view and call the API

Render the Blade view to a string with view()->render(), then POST it to the /api/html endpoint with Laravel's HTTP client. The API takes your HTML and the dimensions and returns a hosted PNG.

// app/Services/CertificateService.php
namespace App\Services;

use App\Models\Course;
use App\Models\User;
use Illuminate\Support\Facades\Http;

class CertificateService
{
    public function generate(User $user, Course $course): string
    {
        $html = view('certificates.completion', [
            'name'   => $user->name,
            'course' => $course->title,
            'date'   => now()->format('j F Y'),
            'code'   => $this->verificationCode($user, $course),
        ])->render();

        $response = Http::withHeaders([
            'X-API-Key' => config('services.html2img.key'),
        ])->post('https://app.html2img.com/api/html', [
            'html'   => $html,
            'width'  => 1600,
            'height' => 1130,
        ]);

        return $response->throw()->json('url');
    }

    public function verificationCode(User $user, Course $course): string
    {
        return strtoupper(substr(
            hash('sha256', "{$user->id}-{$course->id}-".config('app.key')), 0, 10
        ));
    }
}

Add the key to config/services.php so it is not scattered through your code:

'html2img' => [
    'key' => env('HTML2IMG_API_KEY'),
],

The 1600x1130 canvas is a landscape A4-ish ratio that prints cleanly. The verification code is a deterministic hash of the user, the course and your app key, so the same completion always produces the same code and nobody can guess another person's.

Step 3: Issue the certificate on completion

Rendering takes a second or two, so do it in a queued job rather than in the request. Persist the URL so you never re-render for the same completion.

// app/Jobs/IssueCertificate.php
class IssueCertificate implements ShouldQueue
{
    use Dispatchable, Queueable;

    public function __construct(public User $user, public Course $course) {}

    public function handle(CertificateService $certs): void
    {
        Certificate::updateOrCreate(
            ['user_id' => $this->user->id, 'course_id' => $this->course->id],
            [
                'code'      => $certs->verificationCode($this->user, $this->course),
                'image_url' => $certs->generate($this->user, $this->course),
            ]
        );
    }
}

Dispatch it wherever a completion happens, for example in the listener that marks the final lesson done:

IssueCertificate::dispatch($user, $course);

Store the file on your own disk

The hosted URL is enough for most apps, and it is the fastest path. If you would rather hold the file yourself, download it in the same job and put it on S3 or local storage:

$contents = Http::get($url)->body();
Storage::disk('s3')->put("certificates/{$code}.png", $contents);

If you are issuing certificates to a whole cohort at once, generate them in a batch of queued jobs rather than a loop in one request, so a slow render never holds up the others.

Make the certificate verifiable

A certificate is only worth something if it can be checked. The code printed on the image points at a route on your own site:

Route::get('/verify/{code}', function (string $code) {
    $certificate = Certificate::where('code', $code)->firstOrFail();

    return view('certificates.verify', compact('certificate'));
});

Anyone who visits that URL sees who the certificate was issued to and for which course, which is what separates a real credential from a pretty picture. Drop a QR code of the verify URL onto the certificate view and a phone camera does the checking for you.

That is the whole system: a Blade view for the design, one API call to render it, a queued job to issue it, and a route to verify it. The design lives in HTML where you can change it in seconds, and you never run a browser on your own server to get a pixel-perfect file out.