46 KiB
Guzzle PSR-7 Upgrade Guide
2.x to 3.0
Guzzle PSR-7 3.0 is a major release that raises the minimum PHP version, updates to the PSR-7 v2 interfaces, validates header values more strictly, preserves explicit request method casing, and rejects several invalid URI, request, response, upload, query, stream, and multipart values that 2.x previously accepted or cast.
PHP Version and Dependencies
Guzzle PSR-7 3.0 requires PHP ^7.4 || ^8.0. Guzzle PSR-7 2.x supported PHP
^7.2.5 || ^8.0.
If your application still supports PHP 7.2 or 7.3, continue using Guzzle PSR-7 2.x until your minimum PHP version is raised.
Guzzle PSR-7 3.0 requires psr/http-message:^2.0 and
psr/http-factory:^1.1. Guzzle PSR-7 2.x supported
psr/http-message:^1.1 || ^2.0 and psr/http-factory:^1.0. If your dependency
constraints pin psr/http-message to v1, update them before upgrading.
Guzzle PSR-7 no longer depends on ralouphie/getallheaders and no longer
provides a transitive global getallheaders() polyfill.
ServerRequest::fromGlobals() continues to collect request headers internally.
Applications that call getallheaders() directly on SAPIs where PHP does not
provide it should require their own polyfill.
Header Values
Header values must now be strings or non-empty arrays of strings. Empty strings
remain valid explicit header values, but empty arrays, null, false, integers,
floats, and other non-string values are no longer cast or accepted.
// 2.x, no longer accepted in 3.0
$response = $response->withHeader('Api-Version', 1);
$response = $response->withHeader('Empty-List', []);
// 3.0
$response = $response->withHeader('Api-Version', '1');
$response = $response->withHeader('Empty-Value', '');
Use withoutHeader() to remove a header.
Request Method Casing
Request methods passed explicitly to Request, ServerRequest, withMethod(),
Message::parseRequest(), and the PSR-17 factories are no longer uppercased.
PSR-7 treats method names as case-sensitive, so these APIs now preserve the
method exactly as provided. If your application requires uppercase methods,
normalize methods before constructing or modifying requests.
ServerRequest::fromGlobals() is the compatibility-oriented exception. It
continues to uppercase string REQUEST_METHOD values read from PHP server
globals, matching Guzzle PSR-7 2.x and common server request behavior. This
normalization only applies when hydrating from globals; it does not apply to
methods passed explicitly to constructors, factories, or withMethod().
// 2.x
$request = new Request('get', '/');
$request->getMethod(); // GET
// 3.0
$request = new Request('get', '/');
$request->getMethod(); // get
// 3.0, server globals
$_SERVER['REQUEST_METHOD'] = 'post';
$request = ServerRequest::fromGlobals();
$request->getMethod(); // POST
Native PSR-7 Parameter Types
Guzzle PSR-7 3.0 requires the argument types documented by PSR-7 more strictly.
It adds the native parameter types from psr/http-message v2. Code passing
invalid argument types may now receive PHP TypeError exceptions instead of
package-specific InvalidArgumentException exceptions or implicit casts.
Native parameter type changes include:
MessageInterface::withProtocolVersion()now requiresstring.- Message header names now require
string. RequestInterface::withRequestTarget()andwithMethod()now requirestring.RequestInterface::withUri()now requiresboolfor$preserveHost.ResponseInterface::withStatus()now requiresintstatus codes andstringreason phrases.- Server request attribute names now require
string. UriInterface::withPort()now requiresint|null.- URI scheme, user info, host, path, query, and fragment mutators now require strings.
- Stream
seek(),read(),write(), andgetMetadata()now require their PSR-7 v2 parameter types. UploadedFileInterface::moveTo()now requires a string target path.
Update callers to pass values of the documented type before calling these methods:
// 2.x, no longer supported in 3.0
$response = $response->withStatus('201');
$uri = $uri->withPort('8080');
// 3.0
$response = $response->withStatus(201);
$uri = $uri->withPort(8080);
Request Modification Changes
Utils::modifyRequest() now validates recognized change values before applying
request modifications. Unknown change keys are still ignored. Explicit null
values are no longer treated as omitted recognized changes; omit the key instead.
Recognized change values must use the documented types:
method:stringuri:UriInterfacequery:stringversion:stringbody:resource|string|StreamInterface|callable|\Iterator|\Stringableset_headers:array<array-key, string|non-empty-array<array-key, string>>remove_headers:array<array-key, string|int>
When a uri change contains a host, the synthesized Host header now
includes any non-default URI port, matching the Request constructor. 2.x
omitted port zero and every port on schemes other than HTTP and HTTPS.
Uploaded Files
ServerRequestInterface::withUploadedFiles() now rejects invalid nested upload
trees. Every leaf must be an UploadedFileInterface instance.
ServerRequest::normalizeFiles() and ServerRequest::fromGlobals() now reject
malformed $_FILES specifications earlier. Single-file specifications must
contain non-null tmp_name, size, and error values. Single-file and nested
file size values and error values must be non-negative PHP integers;
numeric strings are no longer cast. If PHP supplies an upload size as a string
because the byte count cannot fit in PHP_INT_MAX, it is rejected rather than
truncated or cast. Nested specifications must provide tmp_name, size, and
error as arrays. Every key in tmp_name must also exist in size and
error; additional metadata entries without a matching tmp_name entry are
ignored. When nested name or type metadata is provided, it must also be an
array.
If your tests or adapters build $_FILES arrays manually, populate the full
shape or create UploadedFile instances directly.
// 2.x, no longer accepted in 3.0
$files = ['file' => ['tmp_name' => '/tmp/php123', 'error' => '0']];
// 3.0
$files = ['file' => ['tmp_name' => '/tmp/php123', 'size' => 123, 'error' => UPLOAD_ERR_OK]];
For stream-backed uploads, UploadedFile::moveTo() now rewinds seekable streams
before copying them. If application code reads from a seekable uploaded stream
before calling moveTo(), 3.0 writes the full stream contents to the target
instead of only the unread suffix. Non-seekable stream-backed uploads continue to
copy from their current position because consumed bytes cannot be replayed.
Parsed Body Values
ServerRequestInterface::withParsedBody() now rejects values other than
array, object, or null.
// 2.x, no longer accepted in 3.0
$request = $request->withParsedBody('name=value');
// 3.0
$request = $request->withParsedBody(['name' => 'value']);
URI Host and Scheme Validation
URI hosts containing URI delimiters, backslashes, embedded ports passed to
withHost(), malformed IP-literal brackets, unbracketed IPv6, or other
malformed host forms are no longer accepted. URI schemes containing whitespace
or control characters are also no longer accepted.
If you previously passed a host and port together to withHost(), split them
between withHost() and withPort():
// 2.x, no longer accepted in 3.0
$uri = $uri->withHost('example.com:8080');
// 3.0
$uri = $uri->withHost('example.com')->withPort(8080);
Normal URI strings with ports are still supported:
$uri = new Uri('https://example.com:8080/path');
URI parsing now accepts bracketed IPv6 and IPvFuture hosts consistently with
withHost() for userinfo and network-path authorities such as
http://user@[::1]/, //[::1], and http://[v7.a:b]/. Invalid
delimiter-free bracketed literals, such as [gggg::1], are also reported with
the intact host. Bracketed literals containing authority/path delimiters, such
as [a@b] or [v1.a/b], still reject after fallback parsing and may report the
mangled parsed host.
Userinfo before a bracketed IP-literal host is now percent-encoded ahead of
parsing, so raw control bytes yield encoded userinfo, such as us%01er,
instead of a silently mutated value, and raw DEL bytes in bracketed hosts are
rejected instead of parsed as a mutated host. Consistent with registered-name
authorities, such userinfo containing invalid UTF-8 is now rejected,
percent-sequences such as u%41 are preserved rather than decoded, and a
literal + is preserved rather than decoded to a space.
Only an optional numeric port (which may be empty) and a path, query, or
fragment may follow a bracketed IP-literal host. Trailing bytes that are
neither, such as http://[::1]:80@evil/ or http://[::1]:80x/, are now
rejected instead of being reparsed into a different host.
Parsing still URL-decodes bracketed IP-literal hosts before validation
(registered-name hosts round-trip unchanged), so a literal + in a bracketed
IP-literal decodes to a space and is rejected: withHost('[v1.fe80::a+en1]')
accepts the literal while parsing http://[v1.fe80::a+en1]/ rejects it.
Percent-encoding inside a bracketed IP-literal is now rejected during parsing as
well, since RFC 3986 IP-literals contain no percent-encoding, so
http://[%3A%3A1]/ no longer decodes to [::1]; this matches withHost() and
Rfc3986::isValidHost().
Percent-encoded octets in a registered-name host are normalized to uppercase
hex, so a host such as a%c3%a9b is represented as a%C3%A9b. Malformed
percent sequences and percent-encoded octets that decode to a byte forbidden in
a host, such as ex%zz and %2fhost, are rejected.
Uri::fromParts() accepts integer and decimal digit string ports, but floats and
other port values are no longer cast.
Common host forms such as localhost, single-label hosts, underscores, Unicode
hosts, valid IPv6 literals, and normal host and port URI strings remain
supported.
URI schemes must now match RFC 3986 syntax and begin with a letter.
// 2.x, no longer accepted in 3.0
$uri = (new Uri())->withScheme('0');
// 3.0
$uri = (new Uri())->withScheme('https');
The stricter validation also applies when a request is created or modified from
a custom UriInterface implementation and its host is used to generate or
update a Host header.
ServerRequest::getUriFromGlobals() now falls back to SERVER_NAME, then
SERVER_ADDR, then the existing default host behavior for malformed
HTTP_HOST values. It also rejects zero-port HTTP_HOST authorities and
malformed SERVER_PORT values when fallback authority reconstruction needs the
server port. When REQUEST_URI is absolute-form or CONNECT authority-form and
supplies a valid authority, that authority is used before fallback SERVER_PORT
validation. Origin-form, asterisk-form, missing REQUEST_URI, and fallback
reconstruction paths still reject malformed SERVER_PORT values when fallback
authority reconstruction needs the server port.
Absolute-form REQUEST_URI userinfo is removed when reconstructing the URI and
request target from globals, including empty userinfo such as
http://@example.com/. The host after the last raw @ remains the URI host.
Message::parseRequest() now applies 3.0 authority rules when deriving a URI
from an origin-form or asterisk-form request target. Host ports with leading
zeroes are normalized for URI reconstruction, and port zero is rejected.
It also rejects duplicate Host field lines, including case-insensitive
duplicates. Any present raw Host field is validated before returning a parsed
request, even when the request target supplies the URI authority, such as
absolute-form and CONNECT requests. Valid Host values may still differ from
the absolute-form or CONNECT request-target authority.
For server globals, applications that need to reject malformed inbound Host
headers should validate the original server parameters before calling
getUriFromGlobals() or inspect them afterward.
Message::parseRequest() now applies the same HTTP authority validation to
absolute-form request targets. A zero or padded-zero port
(http://example.com:0/admin) is rejected instead of producing a request
whose synthesized Host header the same parser rejects elsewhere.
Absolute-form targets with no URI host, such as file:///etc/passwd, are
also rejected instead of producing a hostless request URI.
Absolute-form targets whose authority contains userinfo are also rejected,
including empty userinfo such as http://@example.com/. RFC 9110 deprecates
userinfo in http(s) target URIs and directs recipients to treat its presence
as an error; Host headers and CONNECT targets already reject it. These
rules apply to absolute-form targets of every scheme.
ServerRequest::fromGlobals() is unchanged and continues to strip
REQUEST_URI userinfo.
URI hosts now validate percent-encoding. Malformed sequences such as ex%zz,
and percent-encoded octets that decode to bytes the raw host grammar already
rejects (controls, space, DEL, /, ?, #, @, \, :, [, ], and %
itself) throw MalformedUriException from URI parsing and
InvalidArgumentException from Uri::withHost(), and are rejected wherever
hosts are validated, including Host headers and request targets in
Message::parseRequest(). WHATWG-conformant browsers reject all of these hosts;
curl rejects them too, except encoded DEL (%7F), which it decodes and forwards
to name resolution. Other percent-encoded octets, including UTF-8 data such as
a%C3%A9b, remain accepted and are normalized to uppercase hex.
IPv6 hosts are now canonicalized to their RFC 5952 form when a URI is
constructed, so getHost(), getAuthority(), and (string) $uri return the
canonical spelling and synthesized Host headers use it. Leading zeros are
suppressed, hexadecimal fields are lowercase, and the longest run of two or
more zero fields is collapsed with ::. Embedded dotted-decimal notation
follows the rendering policy of BIND-derived inet_ntop() implementations and
curl 8.11 and newer: exactly the IPv4-mapped (::ffff:0:0/96) and deprecated
IPv4-compatible (::/96) layouts use it, while other embedded-IPv4 forms,
including translated (NAT64) well-known prefixes such as 64:ff9b::/96
(RFC 6052), serialize in pure hexadecimal fields.
// 2.x preserved the spelling as given
(string) new Uri('http://[0:0:0:0:0:0:0:1]/'); // http://[0:0:0:0:0:0:0:1]/
// 3.0
(string) new Uri('http://[0:0:0:0:0:0:0:1]/'); // http://[::1]/
(string) new Uri('http://[::FFFF:7F00:1]/'); // http://[::ffff:127.0.0.1]/
(string) new Uri('http://[2001:db8:3:4::192.0.2.33]/'); // http://[2001:db8:3:4::c000:221]/
Applications that persist URI strings, for example as cache keys, will observe
the new canonical form for previously non-canonical IPv6 spellings. Equivalent
spellings of the same address now compare as same-origin in
UriComparator::isCrossOrigin(), which canonicalizes bracketed IPv6 literals
from any PSR-7 implementation before comparing hosts, and as equivalent in
UriNormalizer::isEquivalent(). The new
UriNormalizer::CANONICALIZE_IPV6_HOST flag, included in the default
UriNormalizer::PRESERVING_NORMALIZATIONS, requests the canonical host from
other PSR-7 implementations through withHost() and keeps the result only
when the returned getHost() exactly matches the requested spelling; a
nonexact result leaves that step unchanged while other selected normalizations
still apply, and setter exceptions propagate. UriComparator does not share
this limitation, since it canonicalizes the extracted host text directly. The
public helper Rfc3986::canonicalizeIpv6() exposes the underlying
transformation.
Request Host Synchronization
Request::withUri() now applies PSR-7 Host header synchronization before using
the same-URI no-op shortcut. When the provided URI is the same object already
attached to the request, the method may still return a new request if the URI
has a host and the current Host header is missing, empty, or stale.
With $preserveHost = true, a non-empty Host header is still preserved. Missing
or empty Host headers are treated as absent and are populated from the URI when
the URI contains a host.
$request = (new Request('GET', 'http://example.com:8124/'))->withoutHeader('Host');
$updated = $request->withUri($request->getUri());
$updated->getHeaderLine('Host'); // example.com:8124
If your application intentionally sends an empty or stale Host header, set it
after calling withUri() or preserve a non-empty Host header explicitly.
Message::toString() now applies the same URI host and port synthesis when
serializing a request without a Host header. Generated Host lines include
non-null URI ports.
Message::toString() also validates the host it synthesizes from the request
URI and throws InvalidArgumentException for an invalid host, closing a header-
injection vector. This affects only a custom UriInterface implementation that
returns an invalid host when the request has no stored Host header; first-
party Uri instances always carry a valid host and are unaffected.
URI Paths and Request Targets
Uri::getPath() now normalizes multiple leading slashes to one slash when
returning the path in isolation. Casting the URI to string still preserves the
original URI representation.
$uri = new Uri('http://example.org//valid///path');
$uri->getPath(); // /valid///path
(string) $uri; // http://example.org//valid///path
Request::getRequestTarget() applies the same normalization for URI-derived
origin-form request targets.
Reference resolution and normalization (UriResolver, UriNormalizer, and
Uri::isSameDocumentReference()) operate on the raw path from the URI string
form and are therefore unaffected by this normalization.
Authority-less file URIs with rootless paths now serialize without the //
authority separator: (string) new Uri('file:foo/bar') returns file:foo/bar
instead of file://foo/bar, which reparses with host foo and path /bar.
Rooted paths such as file:///myfile keep their existing serialization.
Authority-less file URIs with empty paths now serialize as file: instead of
file://, which new Uri() itself rejects as unparseable. This affects
degenerate URIs such as new Uri('file:') or
Uri::fromParts(['scheme' => 'file']); the serialization of every file URI
with a non-empty path is unchanged.
UriResolver::removeDotSegments() now applies RFC 3986 Section 5.2.4 to ..
segments above the root of an absolute path: excess .. segments no longer
consume the root, so a following empty segment is preserved. Resolving /..//a
against http://example.org/base yields http://example.org//a where 2.x
produced http://example.org/a. When the resulting URI has no authority,
UriResolver::resolve() and UriNormalizer::normalize() serialize such a
//-leading path with a /. prefix (mailto:/.//a), like the WHATWG URL
Standard, instead of collapsing the slashes or throwing.
URI Reference Relativization
UriResolver::relativize() now returns a network-path reference (for example
//example.com) when the target URI has the same authority as the base URI
but an empty path that no other relative reference round-trips. No path
reference can express such a target, as resolving one always produces a path
of at least /, and an empty reference would keep the base path or inherit
the base query or fragment. The returned reference resolves back to the
exact target string, restoring the documented round-trip guarantee for these
targets.
$base = new Uri('http://example.com/a');
$target = new Uri('http://example.com');
// 2.x
(string) UriResolver::relativize($base, $target); // ../
// which resolved back to http://example.com/
// 3.0
(string) UriResolver::relativize($base, $target); // //example.com
The same applies when the base URI has a query or fragment component that an
empty relative reference would otherwise inherit. When the base URI has an
empty path as well and nothing would be inherited, shorter references such
as the empty reference, #fragment or ?query are still returned.
relativize() also no longer returns the empty reference when the target
path equals the base path but the base has a fragment the target lacks, as
the empty reference would reintroduce that fragment. A relative-path
reference, or a query reference when the target has a query, is returned
instead. When the relative-path reference would be a single path segment
containing a colon, which would be mistaken for a scheme name, it is
prefixed with ./ (for example ./a:b); 2.x threw a MalformedUriException
for such targets when the base had a query the target lacked.
HTTP Start-line Parsing
Message::parseRequest() and Message::parseResponse() now validate HTTP
start-line fields more strictly. Malformed request methods, request targets
containing whitespace or control characters, malformed protocol versions,
invalid response status codes, invalid response spacing, and reason phrases
containing invalid control characters now throw InvalidArgumentException.
Request and Response constructors and mutators apply the same validation to
protocol versions, request targets, status codes, and reason phrases. If you
parse raw HTTP messages or construct messages from partially validated input,
normalize or reject invalid values before passing them to Guzzle PSR-7.
// 2.x-style tolerant input, no longer accepted in 3.0
Message::parseRequest("GET /foo bar HTTP/1.1\r\nHost: example.com\r\n\r\n");
new Response(200, [], null, 'HTTP/1.1');
// 3.0
Message::parseRequest("GET /foo%20bar HTTP/1.1\r\nHost: example.com\r\n\r\n");
new Response(200, [], null, '1.1');
ServerRequest::fromGlobals() applies the same validation to the
REQUEST_METHOD and SERVER_PROTOCOL server values. Malformed values that 2.x
hydrated, such as the SERVER_PROTOCOL value INCLUDED that Apache sets for
server-side include subrequests, now throw InvalidArgumentException. Sanitize
$_SERVER before calling fromGlobals() if such environments must be
tolerated.
Request::withRequestTarget('') throws InvalidArgumentException; omit the
explicit request target to derive / or the URI-derived target automatically.
Message::parseMessage() no longer unfolds folded HTTP/1.0 messages whose
start line carries control bytes in the request target; such messages now
throw the obsolete-line-folding InvalidArgumentException.
Message::parseRequest() and Message::parseResponse() rejected these
messages either way.
Query Builder Values
Query::build() now rejects unsupported values instead of relying on PHP string
casts. Query values must be scalar, null, stringable objects, or flat arrays of
those values.
Nested arrays, resources, and objects without __toString() now throw
InvalidArgumentException.
// Before: could produce warnings or silently mangle the value.
Query::build(['filter' => ['name' => ['value']]]);
// After: use explicit query keys for nested query shapes.
Query::build(['filter[name]' => 'value']);
Flat arrays are still supported for repeated query parameters:
Query::build(['tag' => ['a', 'b']]);
// tag=a&tag=b
Uri::withQueryValues() is stricter than Query::build() and requires string
or null values; cast numeric and boolean query values to string.
Non-string Scalar Bodies
Utils::streamFor() and message bodies no longer accept int, float, or
bool values. Cast them to strings first.
// 2.x, no longer accepted in 3.0
$response = new Response(200, [], 404);
// 3.0
$response = new Response(200, [], '404');
PumpStream Source Callables
PumpStream source callables must now return a non-empty string when producing
data. Returning an empty string now throws RuntimeException instead of being
retried indefinitely. Return false or null to signal EOF.
If your callable used '' to mean "temporarily no data", update it to wait
until data is available, return a non-empty string, or return false or null
when the stream is complete.
Iterator-backed Streams
Utils::streamFor() now validates values yielded by Iterator instances before
passing them to the internal PumpStream. Strings, integers, finite floats,
booleans, null, and stringable objects are converted to string chunks.
Non-finite floats, arrays, resources, and non-stringable objects now throw
UnexpectedValueException when the stream is read.
Iterator exhaustion is now the only EOF signal for iterator-backed streams.
Yielding false, null, or an empty string no longer ends the stream; those
values are zero-length chunks and are skipped while the iterator advances. If
your iterator yielded false or null to stop streaming, update it to finish
iteration instead.
Avoid iterators that yield only zero-length chunks indefinitely. Such iterators never produce bytes and never reach EOF, so they cannot satisfy stream reads.
// Before: yielding false or null could stop an iterator-backed stream early.
$stream = Utils::streamFor(new ArrayIterator([false, 'body']));
// After: false and null are skipped chunks. End the iterator to signal EOF.
$stream = Utils::streamFor(new ArrayIterator(['body']));
Stream Behavior Changes
All stream implementations now reject negative read() lengths with
RuntimeException. In 2.x, some decorators passed negative lengths through,
some returned sliced data, and some behavior varied by PHP version.
LimitStream now rejects negative offsets and limits below -1. For
non-seekable streams, offsets are tracked by the number of bytes actually
skipped. Short reads are retried until the offset is reached, EOF is reached, or
the decorated stream stops making progress.
StreamWrapper now translates RuntimeException failures from the wrapped
PSR-7 stream into PHP stream-wrapper failure values. When using a resource from
StreamWrapper::getResource(), functions such as fread(), fwrite(),
fseek(), feof(), and fstat() may now return normal PHP failure values
instead of propagating the PSR-7 stream exception. Call the PSR-7 stream directly
if you need exception-based failure handling.
The StreamWrapper::stream_read() callback no longer declares a native return
type so read failures can return false. The StreamWrapper::stream_tell()
callback no longer declares a native return type so post-seek position lookup
failures can make fseek() fail.
Stream Mode Capabilities
Stream::isReadable() and Stream::isWritable() now follow PHP stream mode
semantics more closely. Update modes are detected by the presence of +,
including valid modes such as rt+, wt+, at+, xt+, and ct+.
Literal rw metadata is now treated as read-only, matching PHP real-file
streams. If a custom stream wrapper previously exposed rw for a writable
resource, open it with a valid update mode such as r+, w+, or a+ instead.
Stream Copy Behavior
Stream sizes, offsets, high-water marks, and byte counts are now validated as
non-negative PHP integers where applicable. Operations that would overflow
PHP_INT_MAX throw OverflowException instead of silently wrapping or producing
an invalid position or size.
Utils::copyToStream() now returns the number of bytes copied and throws a
RuntimeException when the destination stream cannot make progress, for example
a BufferStream at its high-water mark or a full DroppingStream. Its
signature changed from : void to : int, but callers that ignore the return
value do not need to change anything. In 2.x, the copy stopped silently when the
destination could not make progress. For a guaranteed full copy, use a normal
writable stream such as a file or php://temp stream.
Stream Timeout Detection
Timed-out stream operations now throw
GuzzleHttp\Psr7\Exception\TimeoutException, which extends
RuntimeException. Stream::read(), Stream::write(),
AppendStream::read(), CachingStream::read(), InflateStream::read(),
Utils::copyToStream(), Utils::copyToString(), Utils::hash(),
Utils::readLine(), and Utils::tryGetContents() detect PHP-style stream
timeout metadata when a read or write operation cannot make progress. Timeout
detection is best-effort; custom stream implementations that do not expose
timed_out metadata continue to behave as before. Previously, timed-out reads
could be treated as EOF or return partial results, and timed-out writes could be
reported as generic write failures or no-progress writes.
Message Body Summaries
Message::bodySummary() still summarizes seekable bodies from the beginning,
even when the body was already partially read. It now restores the body cursor to
the position it had before the summary was created. In 2.x, calling
bodySummary() left seekable bodies rewound to the beginning. The optional
$truncateAt argument now accepts null as an explicit request for the default
summary length, matching the behavior of omitting the argument.
Most applications do not need to change anything. Check your code only if you
called bodySummary() and then read the same body while relying on
bodySummary() to leave the body rewound. If you need to read the body from the
beginning after summarizing it, call Message::rewindBody() explicitly.
Stream Lifecycle
FnStream now treats close() and successful detach() calls as terminal
lifecycle operations. Its configured close callback is invoked at most once;
repeated close() calls are no-ops, destruction after explicit close no longer
invokes the close callback, and closed or detached streams no longer forward
read, write, seek, metadata, or stringification callbacks. FnStream also
suppresses exceptions thrown by destructor-triggered close callbacks. Call
close() explicitly if cleanup failures must be observed.
CachingStream::close() is now idempotent. Calling close() after detach()
still closes the remote stream owned by the CachingStream, but it no longer
closes the detached cache resource returned to the caller. Repeated close()
calls are no-ops.
InflateStream::close() now also closes the compressed source stream that was
passed to its constructor. In 2.x, closing an InflateStream left the source
stream open. Call detach() instead of close() if the compressed source
stream must stay open; close() after detach() no longer closes the source.
PumpStream::close() and PumpStream::detach() now discard internally buffered
unread bytes. If a callable or iterator source returns more bytes than a read
requested, drain the stream before closing it if you need those buffered bytes.
Multipart Part Headers and Metadata
MultipartStream no longer adds default Content-Length headers to individual
multipart/form-data parts. RFC 7578 section 4.8 says multipart form-data
parts must not include Content-* headers other than the supported multipart
part headers, so 3.0 stops generating per-part Content-Length by default.
If your tests compare raw multipart payloads, remove the generated
Content-Length lines from expected strings:
// 2.x generated:
--boundary\r\n
Content-Disposition: form-data; name="foo"\r\n
Content-Length: 3\r\n
\r\n
bar\r\n
// 3.0 generates:
--boundary\r\n
Content-Disposition: form-data; name="foo"\r\n
\r\n
bar\r\n
Applications can still pass an explicit Content-Length header in a multipart
element's headers array if a non-standard peer requires it:
$body = new MultipartStream([
[
'name' => 'foo',
'contents' => 'bar',
'headers' => ['Content-Length' => '3'],
],
]);
MultipartStream now escapes generated Content-Disposition name and
filename parameters before serializing multipart part headers. Double quotes,
carriage returns, and line feeds are encoded as %22, %0D, and %0A. Literal
backslashes and other characters are serialized unchanged, matching browser
multipart form submission behavior.
// Before: these values were interpolated into the generated part header.
$body = new MultipartStream([
[
'name' => "field\"\r\nname",
'filename' => "avatar\"\r\n.txt",
'contents' => 'body',
],
]);
// After: the generated Content-Disposition parameters contain
// field%22%0D%0Aname and avatar%22%0D%0A.txt.
Explicit custom boundaries are now validated using RFC 2046 multipart boundary
syntax. Omit the boundary or pass null to continue using a generated random
boundary.
The string '0' is now treated as an explicit custom boundary and is serialized
literally. In 2.x, PHP truthiness caused new MultipartStream($elements, '0')
to use a generated random boundary. Omit the boundary or pass null when you
want a generated boundary.
Custom multipart part header names and values are also validated before serialization. Header names must be valid HTTP tokens, and header values must be strings without CR, LF, or other invalid control bytes.
MultipartStream now preserves trailing spaces and tabs in custom multipart
part header values when serializing the body. In 2.x, the final serialized part
header line was trimmed as a side effect of removing the generated header
terminator. Normal multipart parsers treat this optional whitespace as
insignificant, but tests, signatures, or snapshots that compare raw multipart
body bytes may need updated expectations.
URI Userinfo Redaction
Utils::redactUserInfo() now redacts all non-empty URI userinfo, including
username-only userinfo. In 2.x, it only redacted the password portion when
userinfo contained a password delimiter.
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\Utils;
// 2.x: https://TOKEN@example.com
// 3.0: https://***@example.com
(string) Utils::redactUserInfo(new Uri('https://TOKEN@example.com'));
// 2.x: https://user:***@example.com
// 3.0: https://***@example.com
(string) Utils::redactUserInfo(new Uri('https://user:pass@example.com'));
Header List Helpers
The deprecated Header::normalize() method was removed. Use
Header::splitList() to split HTTP headers that are defined as comma-separated
lists.
Header::splitList() now trims list elements with spaces, horizontal tabs,
carriage returns, and line feeds. 2.x also trimmed null bytes and vertical
tabs. Validated header values cannot contain those bytes, so this only affects
strings passed to Header::splitList() directly.
Non-instantiable Utility Classes
Static utility and constant classes such as Header, Message, MimeType,
Query, and Utils now have private constructors. Replace any accidental
instantiation with static method calls or constant access.
Native PHP Serialization of Streams
Guzzle PSR-7 stream implementations no longer support native PHP serialize()
or unserialize(). Persist stream contents explicitly and recreate streams with
Utils::streamFor() when needed.
URI Normalization of Userinfo and Host
UriNormalizer::CAPITALIZE_PERCENT_ENCODING and
UriNormalizer::DECODE_UNRESERVED_CHARACTERS now also apply to the userinfo and
host components. In 2.x, these normalizations only rewrote the path, query, and
fragment.
Since the host is case-insensitive and PSR-7 requires it to be lowercase, octets
decoded in the host are lowercased. Reserved percent-encoded octets such as
%3A are never decoded, so component boundaries cannot change, and these two
flags never modify bracketed IP-literal hosts, which only the separate
UriNormalizer::CANONICALIZE_IPV6_HOST normalization may canonicalize. Both
flags are part of UriNormalizer::PRESERVING_NORMALIZATIONS, so the output of
UriNormalizer::normalize() and the result of UriNormalizer::isEquivalent()
can change for URIs whose userinfo or host contains percent-encoded octets.
Custom UriInterface implementations now receive withUserInfo() or
withHost() calls from the normalizer when a normalization changes those
components; unchanged components are never rewritten. The rewrite is kept only
when the value returned by the implementation matches the normalized form, and
a userinfo with an empty user segment is never rewritten. If a setter returns a
different representation, that rewrite is discarded, while other selected
normalizations still apply, and setter exceptions propagate. No percent-encoding
normalization is applied to a component with malformed percent syntax, such as a
% not followed by two hexadecimal digits.
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriNormalizer;
// 2.x: http://%75ser@ex%61mple.com/
// 3.0: http://user@example.com/
(string) UriNormalizer::normalize(new Uri('http://%75ser@ex%61mple.com/'));
URI Ports and Authority Handling
Several 3.0 changes affect how URI ports are accepted, validated, and rendered. Each is described in its own section above:
UriInterface::withPort()now requiresint|null; see "Native PSR-7 Parameter Types".Uri::fromParts()validates ports instead of casting them, andwithHost()rejects embeddedhost:portvalues; see "URI Host and Scheme Validation".- A generic
Urican represent ports 0 through 65535. Inbound HTTP authority parsing is stricter:Message::parseRequest()rejects zero-valued ports but accepts nonzero leading-zero ports, normalizing the reconstructed URI while preserving the rawHostor request-target text; see "HTTP Start-line Parsing" and "URI Host and Scheme Validation". - Server globals reject a zero-valued
HTTP_HOSTand validateSERVER_PORTwhen fallback authority reconstruction needs it. A recognized absolute-form or CONNECTREQUEST_URIauthority takes precedence and can still produce a URI with port zero; see "URI Host and Scheme Validation". - Synthesized
Hostheaders now include any non-default URI port; see "Request Modification Changes" and "Request Host Synchronization".
Uri now knows the default ports of the ws and wss schemes, 80 and 443 per
RFC 6455. An explicit default port on a ws or wss URI is removed when the
URI is constructed or modified, Uri::isDefaultPort() returns true for such
URIs, and UriNormalizer::normalize() with the REMOVE_DEFAULT_PORT flag
removes the port from other UriInterface implementations as well. In 2.x,
these ports were preserved.
use GuzzleHttp\Psr7\Uri;
// 2.x: ws://example.com:80/chat
// 3.0: ws://example.com/chat
(string) new Uri('ws://example.com:80/chat');
// 2.x: 443
// 3.0: null
(new Uri('wss://example.com:443'))->getPort();
Because a native Uri never carries a default ws or wss port, the Host
header synchronized from such a request URI omits the port. Request and
Message Host synthesis and Utils::modifyRequest() still append an explicit
default port that a ws or wss URI from another UriInterface implementation
reports.
UriComparator::isCrossOrigin() now applies these default ports when comparing
effective ports, so two ws or wss URIs that differ only by an explicit
default port, such as ws://example.com/ and ws://example.com:80/, are
same-origin no matter which UriInterface implementation supplies them. In 2.x,
such pairs were considered cross-origin. Schemes other than http, https,
ws, and wss still receive no implicit default port.
Sensitive Stack Trace Arguments
Credential-bearing URI, server-global, Authorization-header, and cookie
arguments are marked with #[\SensitiveParameter]. PHP 8.2 and later replace
those arguments in stack traces with SensitiveParameterValue. PHP 7.4 through
8.1 do not redact trace arguments.
This does not redact logs, exception messages, object properties, wire traffic, captured variables, return values, user callbacks, or the separate executing object in an explicit backtrace.
1.x to 2.0
Guzzle PSR-7 2.0 is a major release that removes deprecated APIs, raises the minimum PHP version, and adds PHP 7 parameter and return types. Applications that only depend on PSR-7 interfaces should usually need small changes. Applications that call helper functions, extend package classes, or pass invalid argument types need closer review.
PHP Version and Dependencies
Guzzle PSR-7 2.0 requires PHP ^7.2.5 || ^8.0. Guzzle PSR-7 1.x supported PHP
>=5.4.0.
Composer dependency changes that can affect upgrades:
ralouphie/getallheadersv2 support was dropped; 2.0 requires^3.0.psr/http-factory:^1.0is required because 2.0 ships PSR-17 factories throughGuzzleHttp\Psr7\HttpFactory.
PHP 7 Type Hints and Return Types
Type hints and return types were added wherever possible. Please make sure:
- You pass values of the documented type when calling methods and functions.
- Classes that extend Guzzle PSR-7 classes update any overridden method signatures to remain compatible.
- Code that expected package-specific
InvalidArgumentExceptionexceptions for invalid argument types may now receive PHPTypeErrorexceptions instead.
Common examples include passing a real integer status code to Response::__construct() and passing a string method to Request::__construct().
Removed Function API
The static API was introduced in 1.7.0 to mitigate problems with functions
conflicting between global and local copies of the package. The function API was
removed in 2.0.0, along with the Composer files autoload entry that loaded
src/functions_include.php.
Replace namespaced function calls with the corresponding static methods in the
GuzzleHttp\Psr7 namespace:
// Before:
use function GuzzleHttp\Psr7\stream_for;
$stream = stream_for('body');
// After:
use GuzzleHttp\Psr7\Utils;
$stream = Utils::streamFor('body');
| Original Function | Replacement Method |
|---|---|
str |
Message::toString |
uri_for |
Utils::uriFor |
stream_for |
Utils::streamFor |
parse_header |
Header::parse |
normalize_header |
Header::normalize |
modify_request |
Utils::modifyRequest |
rewind_body |
Message::rewindBody |
try_fopen |
Utils::tryFopen |
copy_to_string |
Utils::copyToString |
copy_to_stream |
Utils::copyToStream |
hash |
Utils::hash |
readline |
Utils::readLine |
parse_request |
Message::parseRequest |
parse_response |
Message::parseResponse |
parse_query |
Query::parse |
build_query |
Query::build |
mimetype_from_filename |
MimeType::fromFilename |
mimetype_from_extension |
MimeType::fromExtension |
_parse_message |
Message::parseMessage |
_parse_request_uri |
Message::parseRequestUri |
get_message_body_summary |
Message::bodySummary |
_caseless_remove |
Utils::caselessRemove |
Header::normalize() remains the direct 2.0 replacement for
normalize_header(). In newer 2.x versions, prefer Header::splitList() for
new code.
Deprecated URI Methods Removed
The deprecated Uri::resolve() and Uri::removeDotSegments() methods were
removed. Use UriResolver instead.
// Before:
$resolved = Uri::resolve($base, '../path');
$path = Uri::removeDotSegments('/a/../b');
// After:
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\Psr7\Utils;
$resolved = UriResolver::resolve($base, Utils::uriFor('../path'));
$path = UriResolver::removeDotSegments('/a/../b');
Stricter URI Validation
Guzzle PSR-7 1.x automatically fixed a URI that combined an authority with a
relative path by prepending / to the path. That deprecated behavior was removed
in 2.0. Such URIs now throw InvalidArgumentException.
// Before: automatically converted to //example.com/foo.
$uri = (new Uri())->withHost('example.com')->withPath('foo');
// After: make the absolute path explicit.
$uri = (new Uri())->withHost('example.com')->withPath('/foo');
Header Validation
Header names are validated more strictly according to RFC 7230 token syntax.
Names containing whitespace, /, (, ), \\, or other invalid characters are
rejected.
If you construct messages from untrusted or non-standard input, normalize or
reject invalid header names before constructing Request, Response, or
ServerRequest instances.
Query String Boolean Serialization
Query::build() now serializes booleans as 1 and 0, matching
http_build_query() behavior.
Query::build(['enabled' => true, 'disabled' => false]);
// enabled=1&disabled=0
In current 2.x versions, pass false as the third argument if you need textual
boolean values:
Query::build(['enabled' => true, 'disabled' => false], PHP_QUERY_RFC3986, false);
// enabled=true&disabled=false
Final Stream and Decorator Classes
Several classes that were annotated with @final in 1.x are declared final in
2.0:
AppendStreamBufferStreamCachingStreamDroppingStreamFnStreamInflateStreamLazyOpenStreamLimitStreamMultipartStreamNoSeekStreamPumpStreamStreamWrapper
If your code extends one of these classes, replace inheritance with composition.
For custom streams, implement Psr\Http\Message\StreamInterface directly or use
GuzzleHttp\Psr7\StreamDecoratorTrait in your own class.
Request, Response, ServerRequest, Stream, UploadedFile, and Uri remain
extendable in 2.0, but overridden methods must have compatible signatures.
Public Constants and Internal Details
Some constants that were public in 1.x are implementation details in 2.0:
Stream::READABLE_MODESStream::WRITABLE_MODESUri::HTTP_DEFAULT_HOST
If your code used these constants, define application-specific constants instead of depending on package internals.
Stream Behavior Changes
BufferStream::write() returns 0 instead of false when the buffer exceeds
its high-water mark. This keeps the method compatible with the int return type
from StreamInterface::write().
Several stream __toString() implementations now catch Throwable. On PHP 7.4
and newer, exceptions thrown during stringification are rethrown. Avoid relying
on (string) $stream to hide read failures; call getContents() or read() and
handle exceptions when failures are possible.
PSR-17 Factories
Guzzle PSR-7 2.0 adds GuzzleHttp\Psr7\HttpFactory, an implementation of the
PSR-17 factory interfaces from psr/http-factory. This is additive, but it is
the reason for the new required dependency.
For the full 2.0 diff, see https://github.com/guzzle/psr7/compare/1.8.1...2.0.0.