ezsystems/templated-uri-bundle
Symfony bundle for the Hautelook TemplatedUriRouter library, adding an RFC-6570-compliant router and URL generator. It exposes the hautelook.router.template service so you can generate templated links with expressions such as {page} and {&sort*}. Install via Composer and register the bundle in y...
First, the reality check that determines your whole day: this is a Symfony bundle, not a Laravel package. composer require hautelook/templated-uri-bundle into a Laravel app gives you a HautelookTemplatedUriBundle class and a hautelook.router.template service ID that Laravel's container knows nothing about, and AppKernel.php / bundles.php registration steps that don't exist in your app. You cannot "just install it" and call app('hautelook.router.template').
What you actually want is the two-line idea at the heart of the README:
$router->generate('hautelook_demo_route', [
'page' => '{page}',
'sort' => ['{sort}'],
'filter' => ['{filter}'],
]);
// => /demo?{&page}{&sort*}{&filter*}
Given a named route and a parameter map where values are placeholders instead of literals, it emits an RFC-6570 URI template rather than a concrete URL. That is the capability; the bundle is a thin Symfony wrapper around it.
So your path in Laravel:
hautelook/TemplatedUriRouter library — that's where the actual generation logic lives. The bundle adds service registration and nothing else worth porting.composer require guzzlehttp/uri-template (or rize/uri-template). Both do RFC-6570 expansion and are actively maintained. You don't need the routing-generation half — Laravel's route() already gives you the path._links block on a paginated collection resource, where the client needs to discover ?page=, ?sort= and ?filter= without you hard-coding those URLs.Minimum viable first slice:
// app/Services/UriTemplates.php
use GuzzleHttp\UriTemplate\UriTemplate;
class UriTemplates
{
public function forRoute(string $name, array $templateVars, array $routeParams = []): string
{
$base = route($name, $routeParams); // concrete path, no query string
$query = [];
foreach ($templateVars as $key => $spec) {
$query[] = $spec['explode']
? '{&' . $key . '*}'
: '{&' . $key . '}';
}
return $base . implode('', $query);
}
}
// in a JsonResource
' _links' => [
'self' => route('orders.index'),
'next' => app(UriTemplates::class)->forRoute('orders.index', [
'page' => ['explode' => false],
'sort' => ['explode' => true],
]),
],
// => https://api.example.test/orders{&page}{&sort*}
You already ship the second half of the contract for free: UriTemplate::expand($template, ['page' => 2, 'sort' => ['created_at', 'id']]) on the client side, or any JS uri-template library.
Interface + provider binding. Don't let the concept leak. Bind a small interface so you can swap implementations (Guzzle today, in-house tomorrow) without touching resources:
interface TemplatedUriGenerator
{
public function generate(string $routeName, array $templateVars, array $routeParams = []): string;
}
// AppServiceProvider::register
$this->app->singleton(TemplatedUriGenerator::class, GuzzleTemplatedUriGenerator::class);
Keep route generation and template generation separate. This is the single most important workflow decision. route() resolves path segments and required params; your template builder only ever appends query-continuation expressions ({&var}, {&var*}). Trying to template path segments through Laravel's router (e.g. orders/{orderId}) is fighting the framework — emit the concrete path and template only the query surface unless you have a hard requirement for {/id} style path expansion.
Optional vs. required parameters. RFC-6570 gives you the semantics for free:
{&page} — emitted only if the client supplies page.{&sort*} — explode modifier; a list value becomes &sort=a&sort=b. Use for multi-value filters/sorts/sparse fieldsets.{?page,sort} — the ? operator gives you the leading ? and , joining in one expression, useful when you want one expression instead of chained {&...}.Prefer one {?...} expression for readability in payloads; the chained {&...} form is fine and slightly easier to build incrementally.
Resource-transformer integration. The natural Laravel home is inside JsonResource::toArray(), delegating to a dedicated link-builder so resources don't assemble template strings:
class OrderResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'_links' => [
'self' => route('orders.show', $this->id),
'cancel' => route('orders.cancel', $this->id),
'index' => app(TemplatedUriGenerator::class)->generate('orders.index', [
'filter' => ['explode' => true],
'sort' => ['explode' => true],
'page' => ['explode' => false],
]),
],
];
}
}
A fluent link builder pays off fast once more than two resources need templates — ->link('orders.index')->with('sort', explode: true)->with('page'). It also becomes the one place you enforce "no duplicate & operators" and "no empty expressions".
Version/contract tests. Write expansion contract tests, not string-snapshot tests. For each route you template, assert:
$this->assertSame(
'https://api.test/orders?page=2&sort=created_at&sort=id',
UriTemplate::expand($template, ['page' => 2, 'sort' => ['created_at', 'id']])
);
$this->assertSame('https://api.test/orders', UriTemplate::expand($template, []));
That second assertion (empty variables ⇒ clean base URL) is the one that catches 90% of template bugs, including the dangling ? case.
Documentation and OpenAPI. Store the template strings next to your route definitions or in a config map (config/uri_templates.php) so /docs can list "expandable parameters per endpoint" without reflection over controllers.
The package is archived and effectively abandoned. 1 star, 0 dependents, README/CHANGELOG-only maturity, no verified PHP 8.x or modern Symfony support. Do not put it in your composer.json — you would own it forever. Treat the repo as a design reference and take a maintained RFC-6570 library.
Name/provenance mismatch. The repository is ezsystems/templated-uri-bundle but the README and Composer name are hautelook/templated-uri-bundle, and the docs still say "Hautelook Templated URI Bundle." Before you trust any code you find, confirm which upstream it actually mirrors. If you ever do need it in a Symfony app, pin the canonical hautelook/* name and check the tag history (v0.1 → 3.0.0).
route() will percent-encode your braces. route('orders.index', ['page' => '{page}']) produces ...?page=%7Bpage%7D — useless as a template. This is exactly the bug the upstream bundle exists to work around ("Fixed braces that should not be encoded" in the 1.0.0 changelog). In Laravel: generate the base URL with no template params, then append the raw expression string. Never round-trip a template through urlencode or through route()'s parameter array.
Placeholders are not validated. The router treats any string as a literal unless it looks like a template expression; there's no check that {page} is declared in your API contract. A typo like {pgae} ships silently and only breaks clients. Validate template variable names against a whitelist in your builder.
Explode (*) only makes sense on query/continuation expressions. {&sort*} is fine; {sort*} in a path segment or a {#frag} context will produce output clients mis-expand. Restrict explode: true to &/? operators.
Clients must implement expansion. A template in your payload is inert until the consumer runs RFC-6570 expansion. Document the required client library (uri-template for JS, guzzlehttp/uri-template for PHP SDKs) or your _links.next will be pasted literally into HTTP clients and 404.
Caching. Templates are deterministic — cache them per route name in the container or a config cache. Do not regenerate per resource instance inside a 200-item collection response.
No extension points to lean on. You can't subclass your way out of a stale Symfony bundle in a Laravel app. Your extension point is the interface you defined; keep the Guzzle dependency behind it so a future swap is a one-line binding change.
Debugging tip. When a template misbehaves in a payload, expand it immediately with a couple of representative variable sets before blaming the client. Mismatched results almost always mean an unencoded brace, a missing & operator (producing ?page=2sort=a), or two adjacent expressions that should have been one.
Do the build-vs-buy math honestly. The adapter is ~50–100 lines. The archived dependency is a permanent governance line item. For a Laravel codebase the answer is nearly always: wrap a maintained expander, own the 80 lines, move on.
How can I help you explore Laravel packages today?