pyrrah/twemoji
pyrrah/twemoji is a PHP/Laravel package for converting Unicode emoji in text into Twemoji image markup, with configurable asset URLs and convenient helpers for rendering emoji consistently across platforms.
pyrrah/twemoji is a small, framework-agnostic PHP parser that finds Unicode emoji in a string and swaps them for Twemoji image markup (or SVG). You'll mostly use it at render time on user-generated content: comments, chat messages, bios, notifications.
Install it:
composer require pyrrah/twemoji
Read the README first, then src/Twemoji.php — the public surface is small (a parse()-style method plus a handful of setters for asset type, base URL, and CSS class). If you're on Symfony, src/TwemojiBundle.php and DependencyInjection/ show how the service is wired; there is no Laravel service provider, so you'll bind it yourself.
First use case — a Blade helper:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Pyrrah\Twemoji\Twemoji::class, fn () => new \Pyrrah\Twemoji\Twemoji());
{!! app(\Pyrrah\Twemoji\Twemoji::class)->parse($comment->body) !!}
Then render one real page with mixed text, emoji, and links to confirm the output markup matches what your CSS expects.
v1.2.0 note: this release only widens the Symfony constraint to ^7.0|^8.0. It's a non-breaking, drop-in upgrade from 1.1.x — no API changes, no deprecations. If you're on Laravel only, the practical impact is that the package no longer pins you out of newer Symfony components pulled in transitively by other dependencies. Bump and run your test suite; nothing else to do.
Render-time, not storage-time. Keep the raw emoji in the database and parse on output. Storing HTML means re-parsing on emoji-set changes is impossible, and it makes search over message bodies useless.
Wrap it in a dedicated presenter/helper. Don't scatter app(Twemoji::class)->parse(...) through views. A EmojiRenderer service (or a HasEmoji trait with a rendered_body accessor) gives you one place to change the CDN base URL, image format, or alt-text strategy.
Blade component for the common case:
{{-- resources/views/components/emoji-text.blade.php --}}
@props(['text'])
{!! app(\Pyrrah\Twemoji\Twemoji::class)->parse(e($text)) !!}
Note the order: escape first with e(), then let Twemoji inject its own <img> tags. Never the reverse.
API resources. If you expose content to a SPA or mobile client, parse in the toArray() of your resource rather than in the model, so raw text stays available for the "edit" screen.
Caching. For high-traffic pages (a feed, a public profile), parse once and cache the rendered HTML with a key tied to the source string:
$html = Cache::remember('emoji:'.md5($body), 3600, fn () => $twemoji->parse($body));
Local assets for anything user-facing at scale. Point the base URL at a public/twemoji/ directory (or a self-hosted CDN on your own domain) instead of the default third-party CDN. It removes an external dependency from your critical render path and avoids leaking referrers.
Pair with a markdown renderer. If you compile Markdown to HTML first, run Twemoji on the result but only on text nodes — see the gotcha below about attributes.
parse() twice on the same string, the generated <img src=".../1f600.svg"> gets its URL re-scanned. Guard with an idempotency check (e.g. skip when the string already contains your marker class) or, better, parse exactly once at the edge.https://example.com/😀 or an alt="😀" will get rewritten and can corrupt your markup. Sanitize/strip attributes first, or parse only the text portions.<img> needs meaningful alt text (the original emoji, or nothing if decorative) or screen readers announce the filename. Wrap images in a role="img" + aria-label span if you need strict control.{!! !!} is a sharp knife. Because this package emits HTML, it's tempting to un-escape everything. Keep the escaping inside the helper and never let a controller pass unescaped user input into the component.<img> differ in how you size with font-size/em. Pick one and set .twemoji { height: 1em; width: 1em; vertical-align: -0.1em; } globally, or the lines will jump.intl/mbstring extensions are present. If they appear as broken images, log one generated src and curl -I it — it's almost always a base-URL or asset-version mismatch.How can I help you explore Laravel packages today?