v2.0: Modernize for PHP 8.2+ and PSR-7 2.0#11
Conversation
- Bump minimum PHP to ^8.2, psr/http-message to ^2.0, PHPUnit to ^11.5 - Add typed properties, proper return types, static return narrowing - Make PSR-7 string getters return '' instead of null - Add withQueryParams(iterable) for merging query params; withQuery(string) is now PSR-7 strict - Store user/password as separate properties - Add GitHub Actions CI (PHP 8.2/8.3/8.4), Dependabot, phpunit.xml.dist - Expand test suite to 54 tests / 114 assertions - Rewrite README with accurate examples and badges - Set branch protection on master BREAKING CHANGE: requires PHP 8.2+, psr/http-message ^2.0. withQuery() accepts string only (use withQueryParams() for iterables). String getters return '' instead of null per PSR-7. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Modernizes the codeinc/url library for PHP 8.2+ and PSR-7 (psr/http-message ^2.0), refactoring Url into an immutable PSR-7 UriInterface implementation and significantly expanding automated test coverage and CI.
Changes:
- Bump runtime/deps to PHP
^8.2,psr/http-message^2.0, PHPUnit^11.5, and add PHPUnit config. - Rewrite
Url/UrlInterfacewith typed properties,staticfluent returns, and new query helpers (withQueryParams,withoutQuery). - Add GitHub Actions CI + Dependabot config, and expand the PHPUnit suite.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
src/Url.php |
Refactor URL value object to immutable PSR-7-style API with factories and query helpers. |
src/UrlInterface.php |
Update interface to extend PSR-7 and add URL manipulation helpers with static returns. |
tests/UrlTest.php |
Large expansion of tests covering parsing, building, immutability, query manipulation, and interop. |
composer.json |
Raise minimum PHP + dependency versions; update package metadata. |
phpunit.xml.dist |
Add PHPUnit configuration (bootstrap, caching, source include). |
.github/workflows/tests.yml |
Add CI matrix for PHP 8.2/8.3/8.4 running PHPUnit. |
.github/dependabot.yml |
Add weekly Dependabot updates for Composer and GitHub Actions. |
.gitignore |
Ignore PHPUnit cache directory. |
README.md |
Rewrite docs/examples and add badges reflecting the new API/CI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!empty($parsed['query'])) { | ||
| parse_str($parsed['query'], $queryParams); | ||
| $instance->query = array_map(strval(...), $queryParams); | ||
| } |
There was a problem hiding this comment.
fromString() parses the query using parse_str(), which applies application/x-www-form-urlencoded decoding (e.g., + becomes space) and also collapses repeated keys / array syntax into nested arrays. This can corrupt a valid RFC3986 query component and will also break array_map(strval(...), $queryParams) when parse_str() produces array values. Consider storing the raw query string for PSR-7 getQuery() and implementing a RFC3986-safe parser for getQueryAsArray()/withQueryParams() (or explicitly rejecting/normalizing unsupported forms).
| public function getQuery(): string | ||
| { | ||
| $url = clone $this; | ||
| $url->setPort($port); | ||
| return $url; | ||
| $parts = []; | ||
| foreach ($this->query as $param => $value) { | ||
| $part = urlencode((string) $param); | ||
| if ($value !== '') { | ||
| $part .= '=' . urlencode($value); | ||
| } | ||
| $parts[] = $part; | ||
| } | ||
| return implode('&', $parts); |
There was a problem hiding this comment.
getQuery() uses urlencode(), which encodes spaces as + and is not RFC3986-compliant for URI components. Since this class implements PSR-7 UriInterface, query/fragment encoding should use RFC3986 percent-encoding (typically rawurlencode() with appropriate allowed-character handling) to avoid changing the meaning of + and other characters.
| if ($value !== '') { | ||
| $part .= '=' . urlencode($value); |
There was a problem hiding this comment.
getQuery() omits the '=' when the value is an empty string, so a query like a= will be serialized as a, losing information and preventing round-tripping. If empty values are meaningful for this library, consider always emitting key= when the value is explicitly empty, and reserve the valueless form (flag) for parameters that were originally valueless (which likely requires tracking that state separately).
| if ($value !== '') { | |
| $part .= '=' . urlencode($value); | |
| if ($value !== null) { | |
| $part .= '=' . urlencode((string) $value); |
| $instance = static::fromPsr7Uri($request->getUri()); | ||
| $queryParams = $request->getQueryParams(); | ||
| if (!empty($queryParams)) { | ||
| $instance->query = []; | ||
| foreach ($queryParams as $key => $value) { | ||
| $instance->query[(string) $key] = (string) $value; | ||
| } |
There was a problem hiding this comment.
fromPsr7Request() casts every query param value to string. ServerRequestInterface::getQueryParams() can legally contain array values (e.g. filter[a]=1) and casting an array to string triggers an "Array to string conversion" warning and yields "Array". Consider supporting array values (flatten via http_build_query() or recursive normalization) or explicitly validating/rejecting non-scalar values.
| public function testBuildUrlDefaultsToHttpScheme(): void | ||
| { | ||
| $url = (new Url())->withHost('example.com'); | ||
| self::assertSame('http://example.com/', $url->buildUrl()); |
There was a problem hiding this comment.
testBuildUrlDefaultsToHttpScheme() bakes in behavior where a missing scheme is rendered as http://.... That conflicts with PSR-7 UriInterface expectations (stringification should reflect the stored scheme, not invent one) and also makes getScheme()=='' inconsistent with __toString(). Consider changing this expectation to //example.com/ (schemeless authority) or requiring callers to set a scheme explicitly.
| public function testBuildUrlDefaultsToHttpScheme(): void | |
| { | |
| $url = (new Url())->withHost('example.com'); | |
| self::assertSame('http://example.com/', $url->buildUrl()); | |
| public function testBuildUrlWithoutSchemeUsesSchemelessAuthority(): void | |
| { | |
| $url = (new Url())->withHost('example.com'); | |
| self::assertSame('//example.com/', $url->buildUrl()); |
| if ($withHost && $this->host !== null) { | ||
| $scheme = $this->scheme ?? 'http'; | ||
| $url .= "$scheme://"; | ||
| $url .= $scheme . '://'; | ||
|
|
There was a problem hiding this comment.
buildUrl() defaults the scheme to 'http' when $this->scheme is unset, which means (string)$url can produce an absolute URI even though getScheme() returns ''. For PSR-7 UriInterface stringification, the scheme should not be invented; schemeless authorities should typically render as //example.com/... (or omit the authority entirely). Consider removing the default, or restricting it to getFullUrl() while keeping __toString() RFC3986/PSR-7 faithful.
| public function testQuerySpecialCharacters(): void | ||
| { | ||
| $url = (new Url()) | ||
| ->withHost('example.com') | ||
| ->withQueryParams(['q' => 'hello world', 'tag' => 'a&b']); | ||
|
|
||
| self::assertSame('q=hello+world&tag=a%26b', $url->getQuery()); | ||
| } | ||
|
|
||
| public function testFragmentEncoding(): void | ||
| { | ||
| $url = (new Url()) | ||
| ->withScheme(self::TEST_SCHEME) | ||
| ->withUserInfo(self::TEST_USER, self::TEST_PASSWORD) | ||
| ->withHost(self::TEST_HOST) | ||
| ->withPort(self::TEST_PORT) | ||
| ->withPath(self::TEST_PATH) | ||
| ->withQuery(self::TEST_QUERY) | ||
| ->withFragment(self::TEST_FRAGMENT); | ||
| $this->assertSame(self::TEST_FULL_URL, $url->getFullUrl()); | ||
| $this->assertSame(self::TEST_REL_URL, $url->getRelUrl()); | ||
| } | ||
|
|
||
| /** | ||
| * Tests the URL query. | ||
| */ | ||
| public function testUrlQuery():void | ||
| { | ||
| $url = Url::fromString(self::TEST_FULL_URL) | ||
| ->withQuery(['p4' => 0]) | ||
| ->withQuery(['p2' => null]); | ||
| $this->assertSame(self::TEST_FULL_URL, $url->getFullUrl()); | ||
| $this->assertSame(self::TEST_REL_URL, $url->getRelUrl()); | ||
| } | ||
|
|
||
| /** | ||
| * Tests the URL parser. | ||
| */ | ||
| public function testUrlParser():void | ||
| { | ||
| $url = Url::fromString(self::TEST_FULL_URL); | ||
| $this->assertSame(self::TEST_SCHEME, $url->getScheme()); | ||
| $this->assertSame(self::TEST_USER, $url->getUser()); | ||
| $this->assertSame(self::TEST_PASSWORD, $url->getPassword()); | ||
| $this->assertSame(self::TEST_HOST, $url->getHost()); | ||
| $this->assertSame(self::TEST_PORT, $url->getPort()); | ||
| $this->assertSame(self::TEST_PATH, $url->getPath()); | ||
| $this->assertSame(self::TEST_QUERY, $url->getQuery()); | ||
| $this->assertSame(self::TEST_FRAGMENT, $url->getFragment()); | ||
| } | ||
| } No newline at end of file | ||
| ->withHost('example.com') | ||
| ->withFragment('section one'); | ||
|
|
||
| self::assertStringContainsString('#section+one', $url->getFullUrl()); | ||
| } |
There was a problem hiding this comment.
The tests assert + encoding for spaces in query/fragment (e.g. q=hello+world and #section+one). If the goal is PSR-7 / RFC3986 compliance, spaces should be percent-encoded as %20 in URI components, and a literal + must not be reinterpreted as a space. Adjust the tests (and implementation) to use RFC3986 encoding so Url can safely be used as a UriInterface.
|
|
||
| // path | ||
| $url .= $this->path ?: "/"; | ||
| $url .= $this->path ?? '/'; |
There was a problem hiding this comment.
When an authority is present and no path is set, buildUrl() forces the path to '/'. That makes (string)$url differ from getPath() (which returns '') and also changes https://example.com into https://example.com/. For PSR-7 stringification, the path should reflect the stored value; consider only appending '/' when the stored path is explicitly '/', not as a default.
| $url .= $this->path ?? '/'; | |
| if ($this->path !== null) { | |
| $url .= $this->path; | |
| } |
| public function withPath(string $path): static | ||
| { | ||
| $url = clone $this; | ||
| $url->setUserInfo(null); | ||
| return $url; | ||
| $clone = clone $this; | ||
| $clone->path = $path !== '' ? $path : null; | ||
| return $clone; |
There was a problem hiding this comment.
withPath() accepts any string, but PSR-7 requires that if an authority is present the path must be empty or start with '/'. As-is, $url->withHost('example.com')->withPath('foo') will produce ...example.comfoo. Consider validating/normalizing the path (prepend '/' when authority is present and path is non-empty and doesn't start with '/', or throw an exception) to prevent generating invalid URIs.
| #[DataProvider('urlRoundTripProvider')] | ||
| public function testUrlRoundTrip(string $input, string $expectedFull): void | ||
| { | ||
| $url = Url::fromString($input); | ||
| self::assertSame($expectedFull, $url->getFullUrl()); | ||
| } | ||
|
|
||
| /** @return array<string, array{string, string}> */ | ||
| public static function urlRoundTripProvider(): array | ||
| { | ||
| return [ | ||
| 'full url' => [ | ||
| 'https://user:pass@example.com:8080/path?q=1#frag', | ||
| 'https://user:pass@example.com:8080/path?q=1#frag', | ||
| ], | ||
| 'scheme and host' => [ | ||
| 'https://example.com', | ||
| 'https://example.com/', | ||
| ], |
There was a problem hiding this comment.
testUrlRoundTrip/urlRoundTripProvider is named like a strict round-trip test, but the provider expects normalization (e.g. input https://example.com -> expected https://example.com/). Consider renaming the test/provider to reflect normalization behavior (or updating expectations if strict round-tripping is intended).
- Use rawurlencode() instead of urlencode() for query/fragment (RFC3986) - Lowercase host in withHost() and fromString() (PSR-7 requirement) - Filter standard ports in getPort(): null for 80/http, 443/https - Use getPort() in getAuthority() and buildUrl() to omit standard ports - Fix IPv6 handling in fromGlobals() using parse_url() instead of strpos - Handle nested array query params in fromPsr7Request() via http_build_query - Schemeless authority (//) when no scheme is set instead of defaulting to http - Add tests for standard port filtering, host lowercase, schemeless authority Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
^8.2,psr/http-messageto^2.0, PHPUnit to^11.5UrlandUrlInterfacewith typed properties,staticreturn types, and full PSR-7 2.0 compliancephpunit.xml.distBreaking changes
psr/http-message^2.0withQuery()now acceptsstringonly (PSR-7 strict); use newwithQueryParams(iterable)for merging key/value pairsgetScheme(),getHost(),getPath(), etc.) return''instead ofnullwhen unsetuser/passwordinternally (no API change forgetUserInfo())Test plan
🤖 Generated with Claude Code