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

How to generate event tickets with QR codes from HTML

4 min read
Published on 13th August 2026

Tickets are a design problem and a trust problem in one artefact. The design part is easy if you can use the tools you already know, and the trust part is easy if the QR code carries something only your application could have produced. This article builds both in Laravel: a ticket laid out in HTML and CSS, a signed QR code, a PNG render you can email, and the door-side check that stops a screenshot being used twice.

Lay the ticket out in HTML

Skip the image editor. A ticket is a card with two zones, the human side and the machine side, and CSS handles that in a dozen lines.

<div class="ticket">
  <div class="stub">
    <p class="event">LARACON EU 2027</p>
    <h1>Sarah Chen</h1>
    <p class="meta">Fri 5 Feb 2027 &middot; Doors 08:30 &middot; Seat B41</p>
  </div>
  <div class="scan">
    <img src="{{ $qr }}" width="200" height="200" alt="">
    <p class="ref">{{ $ticket->reference }}</p>
  </div>
</div>
.ticket {
  display: flex;
  width: 900px;
  font-family: 'Inter', sans-serif;
  background: #0f172a;
  color: #fff;
  border-radius: 20px;
  overflow: hidden;
}
.stub { flex: 1; padding: 44px; }
.scan {
  width: 280px;
  background: #fff;
  color: #0f172a;
  border-left: 4px dashed #cbd5e1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
}
.event { letter-spacing: 0.2em; color: #38bdf8; font-size: 14px; }
h1 { font-size: 40px; margin: 8px 0; }
.meta { color: #94a3b8; }
.ref { font-family: monospace; letter-spacing: 0.1em; }

Because it is a Blade view, personalising a thousand tickets is a loop, not a design task.

Put something signed in the QR code

The QR code should not contain ticket data. Anyone can generate a QR code that says {"seat": "B41", "paid": true}. Encode a URL that only your application could have produced, and let the door scan resolve it.

Laravel's signed routes do the cryptography for you:

use Illuminate\Support\Facades\URL;

$url = URL::signedRoute('tickets.verify', ['ticket' => $ticket->uuid]);

Then turn the URL into an image with endroid/qr-code, and keep it as a data URI so the code is baked into the HTML rather than fetched from anywhere:

use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\PngWriter;

$builder = new Builder(
    writer: new PngWriter(),
    data: $url,
    size: 400,
    margin: 16,
);

$qr = $builder->build()->getDataUri();

The data URI matters for the next step. The render happens in a headless browser on someone else's infrastructure, and an inlined image means there is nothing for that browser to fetch and no private route to expose.

Render the ticket to a PNG

Now hand the populated view to an HTML to image API and store the hosted result. Render at dpi: 2; a crisp QR code scans faster from a scuffed phone screen in a dark doorway.

use Illuminate\Support\Facades\Http;

$html = view('tickets.card', [
    'ticket' => $ticket,
    'qr' => $qr,
])->render();

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

$ticket->update(['image_url' => $response->json('url')]);

There is a worked QR code example in the HTML to Image docs if you want to see the render in isolation. From here the PNG goes wherever tickets go: attached to the confirmation email, shown on the order page, or both.

Check it at the door

The verify route does two jobs: confirm the signature and burn the ticket so it cannot be admitted twice.

Route::get('/tickets/verify/{ticket:uuid}', VerifyTicketController::class)
    ->name('tickets.verify')
    ->middleware('signed');
class VerifyTicketController extends Controller
{
    public function __invoke(Ticket $ticket)
    {
        if ($ticket->admitted_at) {
            return response()->json([
                'status' => 'already-admitted',
                'admitted_at' => $ticket->admitted_at->toDateTimeString(),
            ], 409);
        }

        $ticket->update(['admitted_at' => now()]);

        return response()->json([
            'status' => 'ok',
            'name' => $ticket->holder_name,
            'seat' => $ticket->seat,
        ]);
    }
}

The signed middleware rejects any URL whose signature does not match, so edited or invented QR codes bounce with a 403 before your controller runs. A screenshot of a real ticket scans once; the second scan gets the 409 and door staff see it instantly.

One honest caveat: this design assumes the door has connectivity. A field with no signal needs a pre-synced attendee list and offline HMAC checks, which is a different article.

Where to take it next

The same view, render, store pipeline powers course completion certificates, and swapping the Blade template is all it takes to move between them. If your event wants tickets in Apple or Google Wallet, those are separate formats built from JSON rather than HTML, worth a v2 once the PNG pipeline is earning its keep.