fg/essence
fg/essence is a PHP package for extracting media and metadata from web pages. Given a URL, it retrieves titles, descriptions, images, videos, and embedded content via Open Graph, Twitter Cards, oEmbed, and other providers—ideal for link previews and rich cards.
Essence is a standalone HTTP scraper that turns a URL into a normalized Essence\Media object (title, description, thumbnail, embed HTML, etc.) by trying a chain of providers — oEmbed endpoints first (YouTube, Vimeo, Twitter, SoundCloud, …) and then OpenGraph / Twitter Cards / generic HTML extraction. The fg/essence package itself has no Laravel service provider, so day one is: install, bind it, and call extract().
composer require fg/essence
Where to look first:
Essence\Essence — the only class you normally touch (extract, extractAll, replace).Essence\Media — the value object you render. It implements ArrayAccess, __toString() (returns html), and is json_encode-able.Essence\Di\Container — the config array keys you can override: Http, Cache, Dom, Log, providers.vendor/fg/essence/src/Provider/ — the provider classes, and the bundled oEmbed endpoint list.Register it as a singleton so the per-request cache is shared:
// app/Providers/EssenceServiceProvider.php
public function register(): void
{
$this->app->singleton(\Essence\Essence::class, fn () => new \Essence\Essence([
'Cache' => new \App\Support\EssenceCache($this->app['cache']->store()),
'Log' => new \App\Support\EssenceLogger($this->app['log']),
]));
}
First use case — "paste a link, get a preview card" (link unfurling for a post composer):
$Media = app(\Essence\Essence::class)->extract($request->string('url'));
if (! $Media) {
return response()->json(['error' => 'unsupported'], 422);
}
return response()->json([
'type' => $Media->type, // video | image | rich | link | audio
'title' => $Media->title,
'provider' => $Media->providerName,
'thumbnail' => $Media->thumbnailUrl,
'html' => in_array($Media->type, ['video', 'rich'], true) ? $Media->html : null,
]);
extract() returns null (not an exception) for unsupported/failed URLs — treat "no media" as a normal branch, not an error.
Guard the outbound request. extract() performs a real HTTP GET against a user-supplied URL, which is textbook SSRF. Validate before you call it:
$url = $request->validate(['url' => ['required', 'url:http,https']])['url'];
if (! filter_var(parse_url($url, PHP_URL_HOST), FILTER_VALIDATE_IP) === false) { /* block IP literals */ }
// and resolve DNS / block 127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1
Prefer doing this in a FormRequest rule or a small IsPublicUrl rule so every call site inherits it.
Queue it, don't inline it. Extraction is 1–3 network round trips. For "unfurl every link in a submitted post", dispatch a job and store the resulting Media JSON on the model, rather than calling extract() in the request cycle.
class UnfurlLink implements ShouldQueue
{
public function handle(\Essence\Essence $essence): void
{
$media = $essence->extract($this->link->url);
$this->link->update(['preview' => $media?->properties, 'fetched_at' => now()]);
}
}
Persist $Media->properties, not the object. Media is a plain data bag; JSON-serialize it into a json column and render from the stored copy. This is also your cache — you avoid re-hitting YouTube for every page view.
replace() for rich-text bodies. If you render user posts containing bare URLs, this scans the text and substitutes embeds:
$html = app(\Essence\Essence::class)->replace(nl2br(e($post->body)));
Because the returned string contains provider html (raw <iframe>/<blockquote>), only run this on text you control or sanitize afterwards. Don't {!! !!} the output of replace() on untrusted content without an HTML sanitizer.
Batch extraction. extractAll(array $urls) returns a map keyed by URL and shares the container cache, which is cheaper than a loop of extract() only if you've swapped in a persistent cache (see below).
Swap the cache for Laravel's. The default Essence\Cache\Volatile lives only for the current process, so every request pays full price. Implement the package cache interface against a Laravel store:
final class EssenceCache implements \Essence\Cache\CacheInterface
{
public function __construct(private \Illuminate\Contracts\Cache\Repository $cache) {}
private function key(string $key): string
{
return 'essence:'.sha1($key); // keys can be full URLs — hash for safety
}
public function has(string $key): bool { return $this->cache->has($this->key($key)); }
public function get(string $key) { return $this->cache->get($this->key($key)); }
public function set(string $key, $data): void { $this->cache->put($this->key($key), $data, now()->addHours(6)); }
}
TTL should be short-ish (hours, not days) — thumbnails, titles, and tweet text change.
Route logs into Laravel's logger. The default logger is silent, which makes "why did extraction return null?" invisible. Implement Essence\Log\Logger and forward to Log::debug() / Log::warning() with the URL in context.
Test without the network. Inject a fake Http implementation into the container so extract() reads fixtures:
$essence = new \Essence\Essence([
'Http' => new FakeHttpClient(resource_path('fixtures/youtube.html')),
'Cache' => new \Essence\Cache\Volatile(),
'Log' => new \Essence\Log\Logger(), // or your adapter
'Dom' => new \Essence\Dom\Parser(),
]);
This is also the pattern for local dev and CI where outbound HTTP is blocked.
Add your own provider (or an internal one). If you own a video platform, subclass Essence\Provider\Provider, declare the $tags it matches, and implement embed resolution. Pass the full providers array — note this replaces the defaults rather than appending, so merge your class onto the package's default list explicitly.
null is the failure mode. extract() swallows exceptions and returns null. Without a real logger you cannot distinguish "unsupported domain" from "DNS timeout" from "YouTube changed markup". Wire the logger first, before debugging anything else.//i.ytimg.com/... for thumbnailUrl and sometimes relative url. Normalize against the provider URL before putting it in an <img src>.html is third-party markup. It's what the provider gives you, which is usually fine, but it is not sanitized and can change shape without notice. Sanitize if it lands anywhere near user-editable content, and always render it inside a sandboxed container.type drives rendering, and it can be absent. Handle video/rich (has html), image/audio (has url/thumbnailUrl), and link (title/description only). Never assume html is present just because extraction succeeded.ArrayAccess/Iterator signatures and null-to-string deprecations. Run the test suite on your target PHP before committing to it; if you see deprecation noise, pin the version and suppress locally rather than patching vendor code. Wrap the whole thing behind a small LinkPreviewService interface so a replacement (or a fork) is a one-file swap.Http instance (timeout, user agent, redirect limit) in the container rather than relying on defaults; some providers behave differently to non-browser UAs. Add your own retry around the service call, not inside it.http:// vs https://, youtube.com/watch?v=X vs youtu.be/X, and trailing-slash variants are all distinct entries. Normalize URLs (lowercase host, strip utm_*, resolve shorteners) before calling extract() or your cache hit rate will be poor.extract() is blocking and synchronous. There's no async API and no concurrency; parallel unfurling means multiple queued jobs or a curl-multi wrapper of your own.Media::__toString() returns html. Handy in Blade ({{ $media }}), but surprising when you expected the URL — cast explicitly to be obvious about it.Cache (biggest win), custom Log, custom Http, custom providers. There is no config file and no Laravel facade — everything is constructor injection into Essence, which is why the singleton binding is the single highest-leverage thing you'll write.How can I help you explore Laravel packages today?