ogmake

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.