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.
- Create an Object Storage bucket with public visibility.
- Attach it to the environment and choose its Laravel disk name. Use
publicso the package can keep its default disk. - 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:
-
Fetch a shareable page and confirm its
<head>contains an absoluteog:imageURL. -
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.
-
List the objects from the running application and confirm the generated hash is present:
Storage::disk('public')->files('og-images'); -
Confirm the final response is an image and exactly 1200×630.
-
Request the same URL again and confirm the bytes are unchanged.
-
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
- Updated the bucket setup to use Laravel Cloud's attached public disk.
- Published.