Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/bc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
paths: &paths
- 'src/**'
- '.roave-backward-compatibility-check.xml'
- 'composer.json'
- '.github/workflows/bc.yml'
push:
Expand Down
7 changes: 7 additions & 0 deletions .roave-backward-compatibility-check.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<roave-bc-check xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/roave/backward-compatibility-check/Resources/schema.xsd">
<baseline>
<ignored-regex>#Default parameter value for parameter \$doNotAddOnStatusCode of Yiisoft\\HttpMiddleware\\ContentLengthMiddleware#</ignored-regex>
<ignored-regex>#Default parameter value for parameter \$statusCodes of Yiisoft\\HttpMiddleware\\RemoveBodyMiddleware#</ignored-regex>
</baseline>
</roave-bc-check>
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions docs/guide/en/content-length-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -44,10 +46,32 @@ Default:
100, // Continue
101, // Switching Protocols
102, // Processing
103, // Early Hints
204, // No Content
205, // Reset Content
304, // Not Modified
]
```

An array of HTTP status codes for which the `Content-Length` header should not be added.

### `$removeOnStatusCode`

Type: `list<int>`

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.
42 changes: 42 additions & 0 deletions docs/guide/en/remove-body-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,10 +42,44 @@ Default:
100, // Continue
101, // Switching Protocols
102, // Processing
103, // Early Hints
204, // No Content
205, // Reset Content
304, // Not Modified
]
```

An array of HTTP status codes for which the body should be removed.

### `$keepHeadersOnStatusCode`

Type: `list<int>`

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<non-empty-string>`

Default:
```php
[
'Content-Length',
'Transfer-Encoding',
]
```

An array of headers to remove together with the body, for status codes not listed in `$keepHeadersOnStatusCode`.
21 changes: 20 additions & 1 deletion src/ContentLengthMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -21,8 +23,11 @@
* 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<int> $doNotAddOnStatusCode
* @psalm-param list<int> $removeOnStatusCode
*/
public function __construct(
private readonly bool $removeOnTransferEncoding = true,
Expand All @@ -31,10 +36,19 @@
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
Expand All @@ -54,7 +68,12 @@

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
Expand All @@ -68,7 +87,7 @@
{
$body = $response->getBody();
if (!$body->isReadable()) {
return $response;

Check warning on line 90 in src/ContentLengthMiddleware.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ { $body = $response->getBody(); if (!$body->isReadable()) { - return $response; + } $contentLength = $body->getSize();
}

$contentLength = $body->getSize();
Expand Down
37 changes: 35 additions & 2 deletions src/RemoveBodyMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,49 @@
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
*/
Comment thread
vjik marked this conversation as resolved.
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<int> $statusCodes
* @psalm-param list<int> $keepHeadersOnStatusCode
* @psalm-param list<non-empty-string> $removedHeaders
*/
public function __construct(
private readonly StreamFactoryInterface $streamFactory,
private readonly array $statusCodes = [
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
Expand All @@ -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;
}
}
40 changes: 40 additions & 0 deletions tests/ContentLengthMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public function testDisabledAdd(): void
#[TestWith([100])]
#[TestWith([101])]
#[TestWith([102])]
#[TestWith([103])]
#[TestWith([204])]
#[TestWith([205])]
#[TestWith([304])]
Expand All @@ -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();
Expand Down
71 changes: 71 additions & 0 deletions tests/RemoveBodyMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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])]
Expand Down Expand Up @@ -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'));
}
}
Loading