diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md
index b5ad566cf65..d3e4d1c9eeb 100644
--- a/src/Turbo/CHANGELOG.md
+++ b/src/Turbo/CHANGELOG.md
@@ -1,5 +1,10 @@
# CHANGELOG
+## 2.21.0
+
+- Add `Helper/TurboStream::append()` et al. methods
+- Add `TurboStreamResponse`
+
## 2.19.0
- Fix Doctrine proxies are not Broadcasted #3139
diff --git a/src/Turbo/doc/index.rst b/src/Turbo/doc/index.rst
index d20f6c73ed0..336145ca68c 100644
--- a/src/Turbo/doc/index.rst
+++ b/src/Turbo/doc/index.rst
@@ -374,7 +374,7 @@ Let's discover how to use Turbo Streams to enhance your `Symfony forms`_::
{% endblock %}
Supported actions are ``append``, ``prepend``, ``replace``, ``update``,
-``remove``, ``before`` and ``after``.
+``remove``, ``before``, ``after`` and ``refresh``.
`Read the Turbo Streams documentation for more details`_.
Resetting the Form
diff --git a/src/Turbo/phpstan.neon.dist b/src/Turbo/phpstan.neon.dist
index 62d57c07f1f..74ef7289547 100644
--- a/src/Turbo/phpstan.neon.dist
+++ b/src/Turbo/phpstan.neon.dist
@@ -45,3 +45,8 @@ parameters:
message: "#^Call to an undefined method Doctrine\\\\ORM\\\\Event\\\\PostFlushEventArgs\\:\\:getEntityManager\\(\\)\\.$#"
count: 1
path: src/Doctrine/BroadcastListener.php
+
+ -
+ message: "#^Method Symfony\\\\UX\\\\Turbo\\\\TurboStreamResponse::__construct\\(\\) has parameter \\$headers with no value type specified in iterable type array\\.$#"
+ count: 1
+ path: src/TurboStreamResponse.php
diff --git a/src/Turbo/src/Helper/TurboStream.php b/src/Turbo/src/Helper/TurboStream.php
new file mode 100644
index 00000000000..daea084e6ab
--- /dev/null
+++ b/src/Turbo/src/Helper/TurboStream.php
@@ -0,0 +1,97 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Turbo\Helper;
+
+/**
+ * @see https://turbo.hotwired.dev/reference/streams
+ */
+final class TurboStream
+{
+ /**
+ * Appends to the element(s) designated by the target CSS selector.
+ */
+ public static function append(string $target, string $html): string
+ {
+ return self::wrap('append', $target, $html);
+ }
+
+ /**
+ * Prepends to the element(s) designated by the target CSS selector.
+ */
+ public static function prepend(string $target, string $html): string
+ {
+ return self::wrap('prepend', $target, $html);
+ }
+
+ /**
+ * Replaces the element(s) designated by the target CSS selector.
+ */
+ public static function replace(string $target, string $html, bool $morph = false): string
+ {
+ return self::wrap('replace', $target, $html, $morph ? ' method="morph"' : '');
+ }
+
+ /**
+ * Updates the content of the element(s) designated by the target CSS selector.
+ */
+ public static function update(string $target, string $html, bool $morph = false): string
+ {
+ return self::wrap('update', $target, $html, $morph ? ' method="morph"' : '');
+ }
+
+ /**
+ * Removes the element(s) designated by the target CSS selector.
+ */
+ public static function remove(string $target): string
+ {
+ return \sprintf('', htmlspecialchars($target));
+ }
+
+ /**
+ * Inserts before the element(s) designated by the target CSS selector.
+ */
+ public static function before(string $target, string $html): string
+ {
+ return self::wrap('before', $target, $html);
+ }
+
+ /**
+ * Inserts after the element(s) designated by the target CSS selector.
+ */
+ public static function after(string $target, string $html): string
+ {
+ return self::wrap('after', $target, $html);
+ }
+
+ /**
+ * Initiates a Page Refresh to render new content with morphing.
+ *
+ * @see Initiates a Page Refresh to render new content with morphing.
+ */
+ public static function refresh(?string $requestId = null): string
+ {
+ if (null === $requestId) {
+ return '';
+ }
+
+ return \sprintf('', htmlspecialchars($requestId));
+ }
+
+ private static function wrap(string $action, string $target, string $html, string $attr = ''): string
+ {
+ return \sprintf(<<
+ %s
+
+ EOHTML, $action, htmlspecialchars($target), $attr, $html);
+ }
+}
diff --git a/src/Turbo/src/TurboStreamResponse.php b/src/Turbo/src/TurboStreamResponse.php
new file mode 100644
index 00000000000..e597ed30775
--- /dev/null
+++ b/src/Turbo/src/TurboStreamResponse.php
@@ -0,0 +1,107 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Turbo;
+
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\UX\Turbo\Helper\TurboStream;
+
+class TurboStreamResponse extends Response
+{
+ public function __construct(?string $content = '', int $status = 200, array $headers = [])
+ {
+ parent::__construct($content, $status, $headers);
+
+ if (!$this->headers->has('Content-Type')) {
+ $this->headers->set('Content-Type', TurboBundle::STREAM_MEDIA_TYPE);
+ }
+ }
+
+ /**
+ * @return $this
+ */
+ public function append(string $target, string $html): static
+ {
+ $this->setContent($this->getContent().TurboStream::append($target, $html));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function prepend(string $target, string $html): static
+ {
+ $this->setContent($this->getContent().TurboStream::prepend($target, $html));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function replace(string $target, string $html, bool $morph = false): static
+ {
+ $this->setContent($this->getContent().TurboStream::replace($target, $html, $morph));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function update(string $target, string $html, bool $morph = false): static
+ {
+ $this->setContent($this->getContent().TurboStream::update($target, $html, $morph));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function remove(string $target): static
+ {
+ $this->setContent($this->getContent().TurboStream::remove($target));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function before(string $target, string $html): static
+ {
+ $this->setContent($this->getContent().TurboStream::before($target, $html));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function after(string $target, string $html): static
+ {
+ $this->setContent($this->getContent().TurboStream::after($target, $html));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function refresh(?string $requestId = null): static
+ {
+ $this->setContent($this->getContent().TurboStream::refresh($requestId));
+
+ return $this;
+ }
+}
diff --git a/src/Turbo/tests/Helper/TurboStreamTest.php b/src/Turbo/tests/Helper/TurboStreamTest.php
new file mode 100644
index 00000000000..263e454effd
--- /dev/null
+++ b/src/Turbo/tests/Helper/TurboStreamTest.php
@@ -0,0 +1,79 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Turbo\Tests\Helper;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\Turbo\Helper\TurboStream;
+
+class TurboStreamTest extends TestCase
+{
+ /**
+ * @testWith ["append"]
+ * ["prepend"]
+ * ["replace"]
+ * ["update"]
+ * ["before"]
+ * ["after"]
+ */
+ public function testStream(string $action): void
+ {
+ $this->assertSame(<<
+ content
+
+ EOHTML,
+ TurboStream::$action('some["selector"]', 'content
')
+ );
+ }
+
+ /**
+ * @testWith ["replace"]
+ * ["update"]
+ */
+ public function testStreamMorph(string $action): void
+ {
+ $this->assertSame(<<
+ content
+
+ EOHTML,
+ TurboStream::$action('some["selector"]', 'content
', morph: true)
+ );
+ }
+
+ public function testRemove(): void
+ {
+ $this->assertSame(<<
+ EOHTML,
+ TurboStream::remove('some["selector"]')
+ );
+ }
+
+ public function testRefreshWithoutId(): void
+ {
+ $this->assertSame(<<
+ EOHTML,
+ TurboStream::refresh()
+ );
+ }
+
+ public function testRefreshWithId(): void
+ {
+ $this->assertSame(<<
+ EOHTML,
+ TurboStream::refresh('a"b')
+ );
+ }
+}
diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php
index 078ba8da579..f61de905272 100644
--- a/src/Turbo/tests/app/Kernel.php
+++ b/src/Turbo/tests/app/Kernel.php
@@ -220,7 +220,7 @@ public function songs(Request $request, EntityManagerInterface $doctrine, Enviro
$song->artist = $doctrine->find(Artist::class, $artistId);
}
}
- if ($remove = $request->get('remove')) {
+ if ($request->get('remove')) {
$doctrine->remove($song);
} else {
$doctrine->persist($song);