From d72fd194bdd94b8e295c2cfa9883c7c29ea702e1 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Wed, 7 Oct 2026 07:30:11 +0200 Subject: [PATCH] Speed up the `html` escaping strategy for long strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the UTF-8 charset, the `html` strategy calls `htmlspecialchars()`, which decodes the string one character at a time: about 5 ns per byte, so 1.6 µs for the 280-character class list of a styled button, even when nothing needs escaping. On valid UTF-8, all it does is replace `&`, `"`, `'`, `<` and `>`: `ENT_SUBSTITUTE` only matters for invalid sequences. Strings longer than 32 bytes are now checked for UTF-8 validity with `preg_match('//u')` and escaped with `strtr()` on those five characters. Invalid UTF-8 and shorter strings still go through `htmlspecialchars()`, whose fixed cost is lower below that length. The output is identical: compared against `htmlspecialchars()` on every Unicode code point (in three positions) and on invalid sequences (lone continuation bytes, overlong forms, surrogates, code points above U+10FFFF, truncated sequences), 3.3 million strings, no difference on PHP 8.4 and 8.5. The shortcut only applies on PHP < 8.7 (`PHP_VERSION_ID < 80700`), since https://github.com/php/php-src/pull/23957 and https://github.com/php/php-src/pull/24145 (both still open, targeting PHP 8.7) make `htmlspecialchars()` itself skip the work on input that needs no encoding. Benchmark below, PHP 8.4, 3 interleaved runs each side, identical output, ns per escape: | String | Before | After | | --- | --- | --- | | short id (`user-42`) | **155-165 ns** | **168-175 ns** (below the threshold; the length check costs ~10 ns) | | 40-character title | **327-334 ns** | **269-285 ns** | | 280-character CSS class list | **1590-1630 ns** | **498-511 ns** (3.2x) | | paragraph with quotes and tags | **1634-1663 ns** | **758-766 ns** (2.2x) | | accented paragraph | **1310-1321 ns** | **373-380 ns** (3.5x) | End to end, on an admin dashboard built with Symfony UX components that escapes about 2,500 attribute values per request (mostly long Tailwind class lists), CPU time per request goes from **36.7-38.7 ms** to **35.7-36.4 ms** (minimum of 6 runs of 60 requests each side), about 3%. ```php 'user-42', 'title (40 chars)' => 'How we made our dashboard ten times faster', 'CSS classes (280 chars)' => $classes, 'paragraph with quotes' => str_repeat('Twig escapes "quotes" & in a sentence like this one. ', 5), 'accented paragraph' => str_repeat('Une phrase accentuée, déjà très répétée. ', 6), ]; foreach ($cases as $label => $string) { $times = []; for ($run = 0; $run < 7; ++$run) { $start = hrtime(true); for ($i = 0; $i < 200000; ++$i) { $escaper->escape($string); } $times[] = (hrtime(true) - $start) / 200000; } sort($times); printf("%-24s %5.0f ns/escape | sha1 %s\n", $label, $times[3], sha1($escaper->escape($string))); } ``` Blackfire profiles (Symfony UX dashboard): - Before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/b344f602-a21f-41ef-a585-46fc865e0880/graph - After: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/e5e16b8b-8895-40e7-9952-43282e3bd0df/graph - Comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/b344f602-a21f-41ef-a585-46fc865e0880...e5e16b8b-8895-40e7-9952-43282e3bd0df/graph Blackfire barely moves (**2.29 s** -> **2.30 s** overall) because its per-call instrumentation dominates calls this short, even though `htmlspecialchars()` drops out of the profile entirely (5,398 calls per request before). This came out of profiling Symfony UX components on https://github.com/Kocal/sf-ux-perfs-twig-components. --- CHANGELOG | 1 + src/Runtime/EscaperRuntime.php | 7 +++++++ tests/Runtime/EscaperRuntimeTest.php | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index af3ab792bf5..b0b93226c21 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -16,6 +16,7 @@ * Speed up applying `html_cva()` recipes that resolve to classes already seen * Fix a class marked safe for `html_attr` not being considered safe in `html` and `html_attr_relaxed` contexts * Add a way to mark a `Twig\Markup` instance as safe for some escaping strategies only + * Speed up the `html` escaping strategy for long strings # 3.30.0 (2026-09-25) diff --git a/src/Runtime/EscaperRuntime.php b/src/Runtime/EscaperRuntime.php index a2b0df06d37..410a38f0b50 100644 --- a/src/Runtime/EscaperRuntime.php +++ b/src/Runtime/EscaperRuntime.php @@ -29,6 +29,8 @@ final class EscaperRuntime implements RuntimeExtensionInterface 'html_attr_relaxed' => ['html'], ]; + private const HTML_SPECIAL_CHARS = ['&' => '&', '"' => '"', '\'' => ''', '<' => '<', '>' => '>']; + /** @var array */ private $escapers = []; @@ -157,6 +159,11 @@ public function escape($string, string $strategy = 'html', ?string $charset = nu // see https://www.php.net/htmlspecialchars if ('UTF-8' === $charset) { + // Up to PHP 8.6, htmlspecialchars() decodes every character (see https://github.com/php/php-src/pull/23957): on long valid UTF-8 strings, replacing these characters with strtr() is faster + if (\PHP_VERSION_ID < 80700 && \strlen($string) > 32 && preg_match('//u', $string)) { + return strtr($string, self::HTML_SPECIAL_CHARS); + } + return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); } diff --git a/tests/Runtime/EscaperRuntimeTest.php b/tests/Runtime/EscaperRuntimeTest.php index db84d9b7652..c847a3de0c6 100644 --- a/tests/Runtime/EscaperRuntimeTest.php +++ b/tests/Runtime/EscaperRuntimeTest.php @@ -174,6 +174,25 @@ public function testHtmlEscapingConvertsSpecialChars(): void } } + /** + * @dataProvider provideLongStrings + */ + #[DataProvider('provideLongStrings')] + public function testHtmlEscapingLongStringsLikeHtmlspecialchars(string $string): void + { + $this->assertSame(htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'), (new EscaperRuntime())->escape($string, 'html')); + } + + public static function provideLongStrings(): iterable + { + $padding = str_repeat('padding ', 5); + + yield 'nothing to escape' => [$padding]; + yield 'special chars' => [$padding.'Tom & Jerry\'s']; + yield 'multibyte chars' => [$padding.'éàü 😀']; + yield 'invalid UTF-8' => [$padding."\xC3\x28 \xFF \xED\xA0\x80 &"]; + } + public function testHtmlAttributeEscapingConvertsSpecialChars(): void { foreach ($this->htmlAttrSpecialChars as $key => $value) {