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}}
Size and format
Both are per template, with the values at the root of the config as the fallback. One project can serve a 1200x630 open graph card and a square social card from the same command.
'width' => 1200, 'height' => 630, 'format' => 'png', 'quality' => 85, 'templates' => [ 'post' => ['file' => 'post.svg'], 'photo' => ['file' => 'photo.svg', 'format' => 'jpg'], 'square' => ['file' => 'square.svg', 'width' => 1080, 'height' => 1080], ],
png, jpg and webp are supported. PNG keeps a flat card crisp and lossless; behind a photograph, jpg is a fraction of the size. The renderers only write PNG, so anything else is converted with GD afterwards, and a JPEG is flattened onto white because it has no alpha.
The extension of an explicit ->output() path wins over the configured format, because asking for a .jpg and getting a PNG named .jpg would be worse than any precedence rule.
Placing a block whose height you do not know
A headline can take one line or three, and SVG cannot do arithmetic, so the position has to be computed before the template sees it.
anchor decides which line lands on baseline. The default, top, puts the first line there. With bottom, the last line does, which keeps a short headline and a long one sitting on the same rule instead of drifting down the card.
'title' => [ 'font' => 'default', 'x' => 80, 'max_width' => 1040, 'max_lines' => 3, 'sizes' => [82, 72, 64, 56, 48], 'line_height' => 1.17, 'anchor' => 'bottom', 'baseline' => 434, ],
The template reads the result from {{title_baseline}}, and everything that travels with the headline goes in a group translated to it:
<g transform="translate(0 {{title_baseline}})"> <text x="80" y="-78" font-size="19" fill="#eab308">{{category_label}}</text> <text x="80" y="0" font-size="{{title_font_size}}">{{title_tspans}}</text> </g>
Every block also exposes _bottom, where it actually ends, so a subtitle can hang off a headline of unknown height and keep the same gap in every card. Alongside those, a fit rule fills _tspans, _font_size and _line_count.
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')], ], ],
A third way avoids touching the image at all. rsvg-convert reads fontconfig, and the renderer passes through whatever environment you give it:
'rsvg' => [ 'binary' => 'rsvg-convert', 'env' => ['FONTCONFIG_FILE' => resource_path('cards/fonts.conf')], ],
That file has to list the system font directories too, because it replaces the system configuration rather than adding to it. prefix="relative" resolves against the config file, so the same file works on your laptop and inside a container without knowing the mount path:
<fontconfig> <dir prefix="relative">../../public/fonts</dir> <dir>/usr/share/fonts</dir> <cachedir prefix="relative">../../storage/app/laracards/fccache</cachedir> </fontconfig>
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