danog/telegram-entities
Convert Telegram message entities to and from Markdown, HTML and plain text with this lightweight PHP library. Parse formatted messages, preserve styling, and generate entity arrays for sending rich text via the Telegram Bot API or MadelineProto.
composer require danog/telegram-entities
The package solves exactly one problem, but it is a problem you will hit: Telegram expresses offset/length for every MessageEntity in UTF-16 code units, while PHP gives you UTF-8 bytes and mb_* gives you characters or graphemes. None of those agree once a message contains an emoji, a flag, a CJK string, or a Zalgo-stacked name. Every "bold text is off by one" and "my link points into the middle of an emoji" bug traces back to this.
Where to look first, in this order:
src/Entity.php — the DTO. Note the fields: type, offset, length, plus the optional payload fields (url for text_link, user for text_mention, language for pre, custom emoji id). This is the shape you will persist.src/Entities.php — the collection plus the converters. Grep for public static function here; the factory/converter names (fromHtml / parseHTML / toMarkdown / toTelegram) are the whole API surface, and they carry across versions more reliably than the names themselves.First use case — read a message, render it on the web. This is the 20-minute win: you have a bot or MadelineProto client ingesting updates, and you want a web panel that shows messages with formatting intact.
use danog\TelegramEntities\Entities;
// $update['message'] contains: text + entities[] in UTF-16 units
$entities = Entities::fromTelegram($message['text'], $message['entities'] ?? []);
echo $entities->toHTML(); // safe for a Blade view
Persist text as-is plus json_encode($entities) as a TEXT/JSON column. Do not persist the HTML — that is a rendering decision, not a data decision.
1. Entities are the canonical form; HTML/Markdown are view-layer only. Treat (text, entities[]) as your storage format and convert at the edges. Anything else (HTML in the DB, Markdown in the DB) guarantees a lossy round-trip the first time someone sends a nested text_link inside bold.
// Outbound controller / notification
$entities = Entities::fromHtml('<b>Deploy</b> finished: <a href="https://ci.example.com/42">#42</a>');
$this->telegram->messages->sendMessage([
'peer' => $peer,
'message' => $entities->getText(), // plain text, no tags
'entities' => $entities->toTelegram(), // MessageEntity[] in UTF-16
]);
2. Build a thin application-level value object around it. You will want RichText::fromTelegram(), ->toHtml(), ->toPlain(), ->truncate(). Wrapping the library means the day its constructor signature shifts, you patch one class instead of forty call sites.
final class RichText implements JsonSerializable
{
private function __construct(
public readonly string $text,
public readonly Entities $entities,
) {}
public static function fromTelegram(string $text, array $raw): self
{
return new self($text, Entities::fromTelegram($text, $raw));
}
public function toPlain(): string { return $this->text; }
public function jsonSerialize(): array
{
return ['text' => $this->text, 'entities' => $this->entities];
}
}
3. Normalise heterogeneous input at the boundary. Updates arrive as TL objects from MadelineProto, as plain arrays from webhooks, and as JSON from your queue. Hydrate all three into one Entities instance on ingest. If you skip this, you end up with three subtly different offset conventions and a text_mention bug that only reproduces for one transport.
4. Chunking long messages. Telegram caps at 4096 chars (1024 for captions) and 100 entities per message. When you split, you must slice entities as well as text, and re-base offsets — the library gives you the UTF-16 arithmetic, not the chunking policy. Slice on entity boundaries where possible, drop entities that would be orphaned, and never substr() the text.
5. Use it as a test oracle, not just a converter. In your own entity-handling code (highlighting, search snippets, truncation), assert against this library:
$this->assertSame($expectedLengthInUtf16, $entities->getEntity(0)->length);
That single assertion catches the class of bug that otherwise only shows up in production on a user with an emoji in their display name.
6. Pair with whatever already escapes. If you use MadelineProto, it has its own htmlEscape/markdownEscape helpers. Pick one escaping strategy per code path; mixing two is how you get &amp; in sent messages.
😀 is 2 code units, a family emoji with ZWJ can be 11, and a combining-accent e + ◌́ is 2 units but 1 visible glyph. Quick sanity check in tinker: strlen(mb_convert_encoding($s, 'UTF-16LE', 'UTF-8')) / 2. If your computed length disagrees with that, your offsets are wrong.offset and properly nested. Telegram rejects or silently mangles overlapping, non-nested ranges. bold from 0–10 and italic from 5–15 is illegal; 0–10 and 2–8 is fine. Sort and validate before sending, especially when merging entities from two sources (e.g. an auto-linker plus user formatting).text_link without url, text_mention without user, pre without language all fail validation on send. The DTO will happily carry a half-populated entity; validation is on you.substr($text, 0, 100) . '…' — this slices bytes and will split a surrogate pair, producing invalid UTF-8 and dangling entities. Truncate through the library, or truncate plain text and drop all entities.toHtml() output goes into a browser. Confirm the library escapes &<>" in text nodes; if you are on an older release, escape the plain text yourself and only trust the library for offsets. Never {!! !!} raw user entities in Blade without verifying._*[]()~\>#+-=|{}.!all need escaping outside code spans. If the library emits Markdown rather than MarkdownV2, do not pass it toparse_mode=MarkdownV2— use the entities array instead. Honestly, always prefer sendingentitiesoverparse_mode`; it removes the escaping problem entirely.composer.json, keep the wrapper from Pattern 2, and skim the changelog before composer update — this is a library where a patch release can change a converter's whitespace behaviour.entities against what you computed. Log text, each entity's offset/length, and mb_substr($text, 0, $offset) alongside it — the mismatch jumps out immediately.JsonSerializable, so you can post-process (strip all text_links before storing, promote url entities to real links in your HTML renderer, redact mentions in logs) without forking. Do that in your wrapper rather than patching vendor code.How can I help you explore Laravel packages today?