OG images in Laravel
The fastest path for a Laravel (or any PHP) app is the signed GET /i/{keyId}/{sig}?...params URL: it renders on first request and serves from cache after that, so it can go straight
into a Blade layout's <meta property="og:image"> with no
call to the ogmake API from your app at request time.
1. Get a key
Sign in at /login and create a key in the dashboard. The signing secret
is shown once — save it in your app's .env as, for example,
OGMAKE_SIGNING_SECRET.
Still on a hand-issued beta key? Email support@ogmake.com.
2. Install the Composer package
ogmake/laravel wraps the signing call, config and a Blade component so nothing in your app builds the canonical string by hand:
composer require ogmake/laravel php artisan vendor:publish --tag=ogmake-config
Add the key from step 1 to your app's .env:
OGMAKE_KEY_ID=k_... OGMAKE_SIGNING_SECRET=base64-secret-shown-once-when-the-key-was-issued OGMAKE_BASE_URL=https://ogmake.com
Build a signed URL with the Ogmake facade:
use Ogmake\Laravel\Facades\Ogmake;
$url = Ogmake::url('blog', [
'site' => 'My Blog',
'author' => 'Jane Doe',
'date' => '2026-09-20',
'title' => 'Hello World',
]);
// https://ogmake.com/i/k_.../<sig>?author=Jane%20Doe&date=2026-09-20&site=My%20Blog&template=blog&title=Hello%20World
Or drop the Blade component straight into a layout's <head>:
<x-og-image template="blog" :params="[
'site' => config('app.name'),
'author' => $post->author,
'date' => $post->published_at->toDateString(),
'title' => $post->title,
]" />
{{-- <meta property="og:image" content="https://ogmake.com/i/k_.../<sig>?..."> --}} The URL renders on first request and serves from cache after that — no server round trip needed at request time.
3. Or sign it yourself
No Composer package? The canonical string is just a few lines of plain PHP: drop sig and debug, sort remaining keys by UTF-8 byte order,
RFC3986-encode each key=value, join with &, then HMAC-SHA256 it with your key's signing
secret. The file below is the real, interpreter-run script
(docs/snippets/sign.php, run with php sign.php) also used on the signing page — it reproduces the same fixed
test vector, so it is provably correct rather than just plausible. A Laravel controller or
view composer does the same three calls (canonical_query,
sign_query) against your own params.
<?php
// docs/snippets/sign.php — WP5 step 42 (docs/WP3_WP6_BREAKDOWN.md).
// Same fixed test vector as sign.mjs; run with `php sign.php`, prints:
// format=png&template=basic&title=Hello%20World
// W8Xvrf9UTFKbVVQtofHq7A2ljK9z-nvJUL3B-rB7MY8
//
// Keys are sorted by UTF-16 code unit (see utf16_code_units() below), the way
// JS's default string comparison sorts them in packages/core/src/signing.ts
// — NOT by PHP's native byte order. The two orders agree for ASCII/BMP keys
// but diverge for an astral-plane key (e.g. an emoji) compared against a BMP
// private-use key; using PHP's native ksort()/SORT_STRING there would build
// a different canonical string than the server and produce a bad signature.
// Your key's signing secret, base64 (shown once when the key is issued).
$SIGNING_SECRET_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
$PARAMS = ["template" => "basic", "title" => "Hello World", "format" => "png"];
function rfc3986_encode(string $value): string
{
// rawurlencode() is RFC3986: everything except A-Za-z0-9-_.~ is escaped.
return rawurlencode($value);
}
// Keys excluded from the canonical query (must match packages/core/src/signing.ts).
const CANONICAL_EXCLUDED_KEYS = ["sig", "debug"];
function canonical_query(array $params): string
{
$params = array_diff_key($params, array_flip(CANONICAL_EXCLUDED_KEYS));
uksort($params, "compare_utf16_keys");
$parts = [];
foreach ($params as $key => $value) {
$parts[] = rfc3986_encode($key) . "=" . rfc3986_encode($value);
}
return implode("&", $parts);
}
// Compares two keys by UTF-16 code unit, like JS's default string
// comparison (see the file header comment).
function compare_utf16_keys(string $a, string $b): int
{
$unitsA = utf16_code_units($a);
$unitsB = utf16_code_units($b);
$len = min(count($unitsA), count($unitsB));
for ($i = 0; $i < $len; $i++) {
if ($unitsA[$i] !== $unitsB[$i]) {
return $unitsA[$i] <=> $unitsB[$i];
}
}
return count($unitsA) <=> count($unitsB);
}
// Decodes a UTF-8 string into UTF-16 code units (astral codepoints become a
// surrogate pair), matching how JS represents strings internally.
function utf16_code_units(string $value): array
{
$units = [];
$len = strlen($value);
$i = 0;
while ($i < $len) {
$byte = ord($value[$i]);
if ($byte < 0x80) {
$codepoint = $byte;
$i += 1;
} elseif (($byte & 0xE0) === 0xC0 && $i + 1 < $len) {
$codepoint = (($byte & 0x1F) << 6) | (ord($value[$i + 1]) & 0x3F);
$i += 2;
} elseif (($byte & 0xF0) === 0xE0 && $i + 2 < $len) {
$codepoint = (($byte & 0x0F) << 12)
| ((ord($value[$i + 1]) & 0x3F) << 6)
| (ord($value[$i + 2]) & 0x3F);
$i += 3;
} elseif (($byte & 0xF8) === 0xF0 && $i + 3 < $len) {
$codepoint = (($byte & 0x07) << 18)
| ((ord($value[$i + 1]) & 0x3F) << 12)
| ((ord($value[$i + 2]) & 0x3F) << 6)
| (ord($value[$i + 3]) & 0x3F);
$i += 4;
} else {
$codepoint = $byte;
$i += 1;
}
if ($codepoint > 0xFFFF) {
$codepoint -= 0x10000;
$units[] = 0xD800 + ($codepoint >> 10);
$units[] = 0xDC00 + ($codepoint & 0x3FF);
} else {
$units[] = $codepoint;
}
}
return $units;
}
function base64url_encode(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), "+/", "-_"), "=");
}
function sign_query(string $secretBase64, string $canonical): string
{
$secret = base64_decode($secretBase64);
$mac = hash_hmac("sha256", $canonical, $secret, true);
return base64url_encode($mac);
}
$canonical = canonical_query($PARAMS);
echo $canonical . "\n";
echo sign_query($SIGNING_SECRET_BASE64, $canonical) . "\n";
4. Put it in the Blade layout
Compute the signed URL wherever the page's title/description are known (a view composer, or inline in the controller) and pass it to the layout like any other view variable:
// app/Http/Controllers/PostController.php
// Path only — prepend your OGMAKE_BASE_URL ("https://ogmake.com") to get the
// full signed URL. $keyId, $sig, $canonical come from the sign.php helpers.
$ogImagePath = sprintf('/i/%s/%s?%s', $keyId, $sig, $canonical);
$ogImageUrl = config('ogmake.base_url') . $ogImagePath;
return view('posts.show', ['post' => $post, 'ogImageUrl' => $ogImageUrl]); {{-- resources/views/layouts/app.blade.php --}}
<meta property="og:image" content="{{ $ogImageUrl }}" />
<meta name="twitter:card" content="summary_large_image"> 5. Or pre-render with the JSON API
If you'd rather store a URL once (e.g. at publish time, cached in your own DB) than sign one
per request, POST /v1/images from a queued job or an Artisan
command using Laravel's HTTP client:
Http::withToken(config('services.ogmake.key'))
->post('https://ogmake.com/v1/images', [
'template' => 'blog',
'params' => [
'site' => 'example.com/blog',
'author' => 'Jane Doe',
'date' => 'Sep 20, 2026',
'title' => $post->title,
],
])->json(); curl https://ogmake.com/v1/images \
-H "Authorization: Bearer og_live_..." \
-H "Content-Type: application/json" \
-d '{
"template": "blog",
"params": {
"site": "example.com/blog",
"author": "Jane Doe",
"date": "Sep 20, 2026",
"title": "Hello World"
}
}' Full field list per template on the reference page.