urodoz/truncate-html
urodoz/truncate-html is a lightweight PHP library for safely truncating HTML strings to a chosen length without breaking tags. It preserves valid markup, supports custom ellipsis and encoding, and is ideal for generating clean excerpts from rich text in Laravel or any PHP project.
This is a single-class, framework-agnostic utility — not a Laravel package. There is no service provider, no facade, no config/truncate-html.php, and no auto-discovery. Don't go looking for them.
Install
composer require urodoz/truncate-html
First use case — the one thing this solves that Str::limit() does not: producing a truncated excerpt from stored HTML body copy without leaving dangling <p>, <strong>, or <a> tags in your Blade output.
use TruncateHTML\TruncateHTML;
$excerpt = (new TruncateHTML())->truncate($post->body, 180, '…');
Where to look first
vendor/urodoz/truncate-html/src/TruncateHTML.php — it is one file, read it in five minutes. You need to know exactly how it walks text nodes before you trust it in a view.vendor/urodoz/truncate-html/composer.json — check the PHP constraint. The package predates PHP 8 and has had no release activity; treat the install as a compatibility risk to verify, not a given.cakersq/truncate-html) has drifted from it.Sanity check before wiring it in
$t = new TruncateHTML();
dump($t->truncate('<p>Hello <strong>world</strong> and more</p>', 12, '…'));
dump($t->truncate('<p></p>', 10, '…'));
dump($t->truncate('', 10, '…'));
If those three return what you expect, you're ready to proceed.
Bind it as a singleton. The class holds no per-call state you need to isolate, and DOM parsing is not free. Register once in AppServiceProvider::register():
$this->app->singleton(TruncateHTML::class, fn () => new TruncateHTML());
Now it's injectable into controllers, jobs, mailables, and resource classes.
Model accessor — the highest-leverage integration for CMS/blog content:
protected function excerpt(): Attribute
{
return Attribute::get(
fn () => app(TruncateHTML::class)->truncate($this->body ?? '', 200, '…')
);
}
Cache it if body is large or the index page is hot; the DOM walk is the expensive part, the string is static.
Str macro — keeps call sites terse and consistent with the rest of Laravel's string helpers:
Str::macro('limitHtml', fn (string $value, int $limit, string $end = '…') =>
app(TruncateHTML::class)->truncate($value, $limit, $end)
);
// Str::limitHtml($post->body, 160)
API resources — this is where truncation belongs when you serve both a summary list and a detail view from the same model. Truncate at serialization time, not in the controller:
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'excerpt' => app(TruncateHTML::class)->truncate($this->body, 200, '…'),
];
}
Word-boundary vs character-bound truncation. Use the word-oriented method for prose excerpts (avoids cutting mid-word); reserve the char-oriented method for fixed-width teasers, meta descriptions, and SMS/notification previews where the hard limit matters more than readability.
Sanitize before truncating, not after. Run untrusted input through mews/purifier (or your sanitizer of choice) first, then truncate. Truncating first and sanitizing after can re-break tags the truncator carefully closed.
Keep a wrapper class. Even a 15-line App\Support\HtmlExcerpt that takes the truncator as a constructor dependency buys you: a single place to swap the implementation later (this package is a 21-star, zero-dependent project — assume you may need to swap it), and a place to centralize the default ellipsis and limit.
It parses with DOMDocument, so malformed HTML is a real failure mode. Unclosed tags, stray <, or broken attribute quoting in stored content can produce normalized output, dropped nodes, or an empty result. Always run user-generated content through a sanitizer/parser first, and wrap the call in a defensive check when the source is not under your control.
Encoding matters. Multi-byte content depends on the document being interpreted as UTF-8. If you see mojibake or a limit that lands in the wrong place for accented/accented-CJK text, that is the cause — not an off-by-one in your limit.
HTML entities count toward length. &, , and numeric entities inflate the character count relative to what the reader perceives. If exact visual length matters, decode/normalize before truncating.
Markup-only and empty input are edge cases worth a test. A body that is all tags, an empty string, or content shorter than the limit are the cases that return surprising values. Assert on them in a single Pest/PHPUnit test file so a future package swap is safe.
Images and other void elements near the cut point may be dropped. If your content is image-heavy, decide explicitly whether an image at the boundary should be preserved, and adjust the limit rather than hoping.
Pin the version and audit it. Zero dependents and minimal recent activity means no one else is catching regressions for you. Commit the exact constraint, and keep the wrapper class so the escape hatch is one line. If you hit a bug, the maintained fork cakersq/truncate-html is the first place to look; for a more actively supported direction, symfony/string combined with an HTML sanitizer, or a full HTML-to-text pass, are the alternatives worth benchmarking against your own edge-case tests.
Debugging tip: because the class is stateless per call, you can compare its output against a naive Str::limit() on the same string to see exactly which tags would have been broken — that diff is the fastest way to justify (or drop) the dependency in a code review.
Extension point: the class is small and not final in spirit — if you need custom ellipsis placement per block element (e.g. no … after a heading), subclass it and override the node-walking behavior rather than post-processing the output string, which will reintroduce the broken-markup problem you adopted the package to avoid.
How can I help you explore Laravel packages today?