rize/uri-template
PHP implementation of RFC 6570 URI Templates with full support for all expression types and levels. Expand templates with variables, path segments, and more, plus extract variables from URIs. Supports base URIs and default params for clean API endpoint building.
Install it as a leaf utility, not a framework package.
composer require rize/uri-template:^0.4
Do not copy the README's install snippet ("~0.3") — it is stale. The 0.3.x branch is the pre-PHP-8.1 line and carries PHP 8.4 deprecation noise; 0.4.0+ is the modern line and requires PHP 8.1+. Pin ^0.4.2 (or an exact minor) and add a Dependabot/Renovate rule that a human reviews minor bumps, because this is pre-1.0 semver and 0.3 → 0.4 already shipped a breaking type change.
Where to look first. There is exactly one class and two methods:
Rize\UriTemplate::expand(string $template, array $params): stringRize\UriTemplate::extract(string $template, string $uri, bool $strict = false): ?arrayNo service provider, no facade, no config file, no published assets. Read the README's expansion/extraction examples, then the % modifier section — that is the whole API surface.
First use case. The one thing Laravel genuinely cannot do for you is RFC 6570 operators. route() and url() build concrete URLs from named routes; they cannot express {/segments*}, {.extensions*} or {?q*,limit}. So start with an outbound API client:
use Rize\UriTemplate;
$uri = new UriTemplate('https://api.twitter.com/{version}', ['version' => 1.1]);
$uri->expand('/statuses/show/{id}.json', ['id' => '210462857140252672']);
// https://api.twitter.com/1.1/statuses/show/210462857140252672.json
The base URI + default-params constructor arguments are the package's best ergonomic feature. Use them — they remove version prefixing and auth-host boilerplate from every call site.
1. Bind a pre-configured instance, don't new it everywhere. The object is stateless and cheap, but the base URI and defaults are configuration. Resolve them once:
// AppServiceProvider::register()
$this->app->singleton('uri.template.twitter', fn () => new UriTemplate(
'https://api.twitter.com/{version}',
['version' => config('services.twitter.version', '1.1')]
));
Better still, wrap it in a small domain class (TwitterUriBuilder) so call sites speak your vocabulary (->tweet($id)) rather than RFC 6570. The library should never leak past one or two files.
2. Treat the template string as a constant, not a literal. Templates are effectively part of your integration contract with a third party. Put them in a dedicated class or config array so a vendor version change is a one-line diff:
final class Templates
{
public const TWEET = '/statuses/show/{id}.json';
public const SEARCH = '/search/{term:1}/{term}/{?q*,limit}';
}
3. Use extraction + strict mode as a third-party webhook matcher. This is the gap in Laravel: the router matches registered routes, not runtime template strings, so there is no in-framework answer to "does this inbound URI conform to this vendor's advertised template, and what were the variables?" Strict mode makes extract() a validator that returns null on mismatch:
$params = $uri->extract('/{?a,b}', '/?a=1&b=2', strict: true);
if ($params === null) {
abort(404);
}
Non-strict mode is for lenient parsing (you want whatever you can get); strict is for gating. Choose deliberately per call site — they behave very differently.
4. Use the % modifier for Laravel-shaped query arrays. Laravel/PHP clients send filter[name]=x&tags[]=a&tags[]=b. Plain RFC 6570 {?filter} cannot express that; the library's % extension can:
$uri->expand('{?list%,keys%}', [
'list' => ['a', 'b'],
'keys' => ['a' => 1, 'b' => 2],
]);
// ?list%5B%5D=a&list%5B%5D=b&keys%5Ba%5D=1&keys%5Bb%5D=2
This is a non-standard extension. Any consumer of those URLs must speak it too, so restrict it to services you know accept bracket-style arrays (PHP, Rails, most Node stacks).
5. Round-trip as your test pattern. Because the library does both directions, you can assert consistency cheaply:
$template = '/search/{term:1}/{term}/{?q*,limit}';
$params = ['term' => 'john', 'q' => ['a', 'b'], 'limit' => 10];
$expanded = $uri->expand($template, $params);
$extracted = $uri->extract($template, $expanded, strict: true);
expect($extracted)->toMatchArray(['term' => 'john', 'q' => ['a', 'b']]);
Add a handful of these as data providers against the RFC 6570 examples. Templates are strings that silently rot when a vendor changes their API; a round-trip test is the cheapest guard.
6. Don't replace the router with it. For inbound requests to your own application, Laravel's router is faster, cached, and integrated with middleware/model binding. extract() is for URIs your app did not define — webhooks, OAuth callbacks to third parties, deep-link parsing, signed-URL verification.
Constructor arguments are positional and were nearly changed. 0.3.8 was a revert of a constructor change (#31, #32). Pass ($baseUri, $defaults) positionally and don't subclass around the signature.
Prefix-modifier keys collide. extract() adds an extra convenience key for prefix modifiers: {term:1} yields both term:1 and term in the result. The colon is legal in a PHP array key, so it will not error — it will silently travel onward into your validation, logs, or ->all(). Always filter or Arr::only() the keys you actually declared:
$params = $uri->extract($template, $uri, true);
$params = Arr::only($params ?? [], ['term', 'q', 'limit']);
Strict mode returns null, not []. if ($params === null) — not empty() and not === []. Both failure modes (no match vs. genuinely empty result) are easy to conflate.
Non-strict mode pads missing variables with null. extract('/{?a,b}', '/?a=1') returns ['a' => 1, 'b' => null]. Combined with Laravel's ConvertEmptyStringsToNull middleware this makes "absent" and "present but empty" indistinguishable. If you are validating, use strict mode and treat missing keys as absent.
Everything comes back as a string. Extraction has no type information; limit from ?limit=10 is '10', not 10. Cast explicitly against the template's declared type rather than relying on (int) casts scattered through your code — and remember $request->validate() will happily accept '10' for an integer rule, so your tests won't catch a regression here.
Brackets are percent-encoded on expansion. The % modifier produces %5B%5D, not []. If a downstream service does an exact-string comparison on the query (some signature schemes do), the encoding matters — and so does ordering. Build signed URLs from the library's output, never from a hand-rolled http_build_query() variant, or the signatures will diverge.
Undefined vs. empty differ. RFC 6570 drops undefined variables from the expansion entirely; passing '' or [] takes a different code path. Decide at your wrapper boundary whether null means "omit the parameter" and document it, because "send ?q=" and "send nothing" are frequently different requests to real APIs.
Adjacent dot-separated variables. Multiple variables separated by . (e.g. {file}{.extensions*}) in extraction were fixed in 0.3.1. If you are on a 0.3.x branch for PHP 8.0 compatibility, test those templates explicitly — and treat that pin as a scheduled migration, not a destination.
Debugging. When expansion looks wrong, dump the raw output before the request goes out; the failure is almost always a template typo ({?q*} vs {?q}) or a param key mismatch, not a library bug. When extraction returns null in strict mode, re-run non-strict and inspect which key came back null — that identifies the unmatched segment faster than reading the template.
It's a leaf dependency — keep it that way. No state, no I/O, no lifecycle. Resist wrapping it in a facade or a config-published package of your own; a single injected builder class and a Templates constant file are the right amount of indirection for a 400-star utility.
How can I help you explore Laravel packages today?