Skip to content
Scalpels home

Scalpels is officially launching on October 31, 2026. Special early adopter pricing is available now.

← All guides

Guide

Generate Open Graph images on Laravel Cloud

· Ed Grosvenor

Last updated

There are two reasonable ways to run spatie/laravel-og-image on Laravel Cloud:

  • send screenshot work to Cloudflare Browser Rendering;
  • put Puppeteer and an ARM64 Chromium binary in your deployment artifact.

Both work. The choice is operational: an external API and token, or a larger self-contained build that spends your app's own memory and CPU.

Either way, do not store generated images on the local public disk. Laravel Cloud's filesystem is ephemeral, resets on every deployment, and is private to each replica. Use a public object-storage bucket.

This guide was verified on 11 October 2026 against spatie/laravel-og-image 1.3.2, spatie/laravel-screenshot 1.2.0, and spatie/browsershot 5.4.0. The self-contained recipe deliberately pairs Puppeteer 25.11.0 with @sparticuz/chromium 153.0.0; do not replace either pin independently.

Install the PHP packages

Install the image package, its default browser driver, and Laravel's S3-compatible filesystem adapter:

composer require spatie/laravel-og-image:^1.3 spatie/browsershot:^5.4 league/flysystem-aws-s3-v3:^3.0 --with-all-dependencies
php artisan vendor:publish --tag=og-image-config

The package registers its component, middleware, and /og-image/{hash}.{format} route automatically.

Put generated images in a public bucket

Create a public Laravel Object Storage bucket. Only public buckets get a public URL, and the package needs one for its crawler-facing redirect.

Use the Laravel Cloud dashboard for the simplest setup.

  1. Create an Object Storage bucket with public visibility.
  2. Attach it to the environment and choose its Laravel disk name. Use public so the package can keep its default disk.
  3. Redeploy the environment so the attached disk reaches the running application.

If you attach the bucket under another name, select it with OgImage::disk('<that name>') before configureScreenshot(). A name such as s3 works too.

If you create the bucket with the Cloud CLI, --allowed-origins is required:

cloud bucket:create \
    --name=my-app-public \
    --visibility=public \
    --key-name=default_access_key \
    --key-permission=read_write \
    --allowed-origins="https://example.com" \
    --no-interaction \
    --json

The CLI does not have an attach command. For an automated setup, attach the bucket key by sending this field in PATCH /api/environments/{id}; the payload's id is the bucket key ID, not the bucket ID:

{
  "filesystem_keys": [
    {
      "id": "<bucket-key-id>",
      "disk": "public",
      "is_default_disk": false
    }
  ]
}

Use the dashboard if the environment already has other attached disks. The API does not return the current filesystem_keys list for a safe read-modify-write.

Redeploy after the API call too. CloudBootstrapper then replaces filesystems.disks.public with an S3 disk built from Cloud's payload, including the public bucket's URL. Laravel Cloud's AWS_URL note describes a standalone environment variable for code that reads it directly; the attached Laravel disk does not need it.

The package writes with public visibility. Laravel Cloud's R2 storage accepts its public-read ACL, so you do not need a visibility override. R2 does not support reading that ACL back with getVisibility().

During local development, public remains Laravel's normal local public disk. Run php artisan storage:link once, and the same application code works locally and on Cloud.

The distinction matters because the package sends a 301 redirect to $disk->url($path) for a non-local disk. A private bucket, or a disk without its public base URL, gives the crawler a redirect it cannot use.

The bucket also prevents a subtler failure. The image URL is a hash of the card HTML. If the image is missing, the package can regenerate it only while its hash-to-page mapping is still in your application cache. If that mapping is gone too, /og-image/... returns 404. A deploy should not make already-published share images depend on that recovery path.

Add a card to a page

Put an <x-og-image> component in each shareable Blade page. The package injects og:image, twitter:image, and twitter:card into the page's <head>:

<x-og-image>
    <div style="width: 1200px; height: 630px; display: flex; flex-direction: column; justify-content: flex-end; gap: 24px; padding: 72px; background: #111827; color: white; font-family: sans-serif;">
        <p style="font-size: 28px; color: #93c5fd;">{{ config('app.name') }}</p>
        <h1 style="max-width: 980px; font-size: 72px; line-height: 1.05; font-weight: 700;">{{ $post->title }}</h1>
    </div>
</x-og-image>

Use inline styles for the card's essential layout. The screenshot view adds an unlayered reset for margin and padding; those reset declarations outrank Tailwind utilities placed in a cascade layer. A card can look correct in your ordinary page and collapse only in the screenshot.

Open the page with ?ogimage before testing the generated file:

https://example.com/posts/an-example?ogimage

That preview renders the exact 1200×630 document the driver will capture. Keep cards static: hover states, Alpine interactions, and delayed UI do not help a crawler.

Configure the shared behavior

The following configuration supports both rendering choices. It leaves the package's default public disk in place: that is a local disk during local development and the attached object-storage disk on Cloud. It also forces a device scale factor of 1, so a 1200×630 viewport produces a 1200×630 file instead of the package default of 2400×1260.

Add the imports and call configureOgImages() from boot() in app/Providers/AppServiceProvider.php:

use LogicException;
use Spatie\Browsershot\Browsershot;
use Spatie\LaravelScreenshot\ScreenshotBuilder;
use Spatie\OgImage\Facades\OgImage;

public function boot(): void
{
    $this->configureOgImages();
}

private function configureOgImages(): void
{
    OgImage::configureScreenshot(function (ScreenshotBuilder $screenshot): void {
        $runtime = base_path('.cloud/runtime');

        $screenshot
            ->deviceScaleFactor(1)
            ->withBrowsershot(function (Browsershot $browser) use ($runtime): void {
                if (is_dir("{$runtime}/node_modules")) {
                    $browser->setNodeModulePath("{$runtime}/node_modules");
                }

                if (is_executable("{$runtime}/chromium/chromium")) {
                    $browser
                        ->setChromePath("{$runtime}/chromium/chromium")
                        ->noSandbox()
                        ->addChromiumArguments(['disable-dev-shm-usage']);
                }
            });
    });

    $token = config('services.cloudflare.api_token');
    $accountId = config('services.cloudflare.account_id');

    if ((bool) $token !== (bool) $accountId) {
        throw new LogicException('Cloudflare browser rendering needs both an API token and an account ID.');
    }

    if ($token && $accountId) {
        OgImage::useCloudflare(
            apiToken: (string) $token,
            accountId: (string) $accountId,
        );
    }
}

If both Cloudflare values exist, the package uses Cloudflare. With neither value, it keeps the default Browsershot driver. Supplying only one fails at boot with a useful message instead of falling through to a renderer you did not intend to use.

Option A: Cloudflare Browser Rendering

Choose this when you want a small deployment artifact, expect screenshots to compete with a memory-heavy application, or may run on more than one CPU architecture.

Add this entry to config/services.php:

'cloudflare' => [
    'api_token' => env('CLOUDFLARE_API_TOKEN'),
    'account_id' => env('CLOUDFLARE_ACCOUNT_ID'),
],

Create a custom Cloudflare API token with Browser Rendering - Edit permission, as described in Cloudflare's Browser Run setup. Then add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to the Laravel Cloud environment and redeploy. Keep the token in Cloud's environment settings, never in .env.example or source control.

The package calls Cloudflare's /browser-rendering/screenshot Quick Action. Quick Actions are billed by browser time rather than by the size of your Laravel instance; allowances and rates are listed on Cloudflare's current Browser Run pricing page.

The tradeoffs are an extra network dependency and a credential that can expire, be revoked, or have the wrong scope. An invalid token or API error makes first-time image generation fail. Existing images in the bucket remain available.

For exact local parity, use a development-scoped Cloudflare token locally. Without Cloudflare credentials, the shared configuration falls back to Browsershot, so local previews need a local Puppeteer/Chrome installation.

Option B: ship Puppeteer and Chromium in the build

Choose this when you want rendering to stay inside your Laravel Cloud environment and accept the artifact and memory cost.

Laravel Cloud currently runs its application compute on AWS Graviton, so this recipe uses the ARM64 Sparticuz pack. It stops the build on another architecture instead of installing a binary that cannot launch.

Create .cloud/runtime/package.json:

{
  "name": "laravel-cloud-og-image-runtime",
  "private": true,
  "dependencies": {
    "puppeteer": "25.11.0"
  },
  "devDependencies": {
    "@sparticuz/chromium-min": "153.0.0"
  }
}

Generate and commit its lock file:

npm install --prefix .cloud/runtime --package-lock-only --ignore-scripts --no-audit --no-fund

Puppeteer 25.11.0 expects Chrome 153.0.8010.36. The pinned Sparticuz release provides Chromium 153.0.8010.0. A newer Puppeteer release may target a newer browser protocol, which is why latest is not safe here.

Create .cloud/runtime/extract-chromium.mjs:

import chromium from '@sparticuz/chromium-min';

const packDirectory = process.argv[2];

if (! packDirectory) {
    throw new Error('The Chromium pack directory is required.');
}

chromium.setGraphicsMode = false;

console.log(await chromium.executablePath(packDirectory));

Then create .cloud/install-browsershot-runtime.sh:

#!/usr/bin/env bash

set -euo pipefail

APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME_DIR="${APP_ROOT}/.cloud/runtime"
PACK_DIR="${RUNTIME_DIR}/chromium-pack"
CHROMIUM_DIR="${RUNTIME_DIR}/chromium"
PACK_ARCHIVE="${RUNTIME_DIR}/chromium-v153.0.0-pack.arm64.tar"
PACK_URL="https://github.com/Sparticuz/chromium/releases/download/v153.0.0/chromium-v153.0.0-pack.arm64.tar"
PACK_SHA256="b6874fc0d7af15e16a15ef1b73177731f698c296c73d26aa4a90840c0777a338"

if [[ "$(uname -m)" != "aarch64" ]]; then
    echo "The pinned Chromium build requires aarch64; found $(uname -m)." >&2
    exit 1
fi

PUPPETEER_SKIP_DOWNLOAD=true npm ci \
    --prefix "${RUNTIME_DIR}" \
    --ignore-scripts \
    --no-audit \
    --no-fund

rm -rf "${PACK_DIR}" "${CHROMIUM_DIR}"
mkdir -p "${PACK_DIR}" "${CHROMIUM_DIR}"

curl --fail --location --silent --show-error \
    "${PACK_URL}" \
    --output "${PACK_ARCHIVE}"

printf '%s  %s\n' "${PACK_SHA256}" "${PACK_ARCHIVE}" | sha256sum --check --strict
tar --extract --file "${PACK_ARCHIVE}" --directory "${PACK_DIR}"

TMPDIR="${CHROMIUM_DIR}" node \
    "${RUNTIME_DIR}/extract-chromium.mjs" \
    "${PACK_DIR}"

PUPPETEER_SKIP_DOWNLOAD=true npm prune \
    --prefix "${RUNTIME_DIR}" \
    --omit=dev \
    --ignore-scripts \
    --no-audit \
    --no-fund

rm -rf "${PACK_DIR}"
rm -f "${PACK_ARCHIVE}"

"${CHROMIUM_DIR}/chromium" --version

Add the installer after your normal dependency and asset commands in Laravel Cloud's build command:

composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
bash .cloud/install-browsershot-runtime.sh

The script keeps the runtime under the application root so it becomes part of the deployment artifact. It verifies the release asset's SHA-256 before extracting it, then removes the compressed pack and build-only package.

In a measured 512 MB Laravel Cloud deployment, this combination added about 245 MB to the artifact, reached about 177 MiB of cgroup memory during a render, and took about 2.3 seconds to generate the first uncached image. Treat those as sizing data, not guarantees: measure your own app, especially if it already runs near its memory limit. Each cold render also occupies one PHP request while Chromium works.

The noSandbox() call is required in the restricted container. --disable-dev-shm-usage avoids relying on the container's small shared-memory mount. If the binary is missing, has the wrong architecture, or cannot load a library, image generation fails when the image URL is first requested; the final chromium --version makes most of those problems fail during the build instead.

Local development does not use the ARM64 artifact unless your machine matches it. Let Browsershot find a normal local Puppeteer and Chrome installation, and use ?ogimage to compare the resulting card. Fonts and rendering can differ slightly between local Chrome and the pinned Cloud binary.

Verify the deployed path

Do not stop after the page preview. Verify the whole lazy-generation path:

  1. Fetch a shareable page and confirm its <head> contains an absolute og:image URL.

  2. Request that URL with a one-off query string. The request should reach your app, generate the screenshot, store it in the bucket, and redirect to the public bucket URL.

  3. List the objects from the running application and confirm the generated hash is present:

    Storage::disk('public')->files('og-images');
    
  4. Confirm the final response is an image and exactly 1200×630.

  5. Request the same URL again and confirm the bytes are unchanged.

  6. Redeploy, list the disk again, and confirm the old object is still present.

An HTTP 200 is not proof that the object exists. A CDN can continue serving a cached image after the object has been removed, so the disk listing is the write check.

If ?ogimage works but the image route fails, check the selected driver first. For Cloudflare, verify both credentials and the token scope. For Browsershot, read the exception for the Chrome path, architecture, missing shared library, or sandbox failure. Then check that the public disk contains the image and that Storage::disk('public')->url(...) produces the bucket's public hostname rather than its private API endpoint.

Which renderer should you choose?

Situation Cloudflare Browser Rendering Chromium in the Cloud build
External dependency Cloudflare API and token None after the build finishes
Artifact and build Small; no browser download About 245 MB larger; download and extraction on fresh builds
Runtime resources Browser CPU and memory run outside your app Browser competes with your app's instance memory and CPU
Architecture Cloudflare owns it ARM64 pack; build stops on another architecture
Updates API behavior and pricing can change Puppeteer, browser pack, checksum, and lock file move together
Cold render cost Metered browser time and network latency Your Laravel Cloud compute time and memory
Local parity Best with a development token Best with local Puppeteer, but the browser binary may differ
Typical choice Memory-tight apps, small artifacts, or mixed platforms Self-contained deployments with measured memory headroom

Whichever renderer you choose, keep the bucket. Share image URLs have to keep working across replicas and deployments, and swapping the renderer later does not touch the images already stored.

Changelog

  1. Updated the bucket setup to use Laravel Cloud's attached public disk.
  2. Published.

Want some help?

After you buy, we will pay for an hour with one of these experts to help you get everything up and running.

  • Artisan Build

    USA

    We run Scalpels, as well as a suite of other tools for developers and development teams.

    Agency Ask for help
    Ask Artisan Build for help