Skip to content

v2.0: Modernize for PHP 8.2+ and PSR-7 2.0#11

Merged
joanfabregat merged 2 commits intomasterfrom
v2.0
Apr 15, 2026
Merged

v2.0: Modernize for PHP 8.2+ and PSR-7 2.0#11
joanfabregat merged 2 commits intomasterfrom
v2.0

Conversation

@joanfabregat
Copy link
Copy Markdown
Member

Summary

  • Bumps minimum PHP to ^8.2, psr/http-message to ^2.0, PHPUnit to ^11.5
  • Modernizes Url and UrlInterface with typed properties, static return types, and full PSR-7 2.0 compliance
  • Expands test suite from 3 to 54 tests (114 assertions)
  • Adds GitHub Actions CI (PHP 8.2 / 8.3 / 8.4), Dependabot, phpunit.xml.dist
  • Rewrites README with accurate examples and badges
  • Sets branch protection on master (PR review + CI required)
  • Closes Create a PSR7 compatible version of the class #4 (PSR-7 compatibility)
  • Addresses Dependabot alert for PHPUnit CVE-2026-24765

Breaking changes

  • Requires PHP 8.2+ and psr/http-message ^2.0
  • withQuery() now accepts string only (PSR-7 strict); use new withQueryParams(iterable) for merging key/value pairs
  • PSR-7 string getters (getScheme(), getHost(), getPath(), etc.) return '' instead of null when unset
  • User info stored as separate user/password internally (no API change for getUserInfo())

Test plan

  • All 54 PHPUnit tests pass on PHP 8.3
  • CI runs on PHP 8.2, 8.3, 8.4 via GitHub Actions
  • Verify Dependabot starts filing PRs for dependency updates

🤖 Generated with Claude Code

- 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>
Copilot AI review requested due to automatic review settings April 15, 2026 19:41
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/UrlInterface with typed properties, static fluent 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.

Comment thread src/Url.php
Comment on lines +51 to 54
if (!empty($parsed['query'])) {
parse_str($parsed['query'], $queryParams);
$instance->query = array_map(strval(...), $queryParams);
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/Url.php
Comment on lines +208 to +218
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);
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/Url.php Outdated
Comment on lines +213 to +214
if ($value !== '') {
$part .= '=' . urlencode($value);
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
if ($value !== '') {
$part .= '=' . urlencode($value);
if ($value !== null) {
$part .= '=' . urlencode((string) $value);

Copilot uses AI. Check for mistakes.
Comment thread src/Url.php Outdated
Comment on lines +147 to +153
$instance = static::fromPsr7Uri($request->getUri());
$queryParams = $request->getQueryParams();
if (!empty($queryParams)) {
$instance->query = [];
foreach ($queryParams as $key => $value) {
$instance->query[(string) $key] = (string) $value;
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread tests/UrlTest.php Outdated
Comment on lines +366 to +369
public function testBuildUrlDefaultsToHttpScheme(): void
{
$url = (new Url())->withHost('example.com');
self::assertSame('http://example.com/', $url->buildUrl());
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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());

Copilot uses AI. Check for mistakes.
Comment thread src/Url.php
Comment on lines +380 to 383
if ($withHost && $this->host !== null) {
$scheme = $this->scheme ?? 'http';
$url .= "$scheme://";
$url .= $scheme . '://';

Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread tests/UrlTest.php
Comment on lines +397 to +413
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());
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/Url.php

// path
$url .= $this->path ?: "/";
$url .= $this->path ?? '/';
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$url .= $this->path ?? '/';
if ($this->path !== null) {
$url .= $this->path;
}

Copilot uses AI. Check for mistakes.
Comment thread src/Url.php
Comment on lines +262 to +266
public function withPath(string $path): static
{
$url = clone $this;
$url->setUserInfo(null);
return $url;
$clone = clone $this;
$clone->path = $path !== '' ? $path : null;
return $clone;
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread tests/UrlTest.php
Comment on lines +430 to +448
#[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/',
],
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
- 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>
@joanfabregat joanfabregat merged commit f04618e into master Apr 15, 2026
3 checks passed
@joanfabregat joanfabregat deleted the v2.0 branch April 15, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create a PSR7 compatible version of the class

2 participants