Search by

bensomething / craft-placard

bensomething

Open Graph images for every entry, made on your own server. No Chrome, no external service.

Package info

github.com/bensomething/craft-placard

Type:craft-plugin

pkg:composer/bensomething/craft-placard

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0-beta.16 2026-10-20 13:51 UTC

README

Placard

Placard

Generates social (OG) cards with Imagick for Craft entries. Cards are rendered on demand and cached under storage/runtime/og-images, which keeps only each entry's latest card and can be emptied from Utilities → Caches.

A card is a combination of four text elements (label, title, description and meta) over a solid colour, with an optional logo and background image.

Note

Placard is in beta. It's ready to try, but its config settings, Twig API and Social Image field may still change before 1.0.0. Please report anything you hit.

Requirements

  • Craft CMS 5.10 or later
  • PHP 8.2 or later
  • The imagick PHP extension. SVG logos also need ImageMagick's SVG support (see Logo)

Installation

composer require bensomething/craft-placard:^1.0.0-beta

The -beta in the constraint is what lets Composer install it under a project's default stable minimum stability.

Or install from the control panel: Settings → Plugins, or from the terminal:

php craft plugin/install placard

Configure

Copy example.config.php to config/placard.php. Settings → Plugins → Placard previews the card your config produces.

sections and entryTypes override any setting except defaultImagePath, keyed by handle. Entry types win over sections. styles are named overrides editors choose between per entry (see Styles).

return [
    'position' => 'bottom',
    'sections' => [
        'artists' => [
            'position' => 'middle',
            'background' => ['colorField' => 'colour'],
        ],
    ],
    'entryTypes' => [
        'featuredArtist' => ['meta' => false],
    ],
];

Paths accept aliases (@root, @webroot) and env vars. The canvas is fixed at 1200×630, the size every platform recommends.

Fonts

No commercial font is bundled. Inter (SIL OFL) is the default, in a weight per element: Bold title, Medium label, Regular description and meta.

Set fontPath to use your own font file throughout, or googleFont to use a Google Fonts family. An element's own fontPath or googleFont (see Elements) gives it a different font. The element's own font wins over the global one, and at each level a path wins over a Google font.

'googleFont' => 'Fraunces',
'title' => ['googleFont' => 'Playfair Display:900'],
'meta' => ['fontPath' => '@root/src/fonts/MyMono.woff2'],

Font files. TTF, OTF, WOFF and WOFF2 all work. WOFF2 needs ImageMagick's FreeType built with Brotli, as current Debian and Ubuntu packages are. A file is one weight, so fontPath uses the same weight everywhere: give the title its own fontPath for a bolder one. A variable font draws in its default instance only.

Google Fonts. Give a family exactly as it's named on Google Fonts, with its capitals and spaces (Playfair Display, not playfair display or PlayfairDisplay), optionally with a weight: Fraunces or Fraunces:700. Without a weight, each element takes Inter's (700 title, 500 label, 400 description and meta), or 400 when the family doesn't have that weight. Variable families come as a static font at the weight you ask for. Each family and weight is downloaded once, from the server rather than visitors' browsers, into storage/runtime/placard/fonts/. Clear it with Utilities → Caches → Placard Google Fonts to pick up an updated family. A few families with axes besides weight, like Roboto Flex, aren't offered as one file: download those and use fontPath.

A font that can't be drawn (a missing or unreadable file, or a Google family that doesn't exist) falls back to Inter, and the reason goes to Craft's log. If Google can't be reached, the card is drawn in Inter but not cached, so it's drawn again with the font once the download works.

Nested fields

Settings that name a field (shareImageFields, the background's imageField and every colorField) also take a path into a Matrix or Content Block field. seo.seoImage reads seoImage from the entries in seo and uses the first one with a value. Paths can go more than one level deep.

'shareImageFields' => ['seo.seoImage', 'hero'],
'background' => ['colorField' => 'seo.colour'],

Card text templates reach nested fields themselves: '{seo.one().seoTitle ?? title}'.

Templates

{% set ogImage = craft.placard.shareImageUrl(entry ?? null) %}
{% if ogImage %}
    <meta property="og:image" content="{{ ogImage }}">
{% endif %}
Helper Returns
shareImageUrl(entry) The image the entry shares (its Social Image field's choice, else the first image in shareImageFields, else the card). Anything that isn't an entry gets defaultImageUrl()
previewImageUrl(entry) As shareImageUrl(), but the card also renders drafts for logged-in users. For the CP, like a field layout's Template element (craft.placard.previewImageUrl(element)), not front-end templates
entryHasImage(entry) Whether that's one of the entry's own images
cardUrl(entry) The card's URL. Only live entries with a URL are served, so drafts and entries without a page (nested entries, sections without URLs) can't be read from it
defaultImageUrl() defaultImagePath, for pages with no entry, or null when it isn't set

With SEOmatic or SEOmate

With SEOmatic or SEOmate installed, Placard gives it each entry's share image. There's nothing to add to your templates: their og:image and twitter:image tags show the Social Image field's choice, or the card.

  • SEOmatic gets it as the entry's SEO, Open Graph and X image, replacing the images its section settings choose. Pages that aren't entries keep SEOmatic's own.
  • SEOmate gets it as image, og:image and twitter:image, as though your layout had set them in the seomate variable. A template that sets seomate.meta.image itself keeps its own. Pages that aren't entries keep SEOmate's own.

Cards are already 1200×630, so neither plugin resizes them. With SEOmatic, use the summary_large_image X card so they show at full size.

Set shareImageFields to the image fields your SEO plugin used, so entries without a Social Image field share the same image as before. Placard reads paths like seo.seoImage, but not SEOmate's field.type:subField form.

To pass the image yourself instead, set 'seoPlugins' => false, then use craft.placard.shareImageUrl() wherever you like.

Social Image field

Lets editors choose what each entry shares (the generated card, a custom image, or one of the entry's image fields) with a preview that updates as they edit. Its settings choose which sources are offered, which image fields are listed and the default. Asset fields inside the entry's Matrix and Content Block fields are listed too, as SEO → SEO Image. A layout can only hold one, and entries without URLs show a note instead.

The field must be in the entry type's own layout (a tab is fine), not inside a Matrix or Content Block field.

Styles

With styles in the config, the field also offers a Style dropdown for the generated card. Each style is a set of overrides like sections, applied after the section's and entry type's, so the editor's choice wins. name labels it in the dropdown, else the handle is used.

'styles' => [
    'light' => [
        'name' => 'Light',
        'background' => '#ffffff',
        'title' => ['color' => '#111111'],
    ],
    'photo' => [
        'name' => 'Photo',
        'background' => ['imageField' => 'hero', 'opacity' => 0.6],
    ],
],

The field's settings choose which styles it offers. The dropdown only shows while the card is what's shared, and Settings → Plugins → Placard previews each style.

Card text

Each element's text is an object template rendered against the entry, given as its text key or as a string in place of the element's array:

'title' => ['text' => '{title}', 'size' => 96],
'label' => '{type.name}',
'description' => '{summary ?: seoDescription}',
'meta' => '{postDate|date("j F Y")}',

HTML is stripped. Description and meta are empty by default, so they don't show until set. A template that errors is logged and leaves that element blank.

DefineCard event

For anything a template can't do:

use bensomething\placard\events\DefineCardEvent;
use bensomething\placard\services\Generator;
use yii\base\Event;

Event::on(Generator::class, Generator::EVENT_DEFINE_CARD, function (DefineCardEvent $e) {
    if ($e->entry->section?->handle === 'home') {
        $e->title = 'My Brand';
        $e->label = '';
    }
});

The event has title, label, description, meta, backgroundImage and backgroundColor, plus the card's resolved settings, which can be changed for that card alone. An empty value drops that element.

Per site

There are no per-site settings in the config yet, but the event can change a card's settings by the entry's site. setAttributes() takes the same keys as config/placard.php, and its false lets it set any of them:

Event::on(Generator::class, Generator::EVENT_DEFINE_CARD, function (DefineCardEvent $e) {
    if ($e->entry->getSite()->handle === 'fr') {
        $e->settings->setAttributes([
            'background' => '#002395',
            'title' => ['googleFont' => 'Fraunces'],
        ], false);
    }
});

Use the entry's site rather than the current one, as cards are also drawn by queue jobs and placard/generate, outside the site's own requests.

Console

php craft placard/preview 123                    # an entry's card
php craft placard/preview --title="Hello world"  # a dummy card

Also takes --label, --description, --meta and --image. Output goes to storage/runtime/og-images-preview/.

Cards render the first time they're asked for, and are cached after that. Crawlers like Facebook's and LinkedIn's give up on slow images, so saving an entry also renders its card in a queue job (see renderOnSave). Bulk resaves (resave/entries) skip this, as do config changes, which change every card. To render those in one go:

php craft placard/generate                          # every entry's card
php craft placard/generate --section=news --site=en # comma-separate several handles

Also takes --type. Cards already cached are skipped, as are entries that share an image of their own.

Settings

Setting Default Notes
position bottom top, middle, bottom or between (first and last groups at the edges)
layout ['logoLabel', 'title', 'description', 'meta'] What's drawn, in order, and how it's grouped, see Layout
align left left, center or right
textEdges caps Place by cap height to baseline (caps) or by the letters' ink (ink)
padding 80 Inset for the text in px, as CSS shorthand: one value, [vertical, horizontal], [top, horizontal, bottom] or [top, right, bottom, left]. Text wraps at the width between the sides, and center centres within them
gap 24 Space below each element in px, between line boxes, so the visible space also takes in line height and descenders. With between, gaps between groups are minimums. Elements can set their own
fontPath Inter Font file for every element, unless it sets its own, see Fonts. Unset, each element uses the bundled Inter in its own weight (Bold title, Medium label, Regular description and meta)
googleFont none Google Fonts family for every element, as Fraunces or Fraunces:700, see Fonts. fontPath wins over it
shareImageFields ['seoImage', 'hero', 'featuredImage'] Share image fields for entries without a Social Image field
defaultImagePath none Returned by defaultImageUrl(), for pages with no entry
format auto png, jpeg, or auto for JPEG when the card has a background image and PNG otherwise. Photos are several times smaller as JPEG, and flat cards keep crisper text as PNG
jpegQuality 85 1 to 100, for cards saved as JPEG
renderOnSave true Render an entry's card in a queue job when it's saved, so the first crawler to ask finds it cached, see Console
seoPlugins true Give SEOmatic and SEOmate each entry's share image, see With SEOmatic or SEOmate
sections, entryTypes [] Overrides by handle
styles [] Named overrides editors choose per entry, see Styles
label, title, description, meta Each element's settings, see Elements
logo, background, gradient, border See below

Element, logo, background, gradient and border settings are each an array under their name. In sections and entryTypes, an array changes only the keys it names, so 'border' => ['color' => '#ff0000'] keeps the border's widths.

Layout

layout lists what's drawn, from the top: label, title, description, meta, logo for the logo on a row of its own, and logoLabel for the logo beside the label. An array groups items. Anything left out isn't drawn.

'position' => 'between',
'layout' => [
    ['logoLabel'],
    ['title', 'description', 'meta'],
],

Items in a group keep their own gaps. With between, the first group sits at the top, the last at the bottom, and any between are spaced evenly, so this puts the logo and label at the top and the rest together at the bottom. Other positions stack the groups as one. Each item can appear once, and logoLabel counts as both the logo and the label.

Leave layout unset and cards stack as before, with each element its own group and in the order of: logoLabel, title, description, and meta. Like any setting, sections, entryTypes and styles can override it.

Elements

label, title, description and meta each take an array of their settings. A string sets just the text, and false hides the element.

'title' => [
    'text' => '{title}',
    'size' => 96,
    'lineHeight' => 1.05,
    'maxLines' => 2,
],
'description' => ['text' => '{summary}', 'lineHeight' => 1.3],
'meta' => false,
Key Default (label, title, description, meta) Notes
text {type.name}, {title}, empty, empty Object template, see Card text
show true Turn the element off. Hiding the label keeps the logo
size 28, 116, 32, 28 Text size in px
lineHeight 1.02 A multiple of the size, as in CSS. The extra space is shared above and below each line
maxLines 1, 3, 2, 1 Lines before the text is clamped with an ellipsis. A title needing fewer is balanced across the lines it uses
uppercase false, true, false, false Set the text in capitals
color #a3a3a3, #ffffff, #a3a3a3, #a3a3a3 Text colour
colorField none A Color field (or hex text field) to take the colour from, falling back to color
fontPath none The element's font file, over fontPath and googleFont
googleFont none The element's Google font, over fontPath and googleFont. The element's own fontPath wins over it
gap none Space below the element, over gap
align none left, center or right, over align. The label's also aligns a logo beside it

Logo

'logo' => ['path' => '@webroot/img/logo.png', 'height' => 40],

A string sets just the path, and false hides the logo.

Key Default Notes
path none PNG or SVG drawn beside the label, or on its own row (see Layout)
height 48 Height in px. Its width follows its proportions
show true Turn the logo off
align none left, center or right on its own row, over align. Beside the label, it follows the label's

A PNG works on every server. Export it at about four times height so it stays sharp. SVG logos need ImageMagick with SVG support, which many servers don't have; without it the card is drawn with no logo and a warning is logged. Where it is available, ImageMagick's built-in SVG renderer can't draw masks, gradients or filters, so an SVG using them may come out wrong.

Background

'background' => [
    'color' => '#1f1f1f',
    'imageField' => 'hero',
    'opacity' => 0.4,
    'blendMode' => 'multiply',
],

A string sets just the colour. The image is the imageField's, then the share image (with useShareImage), then imagePath. With a Social Image field, the share image is the custom image or image field the editor picked. With the card picked, or no field, it's the first image in shareImageFields.

Images from asset fields must be JPEG, PNG, GIF, WebP or AVIF. Anything else, such as an SVG, is skipped with a warning in the log, as ImageMagick chooses how to read a file from its contents rather than its extension. imagePath is set in config, so it isn't checked.

Key Default Notes
color #000000 Canvas colour
colorField none A Color field (or hex text field) to take the colour from, falling back to color
imageField none Asset field for the background image
useShareImage false Fall back to the share image as the background: the Social Image field's pick, else the first image in shareImageFields
imagePath none Fixed background image, used last
fit cover cover (cropped around the focal point) or contain
padding 0 Inset in px, as CSS shorthand like the main padding, with the canvas colour around it. [0, 0, 0, 600] puts the image on the right half
radius 0 Corner radius in px
grayscale false Set the image in grayscale
opacity 0.25 Strength of the image. A transparent PNG keeps its transparency, scaled by this
imagePathOpacity none opacity for the imagePath image only, so a section can show its photos stronger than the pattern it falls back to
blur 0 Blur strength in px, roughly as CSS blur()
blendMode normal How the image blends with the colour: multiply, screen, overlay, darken, lighten, color-dodge, color-burn, hard-light, soft-light, difference or exclusion, as in CSS

Gradient

'gradient' => ['color' => '#000000', 'from' => 'bottom', 'extent' => 0.6],

A fade from an edge or corner of the card to clear, drawn over the background image and under the border, to keep text readable over a photo without lowering its opacity. A string sets just the colour, so 'gradient' => '#000000' is a fade up from the bottom. There's no gradient until it has a colour, and 'gradient' => '' turns it off for a section or style.

Key Default Notes
color none Gradient colour
colorField none A Color field (or hex text field) to take the colour from, falling back to color
from bottom Where it starts: an edge (top, right, bottom, left) or a corner (topLeft, topRight, bottomLeft, bottomRight). Corners fade as CSS's to bottom right and the like, halfway along at the other two corners
extent 0.7 How far it reaches across the card, from 0 to 1. From a corner, 1 reaches the opposite corner
opacity 0.8 Strength where it starts, from 0 to 1. It eases out to clear

Border

'border' => ['width' => [8, 0, 0, 0], 'color' => '#f5b400'],

A number or list sets just the widths, so 'border' => [8, 0, 0, 0] is a bar along the top in the default colour.

Key Default Notes
width 0 Widths in px, as CSS shorthand like padding. Drawn over the background image, and the text sits inside it
color #ffffff Border colour
colorField none A Color field (or hex text field) to take the colour from, falling back to color

Licence

MIT. Bundled Inter fonts are SIL OFL 1.1 (see src/resources/fonts/OFL.txt).