Laracards

Laracards generates social cards and blog covers for Laravel. A card is an SVG template with placeholders, filled in PHP and rasterized by a single system binary. No headless browser, no Node, no image library beyond GD.
The part that usually forces people back to a browser is the background. Here it is just another SVG layer embedded as a data URI, so the same template takes a flat colour, an Unsplash photo or a PNG produced somewhere else.
Installation
composer require edulazaro/laracards
You also need one renderer binary on the machine:
apt install librsvg2-bin
That gives you rsvg-convert, the default. The alternative is resvg, a self-contained Rust binary with better SVG2 support and, more importantly, a --font-file flag.
Publish the config and the example template:
php artisan vendor:publish --tag=laracards-config php artisan vendor:publish --tag=laracards-templates
Generating a card
use EduLazaro\Laracards\Card; Card::make('why-agents-are-more-than-a-model') ->template('post') ->data([ 'category_label' => 'DESARROLLO', 'title' => 'Por qué un agente de IA es mucho más que llamar a un modelo', 'author_name' => 'Edu Lazaro', 'date_formatted' => '7 de agosto, 2026', ]) ->output(public_path('img/blog/why-agents.png')) ->generate();
title is declared as a fit block in the config, so the generator turns it into {{title_tspans}} and {{title_font_size}} before the template sees it.
Backgrounds
Three drivers, one code path. All of them resolve to a local file that gets embedded through __BACKGROUND_URI__.
$card->background(null); // the template paints its own $card->background(public_path('img/ai/hero.png')); // anything already on disk $card->unsplash('mountain fog'); // downloaded once, then cached
The Unsplash driver caches by query, so re-running the command never hits the API twice and the same query always yields the same card. Deleting the cached file is how you ask for a different photo.
Templates
A template is a plain SVG with two kinds of placeholder and one kind of section.
| Form | Use |
|---|---|
{{key}} |
Text, XML-escaped for you |
__KEY__ |
Values that are not escapable content, such as data URIs |
{{#key}}...{{/key}} |
A block dropped entirely when the key has no value |
Sections matter more than they look. An optional layer has to disappear, not render empty: an <image> with href="" makes librsvg abort, so "no background" cannot mean "a background with no source".
{{#background_uri}}
<image x="0" y="0" width="1200" height="630"
preserveAspectRatio="xMidYMid slice"
href="__BACKGROUND_URI__"/>
{{/background_uri}}
Text fitting is measured, not estimated
Wrapping a title by character count gives every glyph the same budget, so a title made of wide words silently runs off the card. Laracards measures with GD against the same font file, picks the largest candidate size that fits in the allowed number of lines, and only ellipsizes when even the smallest one does not.
For a 1040px column starting at 82px:
| Title | By character count | Measured |
|---|---|---|
MMMMMMMM MMMMMMMM MMMMMMMM |
2 lines, 1672px wide, 632px off the card | 3 lines, 818px |
Indemnizaciones millonarias por incumplimiento… |
5 lines, truncated to 3 | 3 lines at 56px, full title |
Keep laracards.fonts pointing at the same faces your templates declare. Measuring with one font and rendering with another drifts silently.
Sources and the command
A CardSource maps a content collection to cards. One class per kind of content, instead of one artisan command per kind of content.
use EduLazaro\Laracards\Card; use EduLazaro\Laracards\Contracts\CardSource; class BlogPostCards implements CardSource { public function __construct(private BlogService $blog) {} public function cards(): iterable { foreach ($this->blog->all(includeScheduled: true) as $post) { yield Card::make($post->slug) ->template('post') ->data([ 'title' => $post->title, 'category_label' => mb_strtoupper($post->category), 'author_name' => $post->author, 'date_formatted' => $post->date->translatedFormat('j \d\e F, Y'), ]) ->background($post->cover) ->output(public_path("img/blog/{$post->slug}.png")); } } }
Register it under sources in the config and run:
php artisan cards:generate php artisan cards:generate --source=post --only=some-slug --force php artisan cards:generate --dry-run
Regeneration is decided by content, not by file existence
Every generated card records a fingerprint of its template, payload and background in a manifest. Change the title, move the publish date, swap the photo, and the card regenerates by itself on the next run. Change nothing and nothing is rewritten.
That is the whole point of the manifest. Deciding by "does the file exist?" means a card with the publish date printed on it has to be deleted by hand every time the date moves, which is exactly the kind of step nobody remembers.
Fonts in production
The SVG declares its own font-family, and rsvg-convert resolves it through fontconfig. A brand face that is not installed on the machine falls back to something else without an error, and the card stops matching what you saw locally.
Either install the face in the container, or switch renderer and list the files:
'renderer' => 'resvg', 'renderers' => [ 'resvg' => [ 'binary' => 'resvg', 'font_files' => [public_path('fonts/Lato-Bold.ttf')], ], ],
Generate locally, commit the output
Cards are static. Generating them on request puts a render on the hot path and a binary dependency in the web container. Run the command when content changes and commit the images.
built and maintained by Edu Lazaro · MIT license