On a Laravel community forum that allows lightly formatted user bios, why can wrapping a bio in HtmlString be as dangerous as {!! !!} even when the view uses {{ }}?
answer
- e() trusts one interface
- Htmlable returns toHtml() untouched
- HtmlString is a trust marker, not a filter
- Str::markdown allows raw HTML by default
- html_input strip, allow_unsafe_links false
basics
~20 sBlade's e() helper returns any Htmlable object's toHtml() without escaping, and HtmlString is Htmlable, so a bio wrapped in one renders as live markup inside {{ }}; the wrapper must only ever hold HTML your code has sanitized.
solid answer
~40 s`{{ }}` compiles to `e()`, and `e()` short-circuits for `Illuminate\Contracts\Support\Htmlable`: it returns `toHtml()` as-is. `HtmlString` is `Htmlable`, and its `toHtml()` returns exactly the string it was constructed with, so `new HtmlString($user->bio)` or `str($bio)->markdown()->toHtmlString()` is a raw echo hidden behind safe-looking syntax. Markdown alone does not help: `Str::markdown()` passes raw HTML through unless you pass `html_input => 'strip'` and `allow_unsafe_links => false`. The fix is one reviewed method that renders the bio with those options, or through an HTML purifier when some tags are allowed, and only then returns an `HtmlString`. Creating an `Htmlable` is a claim that the HTML is safe; make that claim in exactly one place.
code
php · 18 lines<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
class Profile extends Model
{
public function bioHtml(): HtmlString
{
return new HtmlString(Str::markdown($this->bio ?? '', [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]));
}
}go deeper
Remember that {{ }} passes Htmlable objects such as HtmlString through unescaped, so wrapping user text in one is a raw echo.
Explain e()'s Htmlable short-circuit, list the framework types that implement it, and why Markdown conversion does not sanitize by default.
Design the single sanitizing method, choose strip options or a purifier, and show how you audit HtmlString, toHtmlString() and markdown() calls across the codebase.
Treat Htmlable creation as a controlled capability: one owner per rich-text field, render-time sanitizing so rules can tighten, and review tooling that flags new trust points.
## The scenario A community forum lets members write a short **bio** with limited formatting - bold, italics, links - usually typed as **Markdown**, a plain-text syntax that a converter turns into HTML. The template author knows the rule "use `{{ }}` for user data", writes `{{ $member->bioHtml() }}`, and the page still ends up running a visitor's `<img src=x onerror=...>` payload. Nothing in the view looks wrong. The hole is in what the method returns. ## Why {{ }} does not escape an Htmlable `{{ $x }}` compiles to `<?php echo e($x); ?>`. The `e()` helper in `Illuminate/Support/helpers.php` checks the type of its argument before escaping: - If the value implements `Illuminate\Contracts\Support\Htmlable`, it returns `$value->toHtml()` **without calling `htmlspecialchars`**. - Otherwise it escapes the value with `htmlspecialchars(..., ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`. `Htmlable` is a one-method interface meaning "this object already is HTML". Framework types that implement it include `HtmlString`, `Js`, `Illuminate\View\View`, `ComponentSlot`, `ComponentAttributeBag` and the paginators. That design is what lets `{{ $slot }}` or `{{ $posts->links() }}` render markup. It also means **`e()` trusts whoever created the object**. `Illuminate\Support\HtmlString` is the general-purpose wrapper. Its constructor stores a string and `toHtml()` returns it unchanged. It does no filtering of any kind. So these three lines are equivalent in effect: ```html {!! $member->bio !!} {{ new HtmlString($member->bio) }} {{ str($member->bio)->toHtmlString() }} ``` The second and third are worse in review, because they pass a quick "no `{!!` in views" check. ## Markdown is not a sanitizer The usual next step is "render the Markdown first". Laravel's `Str::markdown()` wraps the CommonMark GitHub-flavoured converter, and **by default it lets raw HTML through**; the Laravel docs warn that this exposes XSS with raw user input. Two converter options close it: | Option | Safe value | Effect | |---|---|---| | `html_input` | `'strip'` (or `'escape'`) | removes (or escapes) raw HTML blocks and inline tags in the source | | `allow_unsafe_links` | `false` | refuses `javascript:`-style link targets in Markdown links | If the product needs some raw tags - say `<abbr>` - Markdown options are not enough; run the converted HTML through an **HTML purifier**, a library that parses HTML and keeps only an allow-list of elements and attributes. A subtle trap: `str($bio)->markdown()` returns an `Illuminate\Support\Stringable`, which is **not** `Htmlable`. Echoed in `{{ }}` it is escaped, the visitor sees literal `<p>` tags, and the quick "fix" is to append `->toHtmlString()` - which converts a safe-but-ugly page into an unsafe one unless the options above were passed. ## A design that keeps the trust decision in one place 1. Store the **source** Markdown the member typed, not HTML. 2. Give the model (or a presenter) one method that converts it with safe options and, where needed, a purifier. 3. Return an `HtmlString` from that method and nowhere else. 4. Echo it with `{{ }}`; the view stays uniform and the trust decision lives in reviewed PHP. 5. If rendering cost matters, cache the rendered HTML keyed on the bio's last update rather than storing HTML as the source of truth. Sanitizing at render time means that when the rules tighten, every existing bio is re-rendered under the new rules, and data arriving through a seeder, an import or an admin tool cannot skip the filter. ## Why the escape hatch exists at all It would be simpler if `e()` escaped everything, but then Blade could not compose views: `{{ $slot }}` in a component, `{{ $posts->links() }}` for pagination and nested `View` objects all carry markup the framework produced. `Htmlable` is how those framework objects say "already HTML". The design is sound as long as application code follows the same discipline the framework does: only objects whose HTML was **built** rather than **received** implement or wrap `Htmlable`. User input reaching an `HtmlString` constructor breaks that contract silently, and no test that only checks the page renders will notice. ## What to check in review - Every `new HtmlString(`, `toHtmlString()` and `{!!` - which code vouches for the markup? - Every `Str::markdown()` / `->markdown()` on user input - are `html_input` and `allow_unsafe_links` set? - Custom `Htmlable` classes - does `toHtml()` sanitize, or just return a field? The principle: **an `Htmlable` is a signed statement that its HTML is safe**. Blade honours the signature without checking it, so only code that actually cleaned the HTML should be allowed to sign.
- Why not sanitize the bio once on save and store the HTML?Storing HTML makes every write path responsible for sanitizing - seeders, imports, admin screens, API endpoints - and freezes old bios under old rules. Keeping the source Markdown and rendering through one method means a tightened rule applies to every bio at once. Cache the rendered output if the conversion cost shows up.
- What does a visitor see if the method returns str($bio)->markdown() without toHtmlString()?Literal tags such as `<p>` and `<strong>`. `Illuminate\Support\Stringable` is not `Htmlable`, so `e()` escapes it. That output is safe but broken, and the tempting fix of appending `toHtmlString()` is exactly where the sanitizing options must already be in place.
- The forum wants members to use a few raw tags such as <abbr>; what changes?`html_input => 'strip'` would remove them, so Markdown options alone no longer fit. Convert with the options you still want, then run the HTML through an allow-list purifier that keeps only the permitted elements and attributes, and wrap that purified result in `HtmlString`.
An HtmlString is like a visitor badge at a secure building: guards wave through anyone wearing one without searching them. The badge is only as trustworthy as the desk that issues it, so only the desk that actually vetted the visitor should hand one out.
saying these in an interview costs you the question
- {{ }} escapes every value, so any object echoed there is safe
- HtmlString escapes its contents when it is echoed
- Str::markdown sanitizes raw HTML by default
- Only {!! !!} can print unescaped HTML in a Blade view
- Validating bio length and characters on input removes the XSS risk