diff --git a/README.md b/README.md
index 47003770..18089464 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,7 @@ multiple concurrent HTTP requests without blocking.
             * [xml()](#xml)
         * [Request](#request-1)
         * [ServerRequest](#serverrequest)
+        * [Uri](#uri)
         * [ResponseException](#responseexception)
     * [React\Http\Middleware](#reacthttpmiddleware)
         * [StreamingRequestMiddleware](#streamingrequestmiddleware)
@@ -2664,6 +2665,18 @@ application reacts to certain HTTP requests.
 > Internally, this implementation builds on top of a base class which is
   considered an implementation detail that may change in the future.
 
+#### Uri
+
+The `React\Http\Message\Uri` class can be used to
+respresent a URI (or URL).
+
+This class implements the
+[PSR-7 `UriInterface`](https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface).
+
+This is mostly used internally to represent the URI of each HTTP request
+message for our HTTP client and server implementations. Likewise, you may
+also use this class with other HTTP implementations and for tests.
+
 #### ResponseException
 
 The `React\Http\Message\ResponseException` is an `Exception` sub-class that will be used to reject
diff --git a/src/Browser.php b/src/Browser.php
index b7bf4425..01a266ca 100644
--- a/src/Browser.php
+++ b/src/Browser.php
@@ -3,12 +3,12 @@
 namespace React\Http;
 
 use Psr\Http\Message\ResponseInterface;
-use RingCentral\Psr7\Uri;
 use React\EventLoop\Loop;
 use React\EventLoop\LoopInterface;
 use React\Http\Io\Sender;
 use React\Http\Io\Transaction;
 use React\Http\Message\Request;
+use React\Http\Message\Uri;
 use React\Promise\PromiseInterface;
 use React\Socket\ConnectorInterface;
 use React\Stream\ReadableStreamInterface;
@@ -834,7 +834,7 @@ private function requestMayBeStreaming($method, $url, array $headers = array(),
     {
         if ($this->baseUrl !== null) {
             // ensure we're actually below the base URL
-            $url = Uri::resolve($this->baseUrl, $url);
+            $url = Uri::resolve($this->baseUrl, new Uri($url));
         }
 
         foreach ($this->defaultHeaders as $key => $value) {
diff --git a/src/Io/AbstractRequest.php b/src/Io/AbstractRequest.php
index 51059ac5..f32307f7 100644
--- a/src/Io/AbstractRequest.php
+++ b/src/Io/AbstractRequest.php
@@ -5,7 +5,7 @@
 use Psr\Http\Message\RequestInterface;
 use Psr\Http\Message\StreamInterface;
 use Psr\Http\Message\UriInterface;
-use RingCentral\Psr7\Uri;
+use React\Http\Message\Uri;
 
 /**
  * [Internal] Abstract HTTP request base class (PSR-7)
diff --git a/src/Io/Transaction.php b/src/Io/Transaction.php
index b93c490c..64738f56 100644
--- a/src/Io/Transaction.php
+++ b/src/Io/Transaction.php
@@ -8,11 +8,11 @@
 use React\EventLoop\LoopInterface;
 use React\Http\Message\Response;
 use React\Http\Message\ResponseException;
+use React\Http\Message\Uri;
 use React\Promise\Deferred;
 use React\Promise\Promise;
 use React\Promise\PromiseInterface;
 use React\Stream\ReadableStreamInterface;
-use RingCentral\Psr7\Uri;
 
 /**
  * @internal
@@ -264,7 +264,7 @@ public function onResponse(ResponseInterface $response, RequestInterface $reques
     private function onResponseRedirect(ResponseInterface $response, RequestInterface $request, Deferred $deferred, ClientRequestState $state)
     {
         // resolve location relative to last request URI
-        $location = Uri::resolve($request->getUri(), $response->getHeaderLine('Location'));
+        $location = Uri::resolve($request->getUri(), new Uri($response->getHeaderLine('Location')));
 
         $request = $this->makeRedirectRequest($request, $location, $response->getStatusCode());
         $this->progress('redirect', array($request));
diff --git a/src/Message/Uri.php b/src/Message/Uri.php
new file mode 100644
index 00000000..4309bbed
--- /dev/null
+++ b/src/Message/Uri.php
@@ -0,0 +1,356 @@
+<?php
+
+namespace React\Http\Message;
+
+use Psr\Http\Message\UriInterface;
+
+/**
+ * Respresents a URI (or URL).
+ *
+ * This class implements the
+ * [PSR-7 `UriInterface`](https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface).
+ *
+ * This is mostly used internally to represent the URI of each HTTP request
+ * message for our HTTP client and server implementations. Likewise, you may
+ * also use this class with other HTTP implementations and for tests.
+ *
+ * @see UriInterface
+ */
+final class Uri implements UriInterface
+{
+    /** @var string */
+    private $scheme = '';
+
+    /** @var string */
+    private $userInfo = '';
+
+    /** @var string */
+    private $host = '';
+
+    /** @var ?int */
+    private $port = null;
+
+    /** @var string */
+    private $path = '';
+
+    /** @var string */
+    private $query = '';
+
+    /** @var string */
+    private $fragment = '';
+
+    /**
+     * @param string $uri
+     * @throws \InvalidArgumentException if given $uri is invalid
+     */
+    public function __construct($uri)
+    {
+        // @codeCoverageIgnoreStart
+        if (\PHP_VERSION_ID < 50407 && \strpos($uri, '//') === 0) {
+            // @link https://3v4l.org/UrAQP
+            $parts = \parse_url('http:' . $uri);
+            unset($parts['schema']);
+        } else {
+            $parts = \parse_url($uri);
+        }
+        // @codeCoverageIgnoreEnd
+
+        if ($parts === false || (isset($parts['scheme']) && !\preg_match('#^[a-z]+$#i', $parts['scheme'])) || (isset($parts['host']) && \preg_match('#[\s_%+]#', $parts['host']))) {
+            throw new \InvalidArgumentException('Invalid URI given');
+        }
+
+        if (isset($parts['scheme'])) {
+            $this->scheme = \strtolower($parts['scheme']);
+        }
+
+        if (isset($parts['user']) || isset($parts['pass'])) {
+            $this->userInfo = $this->encode(isset($parts['user']) ? $parts['user'] : '', \PHP_URL_USER) . (isset($parts['pass']) ? ':' . $this->encode($parts['pass'], \PHP_URL_PASS) : '');
+        }
+
+        if (isset($parts['host'])) {
+            $this->host = \strtolower($parts['host']);
+        }
+
+        if (isset($parts['port']) && !(($parts['port'] === 80 && $this->scheme === 'http') || ($parts['port'] === 443 && $this->scheme === 'https'))) {
+            $this->port = $parts['port'];
+        }
+
+        if (isset($parts['path'])) {
+            $this->path = $this->encode($parts['path'], \PHP_URL_PATH);
+        }
+
+        if (isset($parts['query'])) {
+            $this->query = $this->encode($parts['query'], \PHP_URL_QUERY);
+        }
+
+        if (isset($parts['fragment'])) {
+            $this->fragment = $this->encode($parts['fragment'], \PHP_URL_FRAGMENT);
+        }
+    }
+
+    public function getScheme()
+    {
+        return $this->scheme;
+    }
+
+    public function getAuthority()
+    {
+        if ($this->host === '') {
+            return '';
+        }
+
+        return ($this->userInfo !== '' ? $this->userInfo . '@' : '') . $this->host . ($this->port !== null ? ':' . $this->port : '');
+    }
+
+    public function getUserInfo()
+    {
+        return $this->userInfo;
+    }
+
+    public function getHost()
+    {
+        return $this->host;
+    }
+
+    public function getPort()
+    {
+        return $this->port;
+    }
+
+    public function getPath()
+    {
+        return $this->path;
+    }
+
+    public function getQuery()
+    {
+        return $this->query;
+    }
+
+    public function getFragment()
+    {
+        return $this->fragment;
+    }
+
+    public function withScheme($scheme)
+    {
+        $scheme = \strtolower($scheme);
+        if ($scheme === $this->scheme) {
+            return $this;
+        }
+
+        if (!\preg_match('#^[a-z]*$#', $scheme)) {
+            throw new \InvalidArgumentException('Invalid URI scheme given');
+        }
+
+        $new = clone $this;
+        $new->scheme = $scheme;
+
+        if (($this->port === 80 && $scheme === 'http') || ($this->port === 443 && $scheme === 'https')) {
+            $new->port = null;
+        }
+
+        return $new;
+    }
+
+    public function withUserInfo($user, $password = null)
+    {
+        $userInfo = $this->encode($user, \PHP_URL_USER) . ($password !== null ? ':' . $this->encode($password, \PHP_URL_PASS) : '');
+        if ($userInfo === $this->userInfo) {
+            return $this;
+        }
+
+        $new = clone $this;
+        $new->userInfo = $userInfo;
+
+        return $new;
+    }
+
+    public function withHost($host)
+    {
+        $host = \strtolower($host);
+        if ($host === $this->host) {
+            return $this;
+        }
+
+        if (\preg_match('#[\s_%+]#', $host) || ($host !== '' && \parse_url('http://' . $host, \PHP_URL_HOST) !== $host)) {
+            throw new \InvalidArgumentException('Invalid URI host given');
+        }
+
+        $new = clone $this;
+        $new->host = $host;
+
+        return $new;
+    }
+
+    public function withPort($port)
+    {
+        $port = $port === null ? null : (int) $port;
+        if (($port === 80 && $this->scheme === 'http') || ($port === 443 && $this->scheme === 'https')) {
+            $port = null;
+        }
+
+        if ($port === $this->port) {
+            return $this;
+        }
+
+        if ($port !== null && ($port < 1 || $port > 0xffff)) {
+            throw new \InvalidArgumentException('Invalid URI port given');
+        }
+
+        $new = clone $this;
+        $new->port = $port;
+
+        return $new;
+    }
+
+    public function withPath($path)
+    {
+        $path = $this->encode($path, \PHP_URL_PATH);
+        if ($path === $this->path) {
+            return $this;
+        }
+
+        $new = clone $this;
+        $new->path = $path;
+
+        return $new;
+    }
+
+    public function withQuery($query)
+    {
+        $query = $this->encode($query, \PHP_URL_QUERY);
+        if ($query === $this->query) {
+            return $this;
+        }
+
+        $new = clone $this;
+        $new->query = $query;
+
+        return $new;
+    }
+
+    public function withFragment($fragment)
+    {
+        $fragment = $this->encode($fragment, \PHP_URL_FRAGMENT);
+        if ($fragment === $this->fragment) {
+            return $this;
+        }
+
+        $new = clone $this;
+        $new->fragment = $fragment;
+
+        return $new;
+    }
+
+    public function __toString()
+    {
+        $uri = '';
+        if ($this->scheme !== '') {
+            $uri .= $this->scheme . ':';
+        }
+
+        $authority = $this->getAuthority();
+        if ($authority !== '') {
+            $uri .= '//' . $authority;
+        }
+
+        if ($authority !== '' && isset($this->path[0]) && $this->path[0] !== '/') {
+            $uri .= '/' . $this->path;
+        } elseif ($authority === '' && isset($this->path[0]) && $this->path[0] === '/') {
+            $uri .= '/' . \ltrim($this->path, '/');
+        } else {
+            $uri .= $this->path;
+        }
+
+        if ($this->query !== '') {
+            $uri .= '?' . $this->query;
+        }
+
+        if ($this->fragment !== '') {
+            $uri .= '#' . $this->fragment;
+        }
+
+        return $uri;
+    }
+
+    /**
+     * @param string $part
+     * @param int $component
+     * @return string
+     */
+    private function encode($part, $component)
+    {
+        return \preg_replace_callback(
+            '/(?:[^a-z0-9_\-\.~!\$&\'\(\)\*\+,;=' . ($component === \PHP_URL_PATH ? ':@\/' : ($component === \PHP_URL_QUERY || $component === \PHP_URL_FRAGMENT ? ':@\/\?' : '')) . '%]++|%(?![a-f0-9]{2}))/i',
+            function (array $match) {
+                return \rawurlencode($match[0]);
+            },
+            $part
+        );
+    }
+
+    /**
+     * [Internal] Resolve URI relative to base URI and return new absolute URI
+     *
+     * @internal
+     * @param UriInterface $base
+     * @param UriInterface $rel
+     * @return UriInterface
+     * @throws void
+     */
+    public static function resolve(UriInterface $base, UriInterface $rel)
+    {
+        if ($rel->getScheme() !== '') {
+            return $rel->getPath() === '' ? $rel : $rel->withPath(self::removeDotSegments($rel->getPath()));
+        }
+
+        $reset = false;
+        $new = $base;
+        if ($rel->getAuthority() !== '') {
+            $reset = true;
+            $userInfo = \explode(':', $rel->getUserInfo(), 2);
+            $new = $base->withUserInfo($userInfo[0], isset($userInfo[1]) ? $userInfo[1]: null)->withHost($rel->getHost())->withPort($rel->getPort());
+        }
+
+        if ($reset && $rel->getPath() === '') {
+            $new = $new->withPath('');
+        } elseif (($path = $rel->getPath()) !== '') {
+            $start = '';
+            if ($path === '' || $path[0] !== '/') {
+                $start = $base->getPath();
+                if (\substr($start, -1) !== '/') {
+                    $start .= '/../';
+                }
+            }
+            $reset = true;
+            $new = $new->withPath(self::removeDotSegments($start . $path));
+        }
+        if ($reset || $rel->getQuery() !== '') {
+            $reset = true;
+            $new = $new->withQuery($rel->getQuery());
+        }
+        if ($reset || $rel->getFragment() !== '') {
+            $new = $new->withFragment($rel->getFragment());
+        }
+
+        return $new;
+    }
+
+    /**
+     * @param string $path
+     * @return string
+     */
+    private static function removeDotSegments($path)
+    {
+        $segments = array();
+        foreach (\explode('/', $path) as $segment) {
+            if ($segment === '..') {
+                \array_pop($segments);
+            } elseif ($segment !== '.' && $segment !== '') {
+                $segments[] = $segment;
+            }
+        }
+        return '/' . \implode('/', $segments) . ($path !== '/' && \substr($path, -1) === '/' ? '/' : '');
+    }
+}
diff --git a/tests/BrowserTest.php b/tests/BrowserTest.php
index b7958016..fdd338d9 100644
--- a/tests/BrowserTest.php
+++ b/tests/BrowserTest.php
@@ -5,7 +5,6 @@
 use Psr\Http\Message\RequestInterface;
 use React\Http\Browser;
 use React\Promise\Promise;
-use RingCentral\Psr7\Uri;
 
 class BrowserTest extends TestCase
 {
diff --git a/tests/Io/AbstractRequestTest.php b/tests/Io/AbstractRequestTest.php
index 28c9eaf1..7ff4a9a5 100644
--- a/tests/Io/AbstractRequestTest.php
+++ b/tests/Io/AbstractRequestTest.php
@@ -5,8 +5,8 @@
 use Psr\Http\Message\StreamInterface;
 use Psr\Http\Message\UriInterface;
 use React\Http\Io\AbstractRequest;
+use React\Http\Message\Uri;
 use React\Tests\Http\TestCase;
-use RingCentral\Psr7\Uri;
 
 class RequestMock extends AbstractRequest
 {
diff --git a/tests/Io/ClientConnectionManagerTest.php b/tests/Io/ClientConnectionManagerTest.php
index b28c7964..6aafa6db 100644
--- a/tests/Io/ClientConnectionManagerTest.php
+++ b/tests/Io/ClientConnectionManagerTest.php
@@ -2,8 +2,8 @@
 
 namespace React\Tests\Http\Io;
 
-use RingCentral\Psr7\Uri;
 use React\Http\Io\ClientConnectionManager;
+use React\Http\Message\Uri;
 use React\Promise\Promise;
 use React\Promise\PromiseInterface;
 use React\Tests\Http\TestCase;
diff --git a/tests/Io/ClientRequestStreamTest.php b/tests/Io/ClientRequestStreamTest.php
index 4649087a..181db173 100644
--- a/tests/Io/ClientRequestStreamTest.php
+++ b/tests/Io/ClientRequestStreamTest.php
@@ -3,9 +3,9 @@
 namespace React\Tests\Http\Io;
 
 use Psr\Http\Message\ResponseInterface;
-use RingCentral\Psr7\Uri;
 use React\Http\Io\ClientRequestStream;
 use React\Http\Message\Request;
+use React\Http\Message\Uri;
 use React\Promise\Deferred;
 use React\Promise\Promise;
 use React\Stream\DuplexResourceStream;
diff --git a/tests/Message/UriTest.php b/tests/Message/UriTest.php
new file mode 100644
index 00000000..05eec723
--- /dev/null
+++ b/tests/Message/UriTest.php
@@ -0,0 +1,705 @@
+<?php
+
+namespace React\Tests\Http\Message;
+
+use React\Http\Message\Uri;
+use React\Tests\Http\TestCase;
+
+class UriTest extends TestCase
+{
+    public function testCtorWithInvalidSyntaxThrows()
+    {
+        $this->setExpectedException('InvalidArgumentException');
+        new Uri('///');
+    }
+
+    public function testCtorWithInvalidSchemeThrows()
+    {
+        $this->setExpectedException('InvalidArgumentException');
+        new Uri('not+a+scheme://localhost');
+    }
+
+    public function testCtorWithInvalidHostThrows()
+    {
+        $this->setExpectedException('InvalidArgumentException');
+        new Uri('http://not a host/');
+    }
+
+    public function testCtorWithInvalidPortThrows()
+    {
+        $this->setExpectedException('InvalidArgumentException');
+        new Uri('http://localhost:80000/');
+    }
+
+    public static function provideValidUris()
+    {
+        return array(
+            array(
+                'http://localhost'
+            ),
+            array(
+                'http://localhost/'
+            ),
+            array(
+                'http://localhost:8080/'
+            ),
+            array(
+                'http://127.0.0.1/'
+            ),
+            array(
+                'http://[::1]:8080/'
+            ),
+            array(
+                'http://localhost/path'
+            ),
+            array(
+                'http://localhost/sub/path'
+            ),
+            array(
+                'http://localhost/with%20space'
+            ),
+            array(
+                'http://localhost/with%2fslash'
+            ),
+            array(
+                'http://localhost/?name=Alice'
+            ),
+            array(
+                'http://localhost/?name=John+Doe'
+            ),
+            array(
+                'http://localhost/?name=John%20Doe'
+            ),
+            array(
+                'http://localhost/?name=Alice&age=42'
+            ),
+            array(
+                'http://localhost/?name=Alice&'
+            ),
+            array(
+                'http://localhost/?choice=A%26B'
+            ),
+            array(
+                'http://localhost/?safe=Yes!?'
+            ),
+            array(
+                'http://localhost/?alias=@home'
+            ),
+            array(
+                'http://localhost/?assign:=true'
+            ),
+            array(
+                'http://localhost/?name='
+            ),
+            array(
+                'http://localhost/?name'
+            ),
+            array(
+                ''
+            ),
+            array(
+                '/'
+            ),
+            array(
+                '/path'
+            ),
+            array(
+                'path'
+            ),
+            array(
+                'http://user@localhost/'
+            ),
+            array(
+                'http://user:@localhost/'
+            ),
+            array(
+                'http://:pass@localhost/'
+            ),
+            array(
+                'http://user:pass@localhost/path?query#fragment'
+            ),
+            array(
+                'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment'
+            )
+        );
+    }
+
+    /**
+     * @dataProvider provideValidUris
+     * @param string $string
+     */
+    public function testToStringReturnsOriginalUriGivenToCtor($string)
+    {
+        if (PHP_VERSION_ID < 50519 || (PHP_VERSION_ID < 50603 && PHP_VERSION_ID >= 50606)) {
+            // @link https://3v4l.org/HdoPG
+            $this->markTestSkipped('Empty password not supported on legacy PHP');
+        }
+
+        $uri = new Uri($string);
+
+        $this->assertEquals($string, (string) $uri);
+    }
+
+    public static function provideValidUrisThatWillBeTransformed()
+    {
+        return array(
+            array(
+                'http://localhost:8080/?',
+                'http://localhost:8080/'
+            ),
+            array(
+                'http://localhost:8080/#',
+                'http://localhost:8080/'
+            ),
+            array(
+                'http://localhost:8080/?#',
+                'http://localhost:8080/'
+            ),
+            array(
+                'http://@localhost:8080/',
+                'http://localhost:8080/'
+            ),
+            array(
+                'http://localhost:8080/?percent=50%',
+                'http://localhost:8080/?percent=50%25'
+            ),
+            array(
+                'http://user name:pass word@localhost/path name?query name#frag ment',
+                'http://user%20name:pass%20word@localhost/path%20name?query%20name#frag%20ment'
+            ),
+            array(
+                'HTTP://USER:PASS@LOCALHOST:8080/PATH?QUERY#FRAGMENT',
+                'http://USER:PASS@localhost:8080/PATH?QUERY#FRAGMENT'
+            )
+        );
+    }
+
+    /**
+     * @dataProvider provideValidUrisThatWillBeTransformed
+     * @param string $string
+     * @param string $escaped
+     */
+    public function testToStringReturnsTransformedUriFromUriGivenToCtor($string, $escaped = null)
+    {
+        $uri = new Uri($string);
+
+        $this->assertEquals($escaped, (string) $uri);
+    }
+
+    public function testToStringReturnsUriWithPathPrefixedWithSlashWhenPathDoesNotStartWithSlash()
+    {
+        $uri = new Uri('http://localhost:8080');
+        $uri = $uri->withPath('path');
+
+        $this->assertEquals('http://localhost:8080/path', (string) $uri);
+    }
+
+    public function testWithSchemeReturnsNewInstanceWhenSchemeIsChanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withScheme('https');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('https', $new->getScheme());
+        $this->assertEquals('http', $uri->getScheme());
+    }
+
+    public function testWithSchemeReturnsNewInstanceWithSchemeToLowerCaseWhenSchemeIsChangedWithUpperCase()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withScheme('HTTPS');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('https', $new->getScheme());
+        $this->assertEquals('http', $uri->getScheme());
+    }
+
+    public function testWithSchemeReturnsNewInstanceWithDefaultPortRemovedWhenSchemeIsChangedToDefaultPortForHttp()
+    {
+        $uri = new Uri('https://localhost:80');
+
+        $new = $uri->withScheme('http');
+        $this->assertNotSame($uri, $new);
+        $this->assertNull($new->getPort());
+        $this->assertEquals(80, $uri->getPort());
+    }
+
+    public function testWithSchemeReturnsNewInstanceWithDefaultPortRemovedWhenSchemeIsChangedToDefaultPortForHttps()
+    {
+        $uri = new Uri('http://localhost:443');
+
+        $new = $uri->withScheme('https');
+        $this->assertNotSame($uri, $new);
+        $this->assertNull($new->getPort());
+        $this->assertEquals(443, $uri->getPort());
+    }
+
+    public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withScheme('http');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('http', $uri->getScheme());
+    }
+
+    public function testWithSchemeReturnsSameInstanceWhenSchemeToLowerCaseIsUnchanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withScheme('HTTP');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('http', $uri->getScheme());
+    }
+
+    public function testWithSchemeThrowsWhenSchemeIsInvalid()
+    {
+        $uri = new Uri('http://localhost');
+
+        $this->setExpectedException('InvalidArgumentException');
+        $uri->withScheme('invalid+scheme');
+    }
+
+    public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndPassword()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withUserInfo('user', 'pass');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('user:pass', $new->getUserInfo());
+        $this->assertEquals('', $uri->getUserInfo());
+    }
+
+    public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameOnly()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withUserInfo('user');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('user', $new->getUserInfo());
+        $this->assertEquals('', $uri->getUserInfo());
+    }
+
+    public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndEmptyPassword()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withUserInfo('user', '');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('user:', $new->getUserInfo());
+        $this->assertEquals('', $uri->getUserInfo());
+    }
+
+    public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithPasswordOnly()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withUserInfo('', 'pass');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals(':pass', $new->getUserInfo());
+        $this->assertEquals('', $uri->getUserInfo());
+    }
+
+    public function testWithUserInfoReturnsNewInstanceWhenUserInfoIsChangedWithNameAndPasswordEncoded()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withUserInfo('user:alice', 'pass%20word');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('user%3Aalice:pass%20word', $new->getUserInfo());
+        $this->assertEquals('', $uri->getUserInfo());
+    }
+
+    public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchangedEmpty()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withUserInfo('');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('', $uri->getUserInfo());
+    }
+
+    public function testWithSchemeReturnsSameInstanceWhenSchemeIsUnchangedWithNameAndPassword()
+    {
+        $uri = new Uri('http://user:pass@localhost');
+
+        $new = $uri->withUserInfo('user', 'pass');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('user:pass', $uri->getUserInfo());
+    }
+
+    public function testWithHostReturnsNewInstanceWhenHostIsChanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withHost('example.com');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('example.com', $new->getHost());
+        $this->assertEquals('localhost', $uri->getHost());
+    }
+
+    public function testWithHostReturnsNewInstanceWithHostToLowerCaseWhenHostIsChangedWithUpperCase()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withHost('EXAMPLE.COM');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('example.com', $new->getHost());
+        $this->assertEquals('localhost', $uri->getHost());
+    }
+
+    public function testWithHostReturnsNewInstanceWhenHostIsChangedToEmptyString()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withHost('');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('', $new->getHost());
+        $this->assertEquals('localhost', $uri->getHost());
+    }
+
+    public function testWithHostReturnsSameInstanceWhenHostIsUnchanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withHost('localhost');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('localhost', $uri->getHost());
+    }
+
+    public function testWithHostReturnsSameInstanceWhenHostToLowerCaseIsUnchanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withHost('LOCALHOST');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('localhost', $uri->getHost());
+    }
+
+    public function testWithHostThrowsWhenHostIsInvalidWithPlus()
+    {
+        $uri = new Uri('http://localhost');
+
+        $this->setExpectedException('InvalidArgumentException');
+        $uri->withHost('invalid+host');
+    }
+
+    public function testWithHostThrowsWhenHostIsInvalidWithSpace()
+    {
+        $uri = new Uri('http://localhost');
+
+        $this->setExpectedException('InvalidArgumentException');
+        $uri->withHost('invalid host');
+    }
+
+    public function testWithPortReturnsNewInstanceWhenPortIsChanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withPort(8080);
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals(8080, $new->getPort());
+        $this->assertNull($uri->getPort());
+    }
+
+    public function testWithPortReturnsNewInstanceWithDefaultPortRemovedWhenPortIsChangedToDefaultPortForHttp()
+    {
+        $uri = new Uri('http://localhost:8080');
+
+        $new = $uri->withPort(80);
+        $this->assertNotSame($uri, $new);
+        $this->assertNull($new->getPort());
+        $this->assertEquals(8080, $uri->getPort());
+    }
+
+    public function testWithPortReturnsNewInstanceWithDefaultPortRemovedWhenPortIsChangedToDefaultPortForHttps()
+    {
+        $uri = new Uri('https://localhost:8080');
+
+        $new = $uri->withPort(443);
+        $this->assertNotSame($uri, $new);
+        $this->assertNull($new->getPort());
+        $this->assertEquals(8080, $uri->getPort());
+    }
+
+    public function testWithPortReturnsSameInstanceWhenPortIsUnchanged()
+    {
+        $uri = new Uri('http://localhost:8080');
+
+        $new = $uri->withPort(8080);
+        $this->assertSame($uri, $new);
+        $this->assertEquals(8080, $uri->getPort());
+    }
+
+    public function testWithPortReturnsSameInstanceWhenPortIsUnchangedDefaultPortForHttp()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withPort(80);
+        $this->assertSame($uri, $new);
+        $this->assertNull($uri->getPort());
+    }
+
+    public function testWithPortReturnsSameInstanceWhenPortIsUnchangedDefaultPortForHttps()
+    {
+        $uri = new Uri('https://localhost');
+
+        $new = $uri->withPort(443);
+        $this->assertSame($uri, $new);
+        $this->assertNull($uri->getPort());
+    }
+
+    public function testWithPortThrowsWhenPortIsInvalidUnderflow()
+    {
+        $uri = new Uri('http://localhost');
+
+        $this->setExpectedException('InvalidArgumentException');
+        $uri->withPort(0);
+    }
+
+    public function testWithPortThrowsWhenPortIsInvalidOverflow()
+    {
+        $uri = new Uri('http://localhost');
+
+        $this->setExpectedException('InvalidArgumentException');
+        $uri->withPort(65536);
+    }
+
+    public function testWithPathReturnsNewInstanceWhenPathIsChanged()
+    {
+        $uri = new Uri('http://localhost/');
+
+        $new = $uri->withPath('/path');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('/path', $new->getPath());
+        $this->assertEquals('/', $uri->getPath());
+    }
+
+    public function testWithPathReturnsNewInstanceWhenPathIsChangedEncoded()
+    {
+        $uri = new Uri('http://localhost/');
+
+        $new = $uri->withPath('/a new/path%20here!');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('/a%20new/path%20here!', $new->getPath());
+        $this->assertEquals('/', $uri->getPath());
+    }
+
+    public function testWithPathReturnsSameInstanceWhenPathIsUnchanged()
+    {
+        $uri = new Uri('http://localhost/path');
+
+        $new = $uri->withPath('/path');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('/path', $uri->getPath());
+    }
+
+    public function testWithPathReturnsSameInstanceWhenPathIsUnchangedEncoded()
+    {
+        $uri = new Uri('http://localhost/a%20new/path%20here!');
+
+        $new = $uri->withPath('/a new/path%20here!');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('/a%20new/path%20here!', $uri->getPath());
+    }
+
+    public function testWithQueryReturnsNewInstanceWhenQueryIsChanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withQuery('foo=bar');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('foo=bar', $new->getQuery());
+        $this->assertEquals('', $uri->getQuery());
+    }
+
+    public function testWithQueryReturnsNewInstanceWhenQueryIsChangedEncoded()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withQuery('foo=a new%20text!');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('foo=a%20new%20text!', $new->getQuery());
+        $this->assertEquals('', $uri->getQuery());
+    }
+
+    public function testWithQueryReturnsSameInstanceWhenQueryIsUnchanged()
+    {
+        $uri = new Uri('http://localhost?foo=bar');
+
+        $new = $uri->withQuery('foo=bar');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('foo=bar', $uri->getQuery());
+    }
+
+    public function testWithQueryReturnsSameInstanceWhenQueryIsUnchangedEncoded()
+    {
+        $uri = new Uri('http://localhost?foo=a%20new%20text!');
+
+        $new = $uri->withQuery('foo=a new%20text!');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('foo=a%20new%20text!', $uri->getQuery());
+    }
+
+    public function testWithFragmentReturnsNewInstanceWhenFragmentIsChanged()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withFragment('section');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('section', $new->getFragment());
+        $this->assertEquals('', $uri->getFragment());
+    }
+
+    public function testWithFragmentReturnsNewInstanceWhenFragmentIsChangedEncoded()
+    {
+        $uri = new Uri('http://localhost');
+
+        $new = $uri->withFragment('section new%20text!');
+        $this->assertNotSame($uri, $new);
+        $this->assertEquals('section%20new%20text!', $new->getFragment());
+        $this->assertEquals('', $uri->getFragment());
+    }
+
+    public function testWithFragmentReturnsSameInstanceWhenFragmentIsUnchanged()
+    {
+        $uri = new Uri('http://localhost#section');
+
+        $new = $uri->withFragment('section');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('section', $uri->getFragment());
+    }
+
+    public function testWithFragmentReturnsSameInstanceWhenFragmentIsUnchangedEncoded()
+    {
+        $uri = new Uri('http://localhost#section%20new%20text!');
+
+        $new = $uri->withFragment('section new%20text!');
+        $this->assertSame($uri, $new);
+        $this->assertEquals('section%20new%20text!', $uri->getFragment());
+    }
+
+    public static function provideResolveUris()
+    {
+        return array(
+            array(
+                'http://localhost/',
+                '',
+                'http://localhost/'
+            ),
+            array(
+                'http://localhost/',
+                'http://example.com/',
+                'http://example.com/'
+            ),
+            array(
+                'http://localhost/',
+                'path',
+                'http://localhost/path'
+            ),
+            array(
+                'http://localhost/',
+                'path/',
+                'http://localhost/path/'
+            ),
+            array(
+                'http://localhost/',
+                'path//',
+                'http://localhost/path/'
+            ),
+            array(
+                'http://localhost',
+                'path',
+                'http://localhost/path'
+            ),
+            array(
+                'http://localhost/a/b',
+                '/path',
+                'http://localhost/path'
+            ),
+            array(
+                'http://localhost/',
+                '/a/b/c',
+                'http://localhost/a/b/c'
+            ),
+            array(
+                'http://localhost/a/path',
+                'b/c',
+                'http://localhost/a/b/c'
+            ),
+            array(
+                'http://localhost/a/path',
+                '/b/c',
+                'http://localhost/b/c'
+            ),
+            array(
+                'http://localhost/a/path/',
+                'b/c',
+                'http://localhost/a/path/b/c'
+            ),
+            array(
+                'http://localhost/a/path/',
+                '../b/c',
+                'http://localhost/a/b/c'
+            ),
+            array(
+                'http://localhost',
+                '../../../a/b',
+                'http://localhost/a/b'
+            ),
+            array(
+                'http://localhost/path',
+                '?query',
+                'http://localhost/path?query'
+            ),
+            array(
+                'http://localhost/path',
+                '#fragment',
+                'http://localhost/path#fragment'
+            ),
+            array(
+                'http://localhost/path',
+                'http://localhost',
+                'http://localhost'
+            ),
+            array(
+                'http://localhost/path',
+                'http://localhost/?query#fragment',
+                'http://localhost/?query#fragment'
+            ),
+            array(
+                'http://localhost/path/?a#fragment',
+                '?b',
+                'http://localhost/path/?b'
+            ),
+            array(
+                'http://localhost/path',
+                '//localhost',
+                'http://localhost'
+            ),
+            array(
+                'http://localhost/path',
+                '//localhost/a?query',
+                'http://localhost/a?query'
+            ),
+            array(
+                'http://localhost/path',
+                '//LOCALHOST',
+                'http://localhost'
+            )
+        );
+    }
+
+    /**
+     * @dataProvider provideResolveUris
+     * @param string $base
+     * @param string $rel
+     * @param string $expected
+     */
+    public function testResolveReturnsResolvedUri($base, $rel, $expected)
+    {
+        $uri = Uri::resolve(new Uri($base), new Uri($rel));
+
+        $this->assertEquals($expected, (string) $uri);
+    }
+}