diff --git a/docs/uri/7.0/uri-template.md b/docs/uri/7.0/uri-template.md
new file mode 100644
index 0000000..5d21072
--- /dev/null
+++ b/docs/uri/7.0/uri-template.md
@@ -0,0 +1,294 @@
+---
+layout: default
+title: RFC 6570 compliant URI template
+description: The UriTemplate class enables expanding a URI template and its submitted parameters into an URI object.
+---
+
+URI Template
+=======
+
+The `League\Uri\UriTemplate` class enables expanding a URI object based on a URI template and its
+submitted parameters following [RFC 6570 URI Template](http://tools.ietf.org/html/rfc6570).
+
+## Template Expansion
+
+### RFC Expansion
+
+The `UriTemplate::expand` public method expands a URI template to generate a valid URI conforming
+to RFC3986.
+
+~~~php
+ '42', 'hotel' => 'Rest & Relax'];
+
+$uriTemplate = new UriTemplate($template);
+$uri = $uriTemplate->expand($params); // instance of League\Uri\Uri
+$rfc3986Uri = $uriTemplate->expandToUri($params); // instance of Uri\Rfc3986\Uri
+$whatWgUrl = $uriTemplate->expandToUrl($params); // instance of Uri\Whatwg\Url
+$psr7Uri = $uriTemplate->expandToPsr7Uri($params); // instance of Psr7 UriInterface
+
+echo $uri; //display https://example.com/hotels/Rest%20%26%20Relax/bookings/42"
+~~~
+
+
expandToUri(), expandToUrl() and expandToPsr7Uri() are available since
+version 7.6.0
+
+Since version 7.6 the expand() method takes a second optional parameter
+which serves as a base URI. The generated URI is resolved against the Base URI.
+
+~~~php
+ '42', 'hotel' => 'Rest & Relax'];
+
+$uriTemplate = new UriTemplate($template);
+$uri = $uriTemplate->expand($params, 'https://example.com'); // instance of League\Uri\Uri
+$rfc3986Uri = $uriTemplate->expandToUri($params, 'https://example.com'); // instance of Uri\Rfc3986\Uri
+$whatWgUrl = $uriTemplate->expandToUrl($params, 'https://example.com'); // instance of Uri\Whatwg\Url
+$whatWgUrl = $uriTemplate->expandToPsr7Uri($params, 'https://example.com'); // instance of League\Uri\Http
+
+echo $uri; //display https://example.com/hotels/Rest%20%26%20Relax/bookings/42"
+~~~
+
+### Strict Expansion
+
+By default, if variables are missing or are not provided an empty string is used as replacement
+string as per the RFC. If you want to force correct expansion you can use the `expandOrFail`
+method. It behaves exactly like the `expand` method but will additionnally throw an
+exception if there are missing required variables.
+
+~~~php
+$template = 'https://api.twitter.com/{version}/search/{term}/{?q*,limit}';
+
+$params = [
+ 'term' => ['john', 'doe'],
+ 'q' => ['a', 'b'],
+ 'limit' => '10',
+];
+
+$uriTemplate = new UriTemplate($template);
+echo $uriTemplate->expand($params), PHP_EOL;
+// display https://api.twitter.com//search/john,doe/?q=a&q=b&limit=10 with missing version
+
+echo $uriTemplate->expandOrFail($params);
+// will throw a TemplateCanNotBeExpanded exception with the following message
+// Missing variables `version`
+~~~
+
+expandToUriOrFail() and expandToUrlOrFail() are available since
+version 7.6.0
+
+## Template Variables
+
+For maximum interoperability you should make sure your variables are
+strings or stringable objects otherwise the value will be cast to string following PHP rules
+except for boolean values true and false which will be converted
+to 1 and 0 respectively.
+
+### Default Variables
+
+The constructor takes an optional set of default variables that can be applied by default when
+expanding the URI template.
+
+~~~php
+$template = 'https://api.twitter.com/{version}/search/{term:1}/{term}/{?q*,limit}';
+
+$params = [
+ 'term' => 'john',
+ 'q' => ['a', 'b'],
+ 'limit' => '10',
+];
+
+$uriTemplate = new UriTemplate($template, ['version' => 1.1]);
+echo $uriTemplate->expand($params);
+//displays https://api.twitter.com/1.1/search/j/john/?q=a&q=b&limit=10
+~~~
+
+### Runtime Variables
+
+The default variables are overwritten by those supplied to the `expand` method.
+
+~~~php
+$template = 'https://api.twitter.com/{version}/search/{term:1}/{term}/{?q*,limit}';
+
+$params = [
+ 'term' => 'john',
+ 'q' => ['a', 'b'],
+ 'limit' => '10',
+ 'version' => '2.0'
+];
+
+$uriTemplate = new UriTemplate($template, ['version' => '1.1']);
+echo $uriTemplate->expand($params), PHP_EOL;
+//displays https://api.twitter.com/2.0/search/j/john/?q=a&q=b&limit=10
+~~~
+
+The `expandToUri()`, `expandToUrl()` and `expandToPsr7Uri()` methods will act exactly like the `expand()` method
+but will instead return a `Uri\Rfc3986\Uri`, a `Uri\Whatwg\Url` or a `Psr7\Http\Message\UriInterface` object respectively.
+
+a WHATWG URL must always be absolute if the URI template is not you
+MUST provide a base URL to expandToUrl() otherwise an Uri\WhatWg\InvalidUrlException
+exception will be thrown.
+
+~~~php
+$template = '/{version}/search/{term:1}/{term}/{?q*,limit}';
+//the template represents a non-absolute URL
+
+$params = [
+ 'term' => 'john',
+ 'q' => ['a', 'b'],
+ 'limit' => '10',
+ 'version' => '2.0'
+];
+
+$uriTemplate = new UriTemplate($template, ['version' => '1.1']);
+
+echo $uriTemplate->expandToUrl($params, 'https://api.twitter.com')->toAsciiString(); //works
+echo $uriTemplate->expandToUrl($params); //will throw
+~~~
+
+You may return a different Psr7\Http\Message\UriInterface object if you
+provide to the expandToPsr7Uri your own Psr\Http\Message\UriFactoryInterface class
+as the third optional argument.
+
+~~~php
+use League\Uri\UriTemplate;
+use Laminas\Diactoros\UriFactory;
+
+$template = '/{version}/search/{term:1}/{term}/{?q*,limit}';
+//the template represents a non-absolute URL
+
+$params = [
+ 'term' => 'john',
+ 'q' => ['a', 'b'],
+ 'limit' => '10',
+ 'version' => '2.0'
+];
+
+$uriTemplate = new UriTemplate($template, ['version' => '1.1']);
+$uri = $uriTemplate->expandToPsr7Uri($params, 'https://api.twitter.com', new UriFactory());
+$uri::class; // returns 'Laminas\Diactoros\Uri'
+~~~
+
+By default, if no factory is provided, the returned PSR-7 `UriInterface` object is a `League\Uri\Http` instance.
+
+### Updating Variables
+
+At any given time you may update your default variables but since the `UriTemplate`
+is an immutable object instead of modifying the current instance, a new
+instance with the modified default variables will be returned.
+
+~~~php
+$template = 'https://api.twitter.com/{version}/search/{term:1}/{term}/{?q*,limit}';
+
+$params = [
+ 'term' => 'john',
+ 'q' => ['a', 'b'],
+ 'limit' => '10',
+ 'version' => '2.0'
+];
+
+$uriTemplate = new UriTemplate($template, ['version' => '1.0', 'foo' => 'bar']);
+$uriTemplate->getDefaultVariables(); //returns new VariableBag(['version' => '1.0'])
+$newUriTemplate = $uriTemplate->withDefaultVariables(['version' => '1.1']);
+$newUriTemplate->getDefaultVariables(); //returns new VariableBag(['version' => '1.1'])
+~~~
+
+Following RFC6570 requirements means not support for
+nested array like the one used with http_build_query
+
+~~~php
+$template = 'https://example.com/hotels/{hotel}/book{?query*}';
+$params = [
+ 'hotel' => 'Rest & Relax',
+ 'query' => [
+ 'period' => [
+ 'start' => '2020-01-12',
+ 'end' => '2020-01-15',
+ ],
+ ],
+];
+
+$uriTemplate = new UriTemplate($template);
+$uriTemplate->expand($params);
+// will throw a League\Uri\UriTemplate\TemplateCanNotBeExpanded when trying to expand the `period` value.
+~~~
+
+## Limitations
+
+### Prefix Modifier
+
+While the RFC does not forbid this, the `UriTemplate` class will throw an exception
+if an attempt is made to use the prefix modifier with a list of value. Other
+implementations will silently ignore the modifier **but** this package will
+trigger the exception to alert the user that something might be wrong and
+that the generated URI might not be the one expected.
+
+~~~php
+$template = 'https://api.twitter.com/{version}/search/{term:1}/{term}/{?q*,limit}';
+
+$params = [
+ 'term' => ['john', 'doe'],
+ 'q' => ['a', 'b'],
+ 'limit' => '10',
+ 'version' => '2.0'
+];
+
+$uriTemplate = new UriTemplate($template);
+echo $uriTemplate->expand($params), PHP_EOL;
+// throw a League\Uri\UriTemplate\TemplateCanNotBeExpanded because the term variable is a list and not a string.
+~~~
+
+### Braces Usage
+
+The following implementation disallows the use of braces `{` or `}` outside of being URI
+template expression delimiters. If not used as the boundary of an expression an
+exception will be triggered.
+
+~~~php
+$template = 'https://example.com/hotels/{/book{?query*}';
+$uriTemplate = new UriTemplate($template);
+// will throw a League\Uri\Exceptions\SyntaxError on instantiation
+~~~
+
+If your template do require them you should URL encode them.
+
+~~~php
+$template = 'https://example.com/hotels/%7B/{hotel}';
+$params = ['booking' => 42, 'hotel' => 'Rest & Relax'];
+
+$uriTemplate = new UriTemplate($template);
+echo $uriTemplate->expand($params), PHP_EOL;
+// https://example.com/hotels/%7B/Rest%20%26%20Relax
+~~~
+
+## Interoperability
+
+Available since version 7.6
+
+To allow easier integration with other PHP packages and especially [PSR-13](https://www.php-fig.org/psr/psr-13/)
+the `UriTemplate` class implements the `Stringable` interface.
+
+~~~php
+use League\Uri\UriTemplate;
+use Symfony\Component\WebLink\Link;
+
+$uriTemplate = new UriTemplate('https://google.com/search{?q*}');
+
+$link = (new Link())
+ ->withHref($uriTemplate)
+ ->withRel('next')
+ ->withAttribute('me', 'you');
+
+// Once serialized will return
+// '; rel="next"; me="you"'
+~~~
+
+The `Symfony\Component\WebLink\Link` package implements `PSR-13` interfaces.
diff --git a/uri/UriTemplate/Template.php b/uri/UriTemplate/Template.php
new file mode 100644
index 0000000..6e0d459
--- /dev/null
+++ b/uri/UriTemplate/Template.php
@@ -0,0 +1,148 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace League\Uri\UriTemplate;
+
+use BackedEnum;
+use Deprecated;
+use League\Uri\Exceptions\SyntaxError;
+use Stringable;
+
+use function array_filter;
+use function array_map;
+use function array_reduce;
+use function array_unique;
+use function preg_match_all;
+use function preg_replace;
+use function str_replace;
+use function strpbrk;
+
+use const PREG_SET_ORDER;
+
+/**
+ * @internal The class exposes the internal representation of a Template and its usage
+ */
+final class Template implements Stringable
+{
+ /**
+ * Expression regular expression pattern.
+ */
+ private const REGEXP_EXPRESSION_DETECTOR = '/(?\{[^}]*})/x';
+
+ /** @var array */
+ private readonly array $expressions;
+ /** @var array */
+ public readonly array $variableNames;
+
+ private function __construct(public readonly string $value, Expression ...$expressions)
+ {
+ $this->expressions = $expressions;
+ $this->variableNames = array_unique(
+ array_merge(
+ ...array_map(
+ static fn (Expression $expression): array => $expression->variableNames,
+ $expressions
+ )
+ )
+ );
+ }
+
+ /**
+ * @throws SyntaxError if the template contains invalid expressions
+ * @throws SyntaxError if the template contains invalid variable specification
+ */
+ public static function new(BackedEnum|Stringable|string $template): self
+ {
+ if ($template instanceof BackedEnum) {
+ $template = $template->value;
+ }
+
+ $template = (string) $template;
+ /** @var string $remainder */
+ $remainder = preg_replace(self::REGEXP_EXPRESSION_DETECTOR, '', $template);
+ false === strpbrk($remainder, '{}') || throw new SyntaxError('The template "'.$template.'" contains invalid expressions.');
+
+ preg_match_all(self::REGEXP_EXPRESSION_DETECTOR, $template, $founds, PREG_SET_ORDER);
+
+ return new self($template, ...array_values(
+ array_reduce($founds, function (array $carry, array $found): array {
+ if (!isset($carry[$found['expression']])) {
+ $carry[$found['expression']] = Expression::new($found['expression']);
+ }
+
+ return $carry;
+ }, [])
+ ));
+ }
+
+ /**
+ * @throws TemplateCanNotBeExpanded if the variables are invalid
+ */
+ public function expand(iterable $variables = []): string
+ {
+ if (!$variables instanceof VariableBag) {
+ $variables = new VariableBag($variables);
+ }
+
+ return $this->expandAll($variables);
+ }
+
+ /**
+ * @throws TemplateCanNotBeExpanded if the variables are invalid or missing
+ */
+ public function expandOrFail(iterable $variables = []): string
+ {
+ if (!$variables instanceof VariableBag) {
+ $variables = new VariableBag($variables);
+ }
+
+ $missing = array_filter($this->variableNames, fn (string $name): bool => !isset($variables[$name]));
+ if ([] !== $missing) {
+ throw TemplateCanNotBeExpanded::dueToMissingVariables(...$missing);
+ }
+
+ return $this->expandAll($variables);
+ }
+
+ private function expandAll(VariableBag $variables): string
+ {
+ return array_reduce(
+ $this->expressions,
+ fn (string $uri, Expression $expr): string => str_replace($expr->value, $expr->expand($variables), $uri),
+ $this->value
+ );
+ }
+
+ public function __toString(): string
+ {
+ return $this->value;
+ }
+
+ /**
+ * DEPRECATION WARNING! This method will be removed in the next major point release.
+ *
+ * @throws SyntaxError if the template contains invalid expressions
+ * @throws SyntaxError if the template contains invalid variable specification
+ * @deprecated Since version 7.0.0
+ * @codeCoverageIgnore
+ * @see Template::new()
+ *
+ * Create a new instance from a string.
+ *
+ */
+ #[Deprecated(message:'use League\Uri\UriTemplate\Template::new() instead', since:'league/uri:7.0.0')]
+ public static function createFromString(Stringable|string $template): self
+ {
+ return self::new($template);
+ }
+}
diff --git a/uri/UriTemplate/TemplateTest.php b/uri/UriTemplate/TemplateTest.php
new file mode 100644
index 0000000..85f7dd2
--- /dev/null
+++ b/uri/UriTemplate/TemplateTest.php
@@ -0,0 +1,189 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace League\Uri\UriTemplate;
+
+use JsonException;
+use League\Uri\Exceptions\SyntaxError;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+use Throwable;
+
+use const JSON_THROW_ON_ERROR;
+
+#[CoversClass(Template::class)]
+final class TemplateTest extends TestCase
+{
+ private static string $rootPath = __DIR__.'/../../vendor/uri-templates/uritemplate-test';
+
+ /** @var array */
+ private static array $testFilenames = [
+ 'spec-examples.json',
+ 'negative-tests.json',
+ 'extended-tests.json',
+ ];
+
+ #[DataProvider('uriTemplateSpecificationDataProvider')]
+ #[Test]
+ public function testItCompliesWithUriTemplatesExpansionTests(
+ array $variables,
+ string $input,
+ string|array|false $expected
+ ): void {
+ if (false === $expected) {
+ $this->expectException(Throwable::class);
+ }
+
+ $result = Template::new($input)->expand(new VariableBag($variables));
+
+ if (is_array($expected)) {
+ self::assertContains($result, $expected);
+ } else {
+ self::assertSame($expected, $result);
+ }
+ }
+
+ /**
+ * @throws JsonException
+ * @throws RuntimeException
+ * @return iterable|false
+ * }>
+ */
+ public static function uriTemplateSpecificationDataProvider(): iterable
+ {
+ foreach (static::$testFilenames as $path) {
+ $path = static::$rootPath.'/'.ltrim($path, '/');
+ if (false === $content = file_get_contents($path)) {
+ throw new RuntimeException("unable to connect to the path `$path`.");
+ }
+
+ /** @var array $records */
+ $records = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
+ foreach ($records as $title => $testSuite) {
+ $level = $testSuite['level'] ?? null;
+ $variables = $testSuite['variables'];
+ foreach ($testSuite['testcases'] as $offset => [$input, $expected]) {
+ yield $title.' - '.$level.' # '.($offset + 1) => [
+ 'variables' => $variables,
+ 'input' => $input,
+ 'expected' => $expected,
+ ];
+ }
+ }
+ }
+ }
+
+ #[DataProvider('providesValidNotation')]
+ public function testItCanBeInstantiatedWithAValidNotation(string $notation): void
+ {
+ self::assertSame($notation, Template::new($notation)->value);
+ self::assertSame($notation, (string) Template::new($notation));
+ }
+
+ public static function providesValidNotation(): iterable
+ {
+ return [
+ 'complex template' => ['http://example.com{+path}{/segments}{?query,more*,foo[]*}'],
+ 'template without expression' => ['foobar'],
+
+ ];
+ }
+
+ #[DataProvider('providesInvalidNotation')]
+ public function testItFailsToInstantiatedWithAnInvalidNotation(string $notation): void
+ {
+ self::expectException(SyntaxError::class);
+
+ Template::new($notation);
+ }
+
+ public static function providesInvalidNotation(): iterable
+ {
+ return [
+ ['fooba{r'],
+ ['fooba}r'],
+ ['fooba}{r'],
+ ['{foo{bar'],
+ ['{foo}}bar'],
+ ];
+ }
+
+ #[DataProvider('expectedVariableNames')]
+ public function testGetVariableNames(string $template, array $expected): void
+ {
+ self::assertSame($expected, Template::new($template)->variableNames);
+ }
+
+ public static function expectedVariableNames(): iterable
+ {
+ return [
+ [
+ 'template' => '',
+ 'expected' => [],
+ ],
+ [
+ 'template' => '{foo}{bar}{420}',
+ 'expected' => ['foo', 'bar', '420'],
+ ],
+ [
+ 'template' => '{foo}{foo:2}{+foo}',
+ 'expected' => ['foo'],
+ ],
+ [
+ 'template' => '{bar}{foo}',
+ 'expected' => ['bar', 'foo'],
+ ],
+ ];
+ }
+
+ #[DataProvider('providesExpansion')]
+ public function testItCanExpandVariables(string $notation, array $variables, string $expected): void
+ {
+ self::assertSame($expected, Template::new($notation)->expand($variables));
+ }
+
+ public static function providesExpansion(): iterable
+ {
+ return [
+ 'with variables' => [
+ 'notation' => 'foobar{var}',
+ 'variables' => ['var' => 'yolo'],
+ 'expected' => 'foobaryolo',
+ ],
+ 'with no variables' => [
+ 'notation' => 'foobar',
+ 'variables' => [],
+ 'expected' => 'foobar',
+ ],
+ ];
+ }
+
+ public function testExpandOrFailIfAtLeastOneVariableIsMissing(): void
+ {
+ $this->expectException(TemplateCanNotBeExpanded::class);
+
+ Template::new('{var}{baz}')->expandOrFail(['var' => 'bar']);
+ }
+
+ #[Test]
+ public function it_can_expand_with_expand_or_fail_when_all_variables_are_present(): void
+ {
+ self::assertSame('barfoo', Template::new('{var}{baz}')->expandOrFail(['var' => 'bar', 'baz' => 'foo']));
+ }
+}