Laravel icon

Laravel

Put the render call in a controller and your API key never reaches the browser: the browser, and every social crawler, only ever sees a URL on your own domain. Imejis returns the finished image directly in the response, so an action is a single Http::post, with nothing to poll and no webhook to receive.

Set two environment variables first:

.env
IMEJIS_API_KEY=your_api_key
IMEJIS_DESIGN_ID=your_design_id

The fields you override are component.property pairs: headline.text, title.color, image.image. Only what you send changes, and a design's overridable fields are listed in its Share / API panel.

Every block below comes from a real Laravel app that is booted in Docker and called end to end before this page ships. The GET routes live in routes/web.php, so their URLs are exactly what you write: /og, /og-size, /og-url, /card. The bulk POST lives in routes/api.php and carries Laravel's /api prefix, because a POST through the web group needs a CSRF token and an API client has none.

Route the three getsRoute the three GETs

The GET routes live in routes/web.php, so their URLs are exactly what you write them as.

routes/web.php
<?php
use App\Http\Controllers\OgController;
use Illuminate\Support\Facades\Route;
 
// The GET routes live here, so their URLs are exactly what you write:
// /og, /og-url, /card.
Route::get('/og', [OgController::class, 'show']);
Route::get('/og-size', [OgController::class, 'ogSize']);
Route::get('/og-url', [OgController::class, 'ogUrl']);
Route::get('/card', [OgController::class, 'card']);

Route the postRoute the POST

The bulk POST belongs in routes/api.php instead: the web middleware group verifies a CSRF token an API client does not send, so the same route answers 419 CSRF token mismatch there. Laravel serves this file under /api.

routes/api.php
<?php
use App\Http\Controllers\OgController;
use Illuminate\Support\Facades\Route;
 
// The POST belongs here rather than in web.php: the `web` middleware group
// verifies a CSRF token that an API client does not send, so the same route
// answers `419 CSRF token mismatch` there. Laravel serves this file under
// /api, so the URL is /api/og-bulk.
Route::post('/og-bulk', [OgController::class, 'ogBulk']);

Read the credentials through configRead the credentials through config

Reading env() only here, and config() everywhere else, is what keeps the app working once the config is cached in production, where env() returns null.

config/services.php
<?php
return [
    'imejis' => [
        // Server only. Read it through config, never with env() outside this
        // file — a cached config would otherwise come up empty in production.
        'key' => env('IMEJIS_API_KEY'),
        'design' => env('IMEJIS_DESIGN_ID'),
        'url' => env('IMEJIS_RENDER_URL', 'https://render.imejis.io'),
        // PUBLIC on purpose, and only for the direct-<img> block: that one
        // puts the key in the markup. Leave it unset unless you mean to
        // expose one.
        'public_key' => env('IMEJIS_PUBLIC_API_KEY'),
    ],
];

An endpoint that returns the generated imageAn endpoint that returns the generated image

A route in your app that renders on demand and streams the image straight back, so your render key never reaches the browser. This is the shape to use behind an <img src> on a page you control. ?format= picks png, jpeg, webp or pdf, and the route answers with the content type that matches it. A PDF labelled image/png is a file the browser, the email client and Slack all refuse to open.

app/Http/Controllers/OgController.php
<?php
namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
 
class OgController extends Controller
{
    // The formats the render API accepts. The actions below pass `?format=`
    // through when one is asked for; with none, the render host's default
    // (jpeg) applies.
    private const FORMATS = ['png', 'jpeg', 'webp', 'pdf'];
 
    private function render(string $path, array $query, array $fields)
    {
        $base = config('services.imejis.url');
        $design = config('services.imejis.design');
 
        return Http::withHeaders(['dma-api-key' => config('services.imejis.key')])
            ->post($base.$path.$design.'?'.http_build_query($query), $fields);
    }
 
    // Render on demand and send the image straight back. The key stays on the
    // server, so it never reaches the browser — point an <img src>, or an
    // og:image, at this action.
    //
    // `?format=` decides what comes back — png · jpeg · webp · pdf — and with
    // no format the render host's default (jpeg) applies. Either way the
    // response carries whatever type the render API set, passed straight
    // through: a PDF labelled `image/jpeg` is a file the browser, the email
    // client and Slack all refuse to open.
    public function show(Request $request)
    {
        $requested = $request->query('format', '');
        $query = in_array($requested, self::FORMATS, true) ? ['format' => $requested] : [];
 
        $response = $this->render('/v1/', $query, [
            'headline.text' => $request->query('title', 'Hello from Laravel'),
            'subtitle.text' => $request->query('subtitle', ''),
        ]);
 
        abort_if($response->failed(), 502, 'render failed');
 
        return response($response->body(), 200, [
            'Content-Type' => $response->header('Content-Type') ?: 'image/jpeg',
            // Renders are deterministic, so cache aggressively at the
            // edge/CDN — and every request to the render API is one metered
            // render.
            'Cache-Control' => 'public, immutable, no-transform, max-age=31536000',
        ]);
    }
}

One endpoint every size and jpeg qualityOne endpoint, every size, and JPEG quality

A multi-size design carries several canvases: an OG image, a square post, a vertical story. ?variation=<id> picks which one the route renders, and omitting it gives the base size, so one endpoint serves every size your pages ask for. ?quality= trades file size for fidelity on JPEG only; png, webp and pdf ignore it.

app/Http/Controllers/OgController.php
<?php
namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
 
class OgController extends Controller
{
    // The formats the render API accepts. The actions below pass `?format=`
    // through when one is asked for; with none, the render host's default
    // (jpeg) applies.
    private const FORMATS = ['png', 'jpeg', 'webp', 'pdf'];
 
    private function render(string $path, array $query, array $fields)
    {
        $base = config('services.imejis.url');
        $design = config('services.imejis.design');
 
        return Http::withHeaders(['dma-api-key' => config('services.imejis.key')])
            ->post($base.$path.$design.'?'.http_build_query($query), $fields);
    }
 
    // One design, several canvases — an OG image, a square post, a vertical
    // story. `?variation=<id>` picks which one you get; with none you get the
    // base size. Map YOUR OWN size names to the ids (the design's Share / API
    // panel lists them) so a page asks for "story", not for an id.
    //
    // `?quality=` trades file size for fidelity on JPEG only — png, webp and
    // pdf ignore it.
    private function sizes(): array
    {
        // null is the base canvas: a render with no `variation` at all.
        return ['og' => null, 'story' => env('IMEJIS_VARIATION_ID', 'story')];
    }
 
    public function ogSize(Request $request)
    {
        $sizes = $this->sizes();
        $size = $request->query('size', 'og');
        abort_if(! array_key_exists($size, $sizes), 404, "unknown size: {$size}");
 
        $query = [];
        if ($sizes[$size]) {
            $query['variation'] = $sizes[$size];
        }
        $requested = $request->query('format', '');
        if (in_array($requested, self::FORMATS, true)) {
            $query['format'] = $requested;
        }
        $quality = (int) $request->query('quality', 0);
        if ($quality >= 1 && $quality <= 100) {
            $query['quality'] = $quality;
        }
 
        $response = $this->render('/v1/', $query, [
            'headline.text' => $request->query('title', 'Every size'),
        ]);
 
        abort_if($response->failed(), 502, 'render failed');
 
        return response($response->body(), 200, [
            'Content-Type' => $response->header('Content-Type') ?: 'image/jpeg',
            'Cache-Control' => 'public, immutable, no-transform, max-age=31536000',
        ]);
    }
}

An endpoint that returns a hosted or signed image urlAn endpoint that returns a hosted or signed image URL

Ask for ?delivery=hosted (a public URL) or ?delivery=signed (a private, expiring one) and hand the URL to the caller instead of the bytes, which is what you want for an email, a webhook payload, or a share card.

app/Http/Controllers/OgController.php
<?php
namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
 
class OgController extends Controller
{
    // The formats the render API accepts. The actions below pass `?format=`
    // through when one is asked for; with none, the render host's default
    // (jpeg) applies.
    private const FORMATS = ['png', 'jpeg', 'webp', 'pdf'];
 
    private function render(string $path, array $query, array $fields)
    {
        $base = config('services.imejis.url');
        $design = config('services.imejis.design');
 
        return Http::withHeaders(['dma-api-key' => config('services.imejis.key')])
            ->post($base.$path.$design.'?'.http_build_query($query), $fields);
    }
 
    // Ask for a stored URL instead of bytes. `hosted` is public and
    // permanent; `signed` is private and expires (`expiresIn` is minutes).
    public function ogUrl(Request $request)
    {
        $delivery = $request->query('private') ? 'signed' : 'hosted';
        $query = ['delivery' => $delivery];
        // The stored URL carries the extension of the format it was rendered
        // in, so pass the format through here too — a recipient that sniffs by
        // extension (an email client, a Slack unfurl) needs `.pdf` to say pdf.
        $requested = $request->query('format', '');
        if (in_array($requested, self::FORMATS, true)) {
            $query['format'] = $requested;
        }
        if ($delivery === 'signed') {
            $query['expiresIn'] = 30;
        }
 
        // { url, format, file: { size }, expiresAt (signed only) }
        $stored = $this->render('/v1/', $query, [
            'headline.text' => $request->query('title', 'Shared card'),
        ])->json();
 
        return response()->json([
            'url' => $stored['url'] ?? null,
            'format' => $stored['format'] ?? null,
            'expiresAt' => $stored['expiresAt'] ?? null,
        ]);
    }
}

Bulk image generation from a routeBulk image generation from a route

One POST to /bulk renders many images, a row per record or one per size, and returns a URL for each, in the order you sent them.

app/Http/Controllers/OgController.php
<?php
namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
 
class OgController extends Controller
{
    // The formats the render API accepts. The actions below pass `?format=`
    // through when one is asked for; with none, the render host's default
    // (jpeg) applies.
    private const FORMATS = ['png', 'jpeg', 'webp', 'pdf'];
 
    private function render(string $path, array $query, array $fields)
    {
        $base = config('services.imejis.url');
        $design = config('services.imejis.design');
 
        return Http::withHeaders(['dma-api-key' => config('services.imejis.key')])
            ->post($base.$path.$design.'?'.http_build_query($query), $fields);
    }
 
    // One POST renders many images and returns a URL for each, in the order
    // sent — a row per record, or one per size.
    public function ogBulk(Request $request)
    {
        $rows = $request->input('rows', []);
 
        $bulk = Http::withHeaders(['dma-api-key' => config('services.imejis.key')])
            ->post(config('services.imejis.url').'/bulk', [
                'designId' => config('services.imejis.design'),
                'delivery' => 'hosted',
                'format' => 'png',
                'data' => array_map(fn ($row) => [
                    'headline.text' => $row['name'],
                    'subtitle.text' => $row['role'] ?? '',
                ], $rows),
            ])->json();
 
        return response()->json([
            'summary' => $bulk['summary'],
            'urls' => array_map(fn ($result) => $result['url'] ?? null, $bulk['results']),
        ]);
    }
}

Using the image url directly in a templateUsing the image URL directly in a template

For a public design you can skip the proxy entirely and point an <img> at the render URL with the field values in the query string. Keep the proxy when the key must stay private. That is the right default.

resources/views/og/card.blade.php
{{--
  og:image points at YOUR OWN route, so crawlers fetch your domain and get the
  absolute URL they need. In a real layout this is where the model's title
  goes: url('/og?title='.urlencode($post->title)).
--}}
<head>
  <meta property="og:image" content="{{ $ogImage }}">
  <meta name="twitter:card" content="summary_large_image">
</head>
 
{{--
  For a PUBLIC design you can skip the proxy and point an <img> straight at the
  render URL — but the key then ships in the markup, so it has to be one you
  are happy to expose. Keep the proxy whenever it must stay private: that is
  the right default, and what the show action above is for.
--}}
<img src="{{ $direct }}" width="1200" height="630" alt="Card for {{ $title }}">

Dynamic OG images. The card action above is the whole link-preview pattern, and url() is what makes og:image the absolute URL a crawler needs. In a real Blade view you would set it from the model, with og:image as {{ url('/og?title='.urlencode($post->title)) }}, and X, LinkedIn, Slack and iMessage then fetch your domain and get a freshly rendered 1200×630 image for that post.

The response is the raw image (`image/jpeg` by default). Choose the format with `?format=png|jpeg|webp|pdf` (default jpeg) and set JPEG quality with `?quality=1-100`. Store the result and get a URL back with `?delivery=hosted` (or `?delivery=signed&expiresIn=60`), and render a specific size of a multi-size design with `?variation=<id>`.

ParameterValuesDefaultWhat it does
?format=png · jpeg · webp · pdfjpegOutput image format.
?quality=1–100noneCompression quality for lossy output; ignored for other formats. (JPEG only)
?delivery=stream · hosted · signedstreamstream = raw image bytes (default); hosted = store + return a public URL; signed = store + return a time-limited URL.
?expiresIn=minutes60Signed-URL lifetime in minutes (default 60, max 10080 = 7 days). (delivery=signed only)
?variation=<size id>masterWhich multi-dimension SIZE to render. Omit or use "master" for the base size; an unknown id falls back to the master. (multi-size designs only)
  • PNG (?format=png): Lossless, supports transparency. Largest file.
  • JPEG (?format=jpeg): Smaller, lossy, no transparency. Quality adjustable.
  • WebP (?format=webp): Smallest, supports transparency. Modern browsers.
  • PDF (?format=pdf): Vector page, for print and documents.

See the delivery modes and generate images with the API guides.

Symfony has the same actions with attribute routing, and the PHP page has the calls with no framework. The bulk POST sits in routes/api.php on purpose: through the web group it needs a CSRF token, and an API client has none. $response->body() buffers the render, and each action call is one render, from 100 free a month; see pricing.