PHPackages                             williamjulianvicary/unfurl - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. williamjulianvicary/unfurl

ActiveLibrary

williamjulianvicary/unfurl
==========================

Driver-based OG image generation for Laravel with Cloudflare Browser Rendering and Browsershot support.

v0.1.0(1mo ago)30MITPHPPHP ^8.2CI passing

Since Apr 7Pushed 1mo agoCompare

[ Source](https://github.com/williamjulianvicary/unfurl)[ Packagist](https://packagist.org/packages/williamjulianvicary/unfurl)[ RSS](/packages/williamjulianvicary-unfurl/feed)WikiDiscussions main Synced 1mo ago

READMEChangelog (1)Dependencies (11)Versions (2)Used By (0)

Unfurl for Laravel - Modern OG image generation for URLs or via templates.
==========================================================================

[](#unfurl-for-laravel---modern-og-image-generation-for-urls-or-via-templates)

[![Tests](https://github.com/williamjulianvicary/unfurl/actions/workflows/tests.yml/badge.svg)](https://github.com/williamjulianvicary/unfurl/actions)[![Latest Version on Packagist](https://camo.githubusercontent.com/f52274052528a66d8f7b882078c674543eac6ad7d714c2a275c491d562141537/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f77696c6c69616d6a756c69616e7669636172792f756e6675726c)](https://packagist.org/packages/williamjulianvicary/unfurl)[![License](https://camo.githubusercontent.com/0ff682bdd4f9e650ed21aab4e1d04a72d9d4a2581280c5ad5f30b0d30b1a556d/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f77696c6c69616d6a756c69616e7669636172792f756e6675726c)](https://packagist.org/packages/williamjulianvicary/unfurl)

Most OG image packages for Laravel assume you have Browsershot (and therefore a local Node/Puppeteer install) available - that's a non-starter on managed platforms like Laravel Cloud. They also tend to serve images through PHP on every request and offer limited templating.

Unfurl takes a different approach:

- **Driver-based rendering** - ship with Cloudflare Browser Rendering (no server-side browser needed) or fall back to Browsershot when you can.
- **Static file serving** - generated images are stored on any Laravel filesystem disk (public by default) and served directly by your web server or CDN, not through PHP.
- **Queue-first generation** - images are rendered in the background via Laravel's queue so page loads are never blocked.
- **Built-in templates** - includes ready-to-use Blade templates (`basic`, `dark`, `minimal`) with automatic text fitting - customise the templates or bring your own.

Examples:
---------

[](#examples)

  Basic:
[![Basic template](docs/examples/basic.png)](docs/examples/basic.png) Dark:
[![Dark template](docs/examples/dark.png)](docs/examples/dark.png)   Minimal:
[![Minimal template](docs/examples/minimal.png)](docs/examples/minimal.png) URL Rendering:
[![URL screenshot](docs/examples/url.png)](docs/examples/url.png) > **Requires [PHP 8.2+](https://php.net/releases/)** and **[Laravel 11+](https://laravel.com)** and either **CloudFlare Browser Rendering (free tier available) or Browsershot** with local Chrome available.

- [Installation](#installation)
- [Quickstart](#quickstart)
- [How it works](#how-it-works)
- [Usage](#usage)
    - [Setting the source](#setting-the-source)
    - [`url()`, `generate()`, and `render()`](#url-generate-and-render)
    - [Automatic refresh](#automatic-refresh)
    - [Variants](#variants)
    - [Deleting images](#deleting-images)
    - [Working with models](#working-with-models)
- [Drivers](#drivers)
- [Configuration](#configuration)

Installation
------------

[](#installation)

```
composer require williamjulianvicary/unfurl
```

**Publish the config, migrations and blade OG image templates:**

```
php artisan vendor:publish --provider="WilliamJulianVicary\Unfurl\OgImageServiceProvider"
php artisan migrate
```

**Configure your drivers:**

- **Cloudflare**, add your .env variables with your Cloudflare details (see [before you begin](https://developers.cloudflare.com/browser-rendering/rest-api/#before-you-begin)):

```
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_BROWSER_RENDERING_TOKEN=

```

- **Browsershot** (when local Chrome rendering is practical): Update driver to use Browsershot in your .env: `UNFURL_DRIVER=browsershot` and install browsershot:

```
composer require spatie/browsershot

```

**Optionally, enable routes for rendering page templates:**Templates are rendered as blade files with parameters passed to these URLs to render the OG image.

By default these routes are NOT registered.

To register the routes for templates publish the config file and then adjust `/config/unfurl.php`:

```
'route' => [
        'enabled' => true, // adjust this from false -> true
        'prefix' => 'unfurl', // optionally adjust the path that images are generated from.
        'middleware' => [],
],

```

Quickstart
----------

[](#quickstart)

Complete installation and then the below is all you need to set up a simple OG image which takes a screenshot of your current URL (without query parameters), this returns the *expected* OG image URL that you can pass to your views/frontend to render - image creation is then handled asynchronously via the Laravel queue:

```
OgImage::for('')->screenshot()->url();
// Or for a model, for the current URL:
OgImage::for($model)->screenshot()->url();

```

By default this makes the following assumptions (configurable in the `/config/unfurl.php` once published):

- You have a queue worker running and queues will be dispatched for async generation
- Images are stored and served from the `public` disk

For template based OG image examples, keep reading.

How it works
------------

[](#how-it-works)

1. You define a URL to screenshot or a Blade template to render (templates are included)
    1. For URL based rendering, the URL is loaded by the service at a relevant viewport.
    2. For template based rendering, a URL is passed to the driver (default: `/unfurl/render/{template}`) to render the template.
2. Unfurl dispatches a queued job that uses a **driver** (Cloudflare Browser Rendering, the default or Browsershot) to take a screenshot of that source URL - for templates this is via a Signed URL for security.
    1. The queued jobs implements `ShouldBeUnique` to block excessive requests.
3. The image is stored on the Laravel filesystem disk (public by default, configurable in the config) and tracked in the database with a deterministic key.
4. On subsequent requests, `url()` returns the stored image URL instantly - no re-rendering.

When `generate_on_access` is enabled (the default), the first call to `url()` will automatically dispatch generation in the background and return the expected URL, so images are created lazily without blocking your response.

Usage
-----

[](#usage)

Every operation starts with `OgImage::for()`, which accepts a string key or an Eloquent model (a deterministic key is derived automatically from the model).

```
use WilliamJulianVicary\Unfurl\Facades\OgImage;
```

### Setting the source

[](#setting-the-source)

You can set the source as a URL to screenshot or a Blade template to render.

**Screenshot a URL:**

```
OgImage::for('my-page')->screenshot('https://example.com')->url();
```

**Render from a Blade template:**

To use templates, first enable the render route in `config/unfurl.php`:

```
'route' => [
    'enabled' => true,
],
```

Use one of the built-in templates (`basic`, `dark`, `minimal`) or your own. **All parameters are optional**:

```
OgImage::for($post)->template('basic', [
    'title'       => 'My Blog Post',
    'description' => 'A short summary of the post.',
    'author'      => 'Jane Doe',
    'avatar'      => 'https://example.com/avatar.jpg',
    'date'        => 'April 6, 2026',
    'domain'      => 'example.com',
    'accent'      => '#667eea',
])->url();
```

ParameterDescription`title`Main heading text`description`Secondary text below the title`author`Author name displayed in the footer`avatar`URL to an avatar image (rendered as a circle)`date`Date string displayed alongside the author`domain`Domain or site name displayed in the footer`accent`Accent colour for borders and decorative elements### `url()`, `generate()`, and `render()`

[](#url-generate-and-render)

The builder provides three ways to produce an image:

MethodDispatches jobReturnsUse case`url()`Lazily`?string` - the image URL**Most common.** Returns the stored URL if the image exists. When `generate_on_access` is enabled (the default), dispatches a background job on first access and returns the expected URL. Regenerates every 30 days by default (configurable)`generate()`Always`string` - the expected URLForces a (re)generation job to be dispatched and returns the expected URL. Useful for seeding or regenerating images.`render()`No`string` - raw image bytesRenders the screenshot synchronously in-process. No job, no storage. Useful for streaming responses or custom storage logic.**Typical usage - `url()` in a Blade view:**

```

```

**Force regeneration with `generate()`:**

```
// e.g. in an observer or artisan command
OgImage::for($post)->template('basic', ['title' => $post->title])->generate();
```

**Stream raw bytes with `render()`:**

```
$bytes = OgImage::for($post)->screenshot('https://example.com')->render();

return response($bytes, 200, ['Content-Type' => 'image/jpeg']);
```

### Automatic refresh

[](#automatic-refresh)

By default, `url()` will automatically regenerate images older than 30 days. When a stale image is found, a background job is dispatched and the existing URL is returned in the meantime - so users never see a broken image. This follows a stale-while-revalidate approach, whereby the current generated image is served while the new image regenerates.

Configure the threshold in `config/unfurl.php`:

```
// Refresh images older than 30 days (the default)
'refresh_after_days' => 30,

// Disable automatic refresh
'refresh_after_days' => null,
```

### Variants

[](#variants)

Generate images at different dimensions for different platforms:

```
// config/unfurl.php
'variants' => [
    'twitter' => ['width' => 1200, 'height' => 600],
    'square'  => ['width' => 1200, 'height' => 1200],
],
```

```
OgImage::for($post)->template('basic', ['title' => $post->title])
    ->variant('twitter')
    ->generate();

$twitterUrl = OgImage::for($post)->url('twitter');
```

### Deleting images

[](#deleting-images)

Remove all generated images for a key from storage and the database:

```
OgImage::for($post)->delete();
```

### Working with models

[](#working-with-models)

Any Eloquent model can be passed directly - a deterministic key is generated from the model's morph class and primary key:

```
OgImage::for($post)->template('dark', ['title' => $post->title])->url();
OgImage::for($user)->screenshot($user->profile_url)->url();
```

Drivers
-------

[](#drivers)

- **Cloudflare Browser Rendering** (default) - Uses the Cloudflare Browser Rendering API
- **Browsershot** - Uses [spatie/browsershot](https://github.com/spatie/browsershot) for local rendering

### Cloudflare limits

[](#cloudflare-limits)

Cloudflare Browser Rendering enforces usage limits that vary by plan — see the [official limits documentation](https://developers.cloudflare.com/browser-rendering/limits/) for details.

By default, Unfurl's queue middleware is configured to stay within the free tier limits (`queue.without_overlapping` enabled and `queue.rate_limit` set to 6 per minute). If you are on a paid Cloudflare plan you can relax or disable these constraints in `config/unfurl.php`:

```
'queue' => [
    'without_overlapping' => false, // allow concurrent rendering jobs
    'rate_limit' => null,           // disable rate limiting (or set a higher value)
],
```

Configuration
-------------

[](#configuration)

After publishing the config file (`config/unfurl.php`), the following options are available:

KeyDefaultEnv VariableDescription`driver``'cloudflare'``UNFURL_DRIVER`Rendering driver: `"cloudflare"` or `"browsershot"``drivers.cloudflare.account_id``null``CLOUDFLARE_ACCOUNT_ID`Cloudflare account ID`drivers.cloudflare.api_token``null``CLOUDFLARE_BROWSER_RENDERING_TOKEN`Cloudflare Browser Rendering API token`drivers.browsershot.node_binary``null``UNFURL_NODE_BINARY`Path to Node binary (Browsershot), leave blank for default`drivers.browsershot.npm_binary``null``UNFURL_NPM_BINARY`Path to npm binary (Browsershot), leave blank for default`drivers.browsershot.chrome_path``null``UNFURL_CHROME_PATH`Path to Chrome binary (Browsershot), leave blank for default`storage.disk``'public'``UNFURL_DISK`Laravel filesystem disk for storing images`storage.path``'og-images'``UNFURL_PATH`Base folder within the disk`width``1200`Default image width in pixels`height``630`Default image height in pixels`variants``[]`Named variants with custom dimensions (e.g. `'twitter' => ['width' => 1200, 'height' => 600]`)`queue.enabled``true`Dispatch generation jobs via the queue`queue.connection``null``UNFURL_QUEUE_CONNECTION`Queue connection name, leave blank for Laravel default`queue.name``null``UNFURL_QUEUE`Queue name, leave blank for Laravel default`queue.without_overlapping``true`Apply `WithoutOverlapping` middleware to prevent concurrent jobs for the same key/variant`queue.rate_limit``6`Maximum jobs per minute. Set to `null` or `false` to disable rate limiting`generate_on_access``true`Auto-dispatch generation when `url()` is called with no existing image`refresh_after_days``30`Regenerate images older than this many days. Set to `null` to disable. Affects `url()` calls.`format``'jpeg'`Output format: `"jpeg"` or `"png"``device_scale_factor``2`Device scale factor for rendering (2 = retina)`template_prefix``'unfurl::templates'`View namespace prefix for resolving template names`route.enabled``false`Register the template render route (required for `template()`)`route.prefix``'unfurl'`URL prefix for the template render route`route.middleware``[]`Additional middleware for the template render routeDevelopment
-----------

[](#development)

🧹 Keep a modern codebase with **Pint**:

```
composer lint
```

✅ Run refactors using **Rector**:

```
composer refactor
```

⚗️ Run static analysis using **PHPStan**:

```
composer test:types
```

✅ Run unit tests using **Pest**:

```
composer test:unit
```

🚀 Run the entire test suite:

```
composer test
```

License
-------

[](#license)

Unfurl for Laravel is open-sourced software licensed under the **[MIT license](LICENSE.md)**.

###  Health Score

36

—

LowBetter than 82% of packages

Maintenance93

Actively maintained with recent releases

Popularity4

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity36

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

35d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/46bb85ec3096c613afa4d291c71d176579680d6e487ffbd755153a4e23311f21?d=identicon)[williamjulianvicary](/maintainers/williamjulianvicary)

---

Top Contributors

[![williamjulianvicary](https://avatars.githubusercontent.com/u/1242716?v=4)](https://github.com/williamjulianvicary "williamjulianvicary (12 commits)")

---

Tags

laravelcloudflaresocial mediaopen-graphog-imagebrowser-rendering

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/williamjulianvicary-unfurl/health.svg)

```
[![Health](https://phpackages.com/badges/williamjulianvicary-unfurl/health.svg)](https://phpackages.com/packages/williamjulianvicary-unfurl)
```

###  Alternatives

[laravel/scout

Laravel Scout provides a driver based solution to searching your Eloquent models.

1.7k49.4M479](/packages/laravel-scout)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k12.1M99](/packages/laravel-pulse)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9682.1M97](/packages/roots-acorn)[yadahan/laravel-authentication-log

Laravel Authentication Log provides authentication logger and notification for Laravel.

416632.8k5](/packages/yadahan-laravel-authentication-log)[api-platform/laravel

API Platform support for Laravel

59126.4k6](/packages/api-platform-laravel)[alajusticia/laravel-logins

Session management in Laravel apps, user notifications on new access, support for multiple separate remember tokens, IP geolocation, User-Agent parser

2011.0k](/packages/alajusticia-laravel-logins)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
