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

How to generate blog heroes, quote cards and podcast covers from HTML in Laravel

10 min read
Published on 3rd June 2026

Any team publishing content at scale ends up with a strange portfolio of image work. The blog post has a hero image. The newsletter has a banner across the top. Every pull-quote that goes to social has a quote card. The podcast has a cover, plus a per-episode card for shares. Five different formats, five different dimensions, and almost always five separate Figma files, kept loosely in sync by whoever is on duty.

When the brand refreshes (which it will), you redo all five. When the content team wants to ship a quote card without bothering the designer (which they do), the version that goes out is off-brand. The image stack scales linearly with content output, which is the wrong shape.

This article walks through how to render the entire content publishing image stack from one HTML system in Laravel. The Blade templates live in your repo, the brand variables live in one CSS file, and a brand refresh becomes a single deploy. Working code, the gotchas worth knowing about, and the trade-offs of each approach.

The five formats

Five image types come up over and over in content publishing.

Blog hero. 1600 by 900, sometimes 1920 by 1080. Sits at the top of the article. Contains the title, category, optionally an author. Doubles as the Open Graph image on some sites.

Quote card. 1200 by 1200 square. A pull-quote, attribution, brand mark. Shared on Twitter, LinkedIn, Instagram. The most-shared piece of content for most editorial teams.

Podcast cover. 1500 by 1500 square, occasionally larger. Apple Podcasts and Spotify use this. Show name, host name, tagline. Updates rarely after launch.

Podcast episode card. 1200 by 630. Per-episode share image. Episode number, title, guest. Goes out with every release.

Email header. 1200 by 300 banner. Sits at the top of the newsletter template. Issue number or campaign name, the date, brand mark.

These look like five separate design problems. The plumbing underneath is identical: take some content data, fill in a Blade template, render it to a PNG, cache it, embed it. The work of solving any one of them solves the other four.

Why a Figma file per format does not scale

The first version most teams ship is a folder of Figma files. One per format. The content team opens the right file, copies the artboard, fills in the text, exports, uploads. This works for the first dozen articles or episodes. The friction shows up at three points.

The first is brand drift. When the same template is opened by three different people across six months, three slightly different versions ship. The colour gets nudged, the font weight changes, the padding around the title varies. It looks like one brand from any single touchpoint and like a slightly haphazard one across all of them.

The second is brand refresh cost. When the colour palette changes (which happens every couple of years), the brand designer has to update five files individually. The content team then needs to re-export every historical image, or accept that the back catalogue looks dated.

The third is content-team bandwidth. Every quote card requires a designer in the loop. The fastest content teams ship social pull-quotes within hours of an article going live. A Figma round-trip kills that cadence.

The fix is to move the templates from Figma into HTML, then render the HTML to a PNG when content data lands. The content team supplies the text. The designer maintains the templates. The brand variables live in one place.

Three approaches

The same three places to do the rendering as for any HTML to image problem.

Approach 1: Stay in Figma

Workable if you ship a small amount of content per week and have a designer with capacity. Not what this article is about.

Approach 2: Headless Chromium on your own infrastructure

Laravel with Spatie's Browsershot, or Node with Puppeteer. You own the Chromium binary, the memory budget, font pre-warming, and version pinning. The honest cost of this stack is covered in detail in the dynamic Open Graph images walk-through. Same trade-offs apply.

Approach 3: A managed HTML to image API

POST the HTML, receive a PNG URL. For the rest of this article we use the html2img.com HTML endpoint. Any equivalent service follows the same pattern.

The shared renderer

One Laravel service that handles all five formats. The format controls the viewport size, the Blade view, and the cache key.

namespace App\Services;
 
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
 
class EditorialImageRenderer
{
    private const FORMATS = [
        'blog-hero'    => ['w' => 1600, 'h' => 900,  'view' => 'editorial.blog-hero'],
        'quote-card'   => ['w' => 1200, 'h' => 1200, 'view' => 'editorial.quote-card'],
        'podcast-cover'=> ['w' => 1500, 'h' => 1500, 'view' => 'editorial.podcast-cover'],
        'episode-card' => ['w' => 1200, 'h' => 630,  'view' => 'editorial.episode-card'],
        'email-header' => ['w' => 1200, 'h' => 300,  'view' => 'editorial.email-header'],
    ];
 
    public function render(string $format, array $data): string
    {
        $config = self::FORMATS[$format]
            ?? throw new \InvalidArgumentException("Unknown format: {$format}");
 
        $key = $this->cacheKey($format, $data);
 
        if (Storage::disk('s3')->exists($key)) {
            return Storage::disk('s3')->url($key);
        }
 
        $html = view($config['view'], $data)->render();
 
        $response = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
            ->timeout(30)
            ->post('https://app.html2img.com/api/html', [
                'html' => $html,
                'width' => $config['w'],
                'height' => $config['h'],
                'wait_for_selector' => '.ready',
            ])
            ->throw()
            ->json();
 
        $bytes = Http::get($response['url'])->throw()->body();
        Storage::disk('s3')->put($key, $bytes, 'public');
 
        return Storage::disk('s3')->url($key);
    }
 
    private function cacheKey(string $format, array $data): string
    {
        ksort($data);
        $hash = md5(json_encode([...$data, '_format' => $format, '_v' => '1']));
        return "editorial-images/{$format}/{$hash}.png";
    }
}

The wait_for_selector is set to .ready, with the convention that every Blade view ends with a <div class="ready"> element after all the meaningful content. The screenshot fires once that div is in the DOM, which means everything above it has finished rendering. Simpler than picking a content-specific selector per template, and consistent across formats.

The cache layer hashes the data plus a template version. Bumping the version field invalidates every cached image, which is your brand-refresh switch.

The shared brand foundation

Before the per-format Blade views, a partial that all five include. This is where the brand language lives.

{{-- resources/views/editorial/_brand.blade.php --}}
<style>
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;800;900&family=Fraunces:wght@600;800;900&display=swap');
  * { margin: 0; padding: 0; box-sizing: border-box; }
  :root {
    --brand-bg: #FFFCF5;
    --brand-fg: #1A1A1A;
    --brand-accent: #C2410C;
    --brand-muted: #6B7280;
    --brand-divider: #E5E7EB;
    --font-display: 'Fraunces', Georgia, serif;
    --font-body: 'Inter', sans-serif;
  }
  body {
    background: var(--brand-bg);
    color: var(--brand-fg);
    font-family: var(--font-body);
  }
  .brand-mark {
    font-family: var(--font-display);
    font-weight: 900;
    letter-spacing: -0.02em;
  }
  .ready { visibility: hidden; height: 0; }
</style>

Every per-format view begins with @include('editorial._brand'). When the brand refreshes, you change the CSS variables in this one file, bump the cache version, and every image in the back catalogue regenerates the next time it is requested. No designer hours, no historical drift.

Walk-through: the blog hero

The blog hero sits at the top of an article. The data shape is straightforward: title, category, optional author.

{{-- resources/views/editorial/blog-hero.blade.php --}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
@include('editorial._brand')
<style>
  body { width: 1600px; height: 900px; padding: 100px; display: grid; grid-template-rows: auto 1fr auto; }
  .category {
    font-size: 18px; font-weight: 700; letter-spacing: 4px;
    text-transform: uppercase; color: var(--brand-accent);
  }
  h1 {
    font-family: var(--font-display);
    font-size: 110px; font-weight: 900;
    line-height: 1.02; letter-spacing: -2px;
    align-self: end; max-width: 1300px;
  }
  footer { font-size: 24px; color: var(--brand-muted); display: flex; justify-content: space-between; align-items: end; }
  footer .author { font-weight: 700; color: var(--brand-fg); }
  .divider { width: 80px; height: 4px; background: var(--brand-fg); margin: 16px 0 32px; }
</style>
</head>
<body>
  <div>
    <div class="category">{{ $category }}</div>
    <div class="divider"></div>
  </div>
  <h1>{{ $title }}</h1>
  <footer>
    <span><span class="author">{{ $author }}</span> · {{ $published_at }}</span>
    <span class="brand-mark">{{ config('app.brand') }}</span>
  </footer>
  <div class="ready"></div>
</body>
</html>

The display font (Fraunces) is doing most of the visual work. Title rendered at 110px in a serif feels editorial in a way that 110px in a sans-serif does not. The category line at the top, the title in the middle, the author line at the bottom: three rows in a grid, anchored top and bottom, title in the centre.

Triggering it from the article model looks like this:

class Article extends Model
{
    public function heroImageUrl(): string
    {
        return app(EditorialImageRenderer::class)->render('blog-hero', [
            'title' => $this->title,
            'category' => $this->category->name,
            'author' => $this->author->name,
            'published_at' => $this->published_at->format('jS F Y'),
        ]);
    }
}

Embedded in the article template, the hero image is a plain <img> tag pointing at the URL on your CDN. The same URL doubles as the OG image in the page head.

The other four formats

The quote card, podcast cover, episode card, and email header follow the same pattern. One Blade view each, sized for the viewport, sharing the brand partial. Rather than reproduce four more templates here, the data shapes look like this:

Quote card at 1200 by 1200. Inputs: quote, attribution, optional avatar_url. The display font sets the quote in large serif italic with a big quotation-mark glyph in the top-left as an accent.

Podcast cover at 1500 by 1500. Inputs: show_name, host, tagline, optional episode_count. Square format suitable for Apple Podcasts and Spotify directories. Renders rarely (when the show identity changes).

Episode card at 1200 by 630. Inputs: episode_number, title, guest, optional topic. Per-episode share image. Generated automatically when each episode is published.

Email header at 1200 by 300. Inputs: issue_number, volume, date. Banner across the top of the newsletter template. Generated weekly, monthly, or whenever the cadence is.

If you would rather not maintain the Blade views, html2img exposes a tested template per format that accepts the same data shape and returns a sized PNG: blog hero, quote card, podcast cover, podcast episode card, and email header. The trade-off is the same as elsewhere: the template has an opinion about layout, your HTML has none. For brands that have settled, the templates are faster to ship. For brands still finding their visual voice, the HTML endpoint with your own Blade views gives the design control.

The brand refresh story

The actual payoff of this pattern shows up the first time the brand changes. The colour palette shifts from a warm cream to a cool slate. The display font moves from Fraunces to Newsreader. Three sizes of body text become two.

In the Figma-file-per-format world this is a fortnight of designer time, followed by a manual export-and-replace pass across hundreds of historical pieces of content. The back catalogue stays at the old brand until somebody touches it.

In the HTML-template world it is a five-minute edit to editorial/_brand.blade.php, a cache version bump, and a deploy. Every image regenerates on next request. The back catalogue is up-to-date inside a working day. The content team did not get involved.

The brand refresh is the moment this pattern earns its keep.

Gotchas

Things worth knowing before shipping.

Quote card line breaks. Pull quotes are unpredictable in length. A nine-word quote at 96px looks balanced. A 32-word quote at the same size overflows the card. Use CSS to drop the font size for longer text, or hard-cap the input length in your editorial tooling.

Podcast cover compliance. Apple Podcasts has minimum-size and JPEG-or-PNG requirements that change occasionally. 1500 by 1500 PNG is the safe choice as of writing. Check the current spec before launching a show; the directory rejects covers that fall outside the rules and the support turnaround is slow.

Email header transparency. Some email service providers strip background colours from images that have transparent edges. Make sure your email header has an opaque background that matches the email template's surrounding colour, or accept that some clients will render an awkward fringe.

Don't reference external image assets inside the HTML. Logos as inline SVG, avatars as base64 data URIs, hero photographs either inlined or pre-uploaded to your own CDN. External references slow the render and occasionally fail. The single biggest source of "the image looks wrong" tickets is an unreliable image URL inside the template.

Cache busting on content edits. If an article title changes after publishing, the existing hero image is stale. The data-hash cache key handles this automatically: change the title, the hash changes, the next request regenerates. You do not need extra plumbing.

Tabular numerals for issue numbers and episode counts. font-variant-numeric: tabular-nums on any element showing "Issue 12" or "Episode 047" makes the numbers line up visually across templates. Subtle, immediate improvement.

Picking an approach

The decision tree for the editorial image stack:

  • Small content team, monthly output, designer on the team. Stay with Figma. The friction is not yet costing you enough to justify the engineering work.
  • Daily output, engineering capacity, infrastructure ownership is fine. Browsershot on a queue. Pay the Chromium tax, gain full control, keep maintaining it.
  • Any content cadence, brand consistency matters, content team needs to ship without the designer in the loop. The shared renderer above. Either the HTML endpoint for design control, or the per-template endpoints for zero layout work, or both depending on the format. The most common production setup is a mix. The blog hero and quote card sit in Blade views in the repo, because those are the most-shared and most brand-defining. The podcast cover, episode card, and email header use the pre-built templates, because the layout is settled and the designer's time is better spent elsewhere.

If you already shipped the transactional image stack from the previous article in this series, the editorial stack is the same renderer with a different format dictionary. Same caching, same wait-selector convention, same Mailable-style integration into the rest of the app. The work of standing it up is measured in afternoons, not weeks.