diff --git a/.github/workflows/bc.yml b/.github/workflows/bc.yml
index e429e08..5d3dfa5 100644
--- a/.github/workflows/bc.yml
+++ b/.github/workflows/bc.yml
@@ -4,6 +4,7 @@ on:
pull_request:
paths: &paths
- 'src/**'
+ - '.roave-backward-compatibility-check.xml'
- 'composer.json'
- '.github/workflows/bc.yml'
push:
diff --git a/.roave-backward-compatibility-check.xml b/.roave-backward-compatibility-check.xml
new file mode 100644
index 0000000..a09e91d
--- /dev/null
+++ b/.roave-backward-compatibility-check.xml
@@ -0,0 +1,7 @@
+
+
+ #Default parameter value for parameter \$doNotAddOnStatusCode of Yiisoft\\HttpMiddleware\\ContentLengthMiddleware#
+ #Default parameter value for parameter \$statusCodes of Yiisoft\\HttpMiddleware\\RemoveBodyMiddleware#
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 547555e..43ccf26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,14 @@
## 1.2.2 under development
-- no changes in this release.
+- New #29: Add `$keepHeadersOnStatusCode` and `$removedHeaders` constructor parameters to `RemoveBodyMiddleware` (@vjik)
+- New #29: Add `$removeOnStatusCode` constructor parameter to `ContentLengthMiddleware` (@vjik)
+- Bug #29: Remove `Content-Length` and `Transfer-Encoding` headers in `RemoveBodyMiddleware` when the body is
+ removed (@vjik)
+- Bug #29: Remove already present `Content-Length` header in `ContentLengthMiddleware` for status codes that must
+ not carry one (@vjik)
+- Bug #29: Add missing `103 Early Hints` status code to default status code lists in `RemoveBodyMiddleware` and
+ `ContentLengthMiddleware` (@vjik)
## 1.2.1 August 10, 2026
diff --git a/docs/guide/en/content-length-middleware.md b/docs/guide/en/content-length-middleware.md
index ec507c0..405c207 100644
--- a/docs/guide/en/content-length-middleware.md
+++ b/docs/guide/en/content-length-middleware.md
@@ -6,6 +6,8 @@ A configurable middleware that manages the `Content-Length` HTTP response header
This middleware is used to:
- remove the `Content-Length` header if the `Transfer-Encoding` header is present (typically for chunked responses);
+- remove an already present `Content-Length` header for status codes that must not carry one, such as
+ `204 No Content` (see [RFC 9110, §8.6](https://datatracker.ietf.org/doc/html/rfc9110#section-8.6));
- add the `Content-Length` header if it's missing and the response body allows it.
Default usage:
@@ -44,6 +46,7 @@ Default:
100, // Continue
101, // Switching Protocols
102, // Processing
+ 103, // Early Hints
204, // No Content
205, // Reset Content
304, // Not Modified
@@ -51,3 +54,24 @@ Default:
```
An array of HTTP status codes for which the `Content-Length` header should not be added.
+
+### `$removeOnStatusCode`
+
+Type: `list`
+
+Default:
+```php
+[
+ 100, // Continue
+ 101, // Switching Protocols
+ 102, // Processing
+ 103, // Early Hints
+ 204, // No Content
+ 205, // Reset Content
+]
+```
+
+An array of HTTP status codes for which an already present `Content-Length` header should be removed. `304`
+is not included by default, since per [RFC 9110, §8.6](https://datatracker.ietf.org/doc/html/rfc9110#section-8.6)
+a `304 Not Modified` response may still carry `Content-Length` describing the representation that would have
+been sent in a `200 OK` response to the same request.
diff --git a/docs/guide/en/remove-body-middleware.md b/docs/guide/en/remove-body-middleware.md
index fd6d439..a34a742 100644
--- a/docs/guide/en/remove-body-middleware.md
+++ b/docs/guide/en/remove-body-middleware.md
@@ -3,6 +3,14 @@
This middleware removes the body from the response based on the status code. It is useful when you want to ensure that
no body content is sent for certain HTTP responses, such as `204 No Content` or `304 Not Modified`.
+When the body is removed, headers describing it — `Content-Length` and `Transfer-Encoding` by default — are removed
+as well, so the response does not advertise content that is no longer there. The exception is `304 Not Modified`:
+per [RFC 9110, §8.6](https://datatracker.ietf.org/doc/html/rfc9110#section-8.6) and
+[RFC 9112, §6.1](https://datatracker.ietf.org/doc/html/rfc9112#section-6.1) /
+[§6.3](https://datatracker.ietf.org/doc/html/rfc9112#section-6.3), a `304` response may still carry these
+headers to describe the representation that would have been sent in a `200 OK` response to the same request, so
+they are kept by default.
+
General usage:
```php
@@ -34,6 +42,7 @@ Default:
100, // Continue
101, // Switching Protocols
102, // Processing
+ 103, // Early Hints
204, // No Content
205, // Reset Content
304, // Not Modified
@@ -41,3 +50,36 @@ Default:
```
An array of HTTP status codes for which the body should be removed.
+
+### `$keepHeadersOnStatusCode`
+
+Type: `list`
+
+Default:
+```php
+[
+ 304, // Not Modified
+]
+```
+
+An array of HTTP status codes for which headers listed in `$removedHeaders` are kept even though the body is
+removed. By default, this only applies to `304 Not Modified`, since per
+[RFC 9110, §8.6](https://datatracker.ietf.org/doc/html/rfc9110#section-8.6)
+and [RFC 9112, §6.1](https://datatracker.ietf.org/doc/html/rfc9112#section-6.1) /
+[§6.3](https://datatracker.ietf.org/doc/html/rfc9112#section-6.3) a `304` response may still carry
+`Content-Length` and `Transfer-Encoding` describing the representation that would have been sent in a `200 OK`
+response to the same request.
+
+### `$removedHeaders`
+
+Type: `list`
+
+Default:
+```php
+[
+ 'Content-Length',
+ 'Transfer-Encoding',
+]
+```
+
+An array of headers to remove together with the body, for status codes not listed in `$keepHeadersOnStatusCode`.
diff --git a/src/ContentLengthMiddleware.php b/src/ContentLengthMiddleware.php
index bf73b56..88476de 100644
--- a/src/ContentLengthMiddleware.php
+++ b/src/ContentLengthMiddleware.php
@@ -13,6 +13,8 @@
/**
* Configurable middleware that adds or removes the `Content-Length` header from the response.
+ *
+ * @see https://datatracker.ietf.org/doc/html/rfc9110#section-8.6
*/
final class ContentLengthMiddleware implements MiddlewareInterface
{
@@ -21,8 +23,11 @@ final class ContentLengthMiddleware implements MiddlewareInterface
* is present.
* @param bool $add Whether to add the `Content-Length` header if not present.
* @param array $doNotAddOnStatusCode List of HTTP status codes where `Content-Length` header should not be added.
+ * @param array $removeOnStatusCode List of HTTP status codes for which an already present `Content-Length`
+ * header should be removed.
*
* @psalm-param list $doNotAddOnStatusCode
+ * @psalm-param list $removeOnStatusCode
*/
public function __construct(
private readonly bool $removeOnTransferEncoding = true,
@@ -31,10 +36,19 @@ public function __construct(
100, // Continue
101, // Switching Protocols
102, // Processing
+ 103, // Early Hints
204, // No Content
205, // Reset Content
304, // Not Modified
],
+ private readonly array $removeOnStatusCode = [
+ 100, // Continue
+ 101, // Switching Protocols
+ 102, // Processing
+ 103, // Early Hints
+ 204, // No Content
+ 205, // Reset Content
+ ],
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -54,7 +68,12 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
private function shouldRemoveContentLength(ResponseInterface $response): bool
{
- return $this->removeOnTransferEncoding && $response->hasHeader('Transfer-Encoding');
+ if ($this->removeOnTransferEncoding && $response->hasHeader('Transfer-Encoding')) {
+ return true;
+ }
+
+ return $response->hasHeader('Content-Length')
+ && in_array($response->getStatusCode(), $this->removeOnStatusCode, true);
}
private function shouldSkipContentLength(ResponseInterface $response): bool
diff --git a/src/RemoveBodyMiddleware.php b/src/RemoveBodyMiddleware.php
index 4b957bd..3807057 100644
--- a/src/RemoveBodyMiddleware.php
+++ b/src/RemoveBodyMiddleware.php
@@ -13,15 +13,30 @@
use function in_array;
/**
- * Removes the body from the response for specific HTTP status codes.
+ * Removes the body from the response for specific HTTP status codes, along with headers that describe
+ * the now-removed body (such as `Content-Length` and `Transfer-Encoding`).
+ *
+ * For status codes such as `304 Not Modified`, these headers are kept by default, since per RFC 9110 / RFC 9112
+ * they are still allowed to describe the representation that would have been sent in a `200 OK` response
+ * to the same request, even though no body is actually sent.
+ *
+ * @see https://datatracker.ietf.org/doc/html/rfc9110#section-8.6
+ * @see https://datatracker.ietf.org/doc/html/rfc9112#section-6.1
+ * @see https://datatracker.ietf.org/doc/html/rfc9112#section-6.3
*/
final class RemoveBodyMiddleware implements MiddlewareInterface
{
/**
* @param StreamFactoryInterface $streamFactory Factory to create a stream.
* @param array $statusCodes List of HTTP status codes for which the body should be removed.
+ * @param array $keepHeadersOnStatusCode List of HTTP status codes for which {@see $removedHeaders} should be
+ * kept even though the body is removed.
+ * @param array $removedHeaders List of headers to remove together with the body, for status codes not listed
+ * in {@see $keepHeadersOnStatusCode}.
*
* @psalm-param list $statusCodes
+ * @psalm-param list $keepHeadersOnStatusCode
+ * @psalm-param list $removedHeaders
*/
public function __construct(
private readonly StreamFactoryInterface $streamFactory,
@@ -29,10 +44,18 @@ public function __construct(
100, // Continue
101, // Switching Protocols
102, // Processing
+ 103, // Early Hints
204, // No Content
205, // Reset Content
304, // Not Modified
],
+ private readonly array $keepHeadersOnStatusCode = [
+ 304, // Not Modified
+ ],
+ private readonly array $removedHeaders = [
+ 'Content-Length',
+ 'Transfer-Encoding',
+ ],
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -51,8 +74,18 @@ private function shouldRemoveBody(ResponseInterface $response): bool
private function removeBody(ResponseInterface $response): ResponseInterface
{
- return $response->withBody(
+ $response = $response->withBody(
$this->streamFactory->createStream(),
);
+
+ if (in_array($response->getStatusCode(), $this->keepHeadersOnStatusCode, true)) {
+ return $response;
+ }
+
+ foreach ($this->removedHeaders as $header) {
+ $response = $response->withoutHeader($header);
+ }
+
+ return $response;
}
}
diff --git a/tests/ContentLengthMiddlewareTest.php b/tests/ContentLengthMiddlewareTest.php
index c1cb251..972ec42 100644
--- a/tests/ContentLengthMiddlewareTest.php
+++ b/tests/ContentLengthMiddlewareTest.php
@@ -94,6 +94,7 @@ public function testDisabledAdd(): void
#[TestWith([100])]
#[TestWith([101])]
#[TestWith([102])]
+ #[TestWith([103])]
#[TestWith([204])]
#[TestWith([205])]
#[TestWith([304])]
@@ -113,6 +114,45 @@ public function testDoNotAddOnStatusCodeDefaults(int $statusCode): void
assertSame([], $response->getHeaders());
}
+ #[TestWith([true, 100])]
+ #[TestWith([true, 101])]
+ #[TestWith([true, 102])]
+ #[TestWith([true, 103])]
+ #[TestWith([true, 204])]
+ #[TestWith([true, 205])]
+ #[TestWith([false, 304])]
+ public function testRemoveOnStatusCodeDefaults(bool $expectRemoved, int $statusCode): void
+ {
+ $request = (new ServerRequestFactory())->createServerRequest('GET', '/');
+ $handler = new FakeRequestHandler(
+ new Response(
+ $statusCode,
+ headers: ['Content-Length' => '500'],
+ ),
+ );
+ $middleware = new ContentLengthMiddleware();
+
+ $response = $middleware->process($request, $handler);
+
+ assertSame(!$expectRemoved, $response->hasHeader('Content-Length'));
+ }
+
+ public function testCustomRemoveOnStatusCode(): void
+ {
+ $request = (new ServerRequestFactory())->createServerRequest('GET', '/');
+ $handler = new FakeRequestHandler(
+ new Response(
+ 304,
+ headers: ['Content-Length' => '500'],
+ ),
+ );
+ $middleware = new ContentLengthMiddleware(removeOnStatusCode: [304]);
+
+ $response = $middleware->process($request, $handler);
+
+ assertSame(false, $response->hasHeader('Content-Length'));
+ }
+
public function testDoNotAddOnZeroLength(): void
{
$body = (new StreamFactory())->createStream();
diff --git a/tests/RemoveBodyMiddlewareTest.php b/tests/RemoveBodyMiddlewareTest.php
index b17f1e5..16c7c88 100644
--- a/tests/RemoveBodyMiddlewareTest.php
+++ b/tests/RemoveBodyMiddlewareTest.php
@@ -19,6 +19,7 @@ final class RemoveBodyMiddlewareTest extends TestCase
#[TestWith([false, 100])]
#[TestWith([false, 101])]
#[TestWith([false, 102])]
+ #[TestWith([false, 103])]
#[TestWith([false, 204])]
#[TestWith([false, 205])]
#[TestWith([false, 304])]
@@ -57,4 +58,74 @@ public function testCustomStatus(bool $expectBody, int $statusCode): void
assertSame($expectBody ? 'test' : '', (string) $response->getBody());
}
+
+ #[TestWith([true, 100])]
+ #[TestWith([true, 101])]
+ #[TestWith([true, 102])]
+ #[TestWith([true, 103])]
+ #[TestWith([true, 204])]
+ #[TestWith([true, 205])]
+ #[TestWith([false, 304])]
+ public function testHeadersAreRemovedExceptForKeptStatusCodes(bool $expectHeadersRemoved, int $statusCode): void
+ {
+ $streamFactory = new StreamFactory();
+ $requestHandler = new FakeRequestHandler(
+ (new Response(
+ statusCode: $statusCode,
+ body: (new StreamFactory())->createStream('test'),
+ ))
+ ->withHeader('Content-Length', '4')
+ ->withHeader('Transfer-Encoding', 'chunked'),
+ );
+ $middleware = new RemoveBodyMiddleware($streamFactory);
+
+ $response = $middleware->process(new ServerRequest(), $requestHandler);
+
+ assertSame(!$expectHeadersRemoved, $response->hasHeader('Content-Length'));
+ assertSame(!$expectHeadersRemoved, $response->hasHeader('Transfer-Encoding'));
+ }
+
+ public function testCustomKeepHeadersOnStatusCode(): void
+ {
+ $streamFactory = new StreamFactory();
+ $requestHandler = new FakeRequestHandler(
+ (new Response(
+ statusCode: 204,
+ body: (new StreamFactory())->createStream('test'),
+ ))
+ ->withHeader('Content-Length', '4')
+ ->withHeader('Transfer-Encoding', 'chunked'),
+ );
+ $middleware = new RemoveBodyMiddleware(
+ $streamFactory,
+ keepHeadersOnStatusCode: [204],
+ );
+
+ $response = $middleware->process(new ServerRequest(), $requestHandler);
+
+ assertSame(true, $response->hasHeader('Content-Length'));
+ assertSame(true, $response->hasHeader('Transfer-Encoding'));
+ }
+
+ public function testCustomRemovedHeaders(): void
+ {
+ $streamFactory = new StreamFactory();
+ $requestHandler = new FakeRequestHandler(
+ (new Response(
+ statusCode: 204,
+ body: (new StreamFactory())->createStream('test'),
+ ))
+ ->withHeader('Content-Length', '4')
+ ->withHeader('Transfer-Encoding', 'chunked'),
+ );
+ $middleware = new RemoveBodyMiddleware(
+ $streamFactory,
+ removedHeaders: ['Content-Length'],
+ );
+
+ $response = $middleware->process(new ServerRequest(), $requestHandler);
+
+ assertSame(false, $response->hasHeader('Content-Length'));
+ assertSame(true, $response->hasHeader('Transfer-Encoding'));
+ }
}