Open source August 2026
Laracards

Laracards

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, or an image template (PNG, JPG or WebP) with the text drawn straight onto it by GD and no binary at all. 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

SVG templates also need one renderer binary on the machine (image templates do not, see "Image templates" below):

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.

Image templates, no SVG and no binary

A template can also be a PNG, JPG or WebP. Laracards sees the extension and draws the text straight onto the image with GD, the same extension it already uses to measure text, so no renderer binary is needed and no other PHP library is pulled in. It is the way to keep an existing design that lives in an image, or to generate cards on a server where librsvg cannot be installed.

An image carries no positions, so the fit rules say where each block goes and how it looks. They are the same rules an SVG template uses, plus the two things an SVG would otherwise hold itself: color (#rgb, #rrggbb, #rrggbbaa, rgb() or rgba(), white by default) and align (left, center or right, left by default). With center, x is the centre of each line; with right, its right edge.

'og' => [
    'file' => 'og.png',
    'fit' => [
        'title' => [
            'font' => 'bold', 'x' => 600, 'max_width' => 480, 'max_lines' => 3,
            'sizes' => [44], 'line_height' => 1.2,
            'color' => 'rgba(255,255,255,0.9)', 'align' => 'center',
            'baseline' => 300,
        ],
    ],
],

Every block needs a place: a baseline, as above, or a spot in a stack. A stack measures a group of blocks and centres the whole group on center_y, each block below the previous one with gap between them. A block that comes out empty drops out, so a card without a subtitle keeps its title centred:

'stack' => ['fields' => ['title', 'subtitle'], 'center_y' => 315, 'gap' => 34],

stack works for SVG templates too: it fills _baseline and _bottom for the stacked blocks.

The card background, if the card has one, is drawn under the image, so a template with transparent areas works as an overlay on a photo, just as __BACKGROUND_URI__ does in an SVG. Size, format, the manifest and the command behave exactly as with SVG templates. For image templates the template config is part of the fingerprint, since that is where colours and positions live: changing a colour regenerates the cards drawn with it.

When every configured template is an image, cards:generate runs without a renderer binary. An SVG template on a machine without one fails with a clear error.

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, with SVG templates, 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