ibexa/templated-uri-bundle
Symfony bundle (fork of hautelook/templated-uri-bundle) providing an RFC-6570 compatible templated URI router and URL generator. Exposes the hautelook.router.template service for generating templated route URLs.
First, the honest framing: you will not install this bundle in a Laravel app. ibexa/templated-uri-bundle is a Symfony bundle — its only source file is a HautelookTemplatedUriBundle class extending Symfony\Component\HttpKernel\Bundle\Bundle, registered via registerBundles() / config/bundles.php, exposing the hautelook.router.template service through a compiler pass that decorates Symfony's router. Laravel has no bundle kernel, no hautelook.router.template, and pulling the package in drags symfony/http-kernel, symfony/dependency-injection, and symfony/config into your tree where they will eventually fight Laravel's pinned symfony/console, symfony/routing, and symfony/http-foundation versions.
What you actually steal is the idea, not the package. The bundle exists to solve one problem: generate a concrete Symfony route and leave the query-string placeholders as literal {...} so an API client can expand them later (/demo?{&page}{&sort*}). That is ~30 lines of logic plus a well-specified standard (RFC 6570). Reimplement it in Laravel against Illuminate\Routing\UrlGenerator and you get the whole benefit with zero Symfony coupling.
Minimal steps:
composer require guzzlehttp/uri-template (usually already present transitively via Guzzle 7) if you also need to expand templates — e.g. in tests, in an API gateway proxy, or in a console command that reads a HAL document.TemplatedUrl helper.TemplatedRouterPass and its hautelook.router.template service definition — that is the entire extension point, and it tells you where to hook: the URL generator, not the router.GET /api/products index endpoint returning a links.filter / links.sort / links.page block of expandable templates instead of a dozen hand-built query-string variants.// app/Support/TemplatedUrl.php
namespace App\Support;
use GuzzleHttp\UriTemplate\UriTemplate;
use Illuminate\Contracts\Routing\UrlGenerator;
class TemplatedUrl
{
public function __construct(private UrlGenerator $url) {}
/**
* Build a template: the route stays concrete, the whitelisted
* query params stay as {placeholders} for the client to fill in.
*/
public function template(string $route, array $params = [], bool $absolute = true): string
{
$base = $this->url->route($route, [], $absolute);
if ($params === []) {
return $base;
}
// "?" starts the query, "&" appends; * = explode (repeatable) param
$expressions = [];
foreach (array_values($params) as $i => $name) {
$operator = $i === 0 ? '?' : '&';
$explode = str_ends_with($name, '*') ? '*' : '';
$expressions[] = $operator . rtrim($name, '*') . $explode;
}
return $base . '{' . implode(',', array_map(
fn ($e) => ltrim($e, '?&') . (str_contains($e, '*') ? '*' : ''),
$expressions
)) . '}';
}
/** Expand a template you received (client side, tests, proxying). */
public function expand(string $template, array $variables): string
{
return UriTemplate::expand($template, $variables);
}
}
// app/Providers/TemplatedUrlServiceProvider.php
public function register(): void
{
$this->app->singleton(TemplatedUrl::class);
// mirror the bundle's router decoration: reach for the URL generator
$this->app->alias(TemplatedUrl::class, 'templated-url');
}
$templated->template('api.products.index', ['category', 'page', 'sort*']);
// https://app.test/api/products{?category,page,sort*}
Build in the resource, not in the controller. The Symfony bundle is naturally consumed inside a Hateoas/Bazinga serializer; the Laravel analogue is an JsonResource. Keep the template construction in the resource so every representation of a product carries the same expandable links.
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'links' => [
'self' => route('api.products.show', $this),
'filter' => app(TemplatedUrl::class)->template('api.products.index', ['q', 'category']),
'sort_page' => app(TemplatedUrl::class)->template('api.products.index', ['sort', 'page']),
],
];
}
Whitelist operators; never interpolate raw input into the template string. The Symfony version takes an array and runs it through a UrlGenerator with a custom generator that re-encodes braces after encoding. In Laravel you are concatenating strings, so the array of param names is your security boundary. Validate names against ^[A-Za-z][A-Za-z0-9_]*(\*)?$ and reject anything with {, }, ?, &, # before it reaches the template.
Two directions, two helpers. Producing a template (template()) is string assembly. Consuming one (expand()) needs an RFC 6570 Level 4 implementation — use guzzlehttp/uri-template. Don't hand-roll expansion; the operator matrix ({var}, {+var}, {#var}, {.var}, {/var}, {;var}, {?var}, {&var}, plus * explode and :n prefix modifiers) is bigger than it looks.
Mirror the bundle's router-decoration trick with a URL generator macro if you want the Laravel ergonomics to feel native:
// AppServiceProvider::boot()
UrlGenerator::macro('templated', function (string $route, array $params = []) {
return app(TemplatedUrl::class)->template($route, $params);
});
// url()->templated('api.products.index', ['page', 'sort*'])
This is the direct equivalent of $container->get('hautelook.router.template')->generate(...) — same call site shape, Laravel-flavoured.
Pair it with pagination. Laravel's paginator already emits concrete next_page_url/prev_page_url. Add a sibling page_template so clients can jump to an arbitrary page and keep their current filter set:
'page_template' => $templated->template('api.products.index', ['page']),
'filter_template' => $templated->template('api.products.index', ['q', 'category', 'sort*']),
Use it in docs and OpenAPI output. A console command that dumps every index endpoint's templates makes a decent zero-effort "discoverable API" artifact, and it exercises the helper without touching request paths — a safe pilot, exactly like the bundle's Symfony-side recommendation to start with non-critical routes.
Testing: expansion is pure, so test it without HTTP. Assert that expand($template, ['page' => 2, 'sort' => ['name', '-price']]) yields ...?page=2&sort=name&sort=-price, and assert that unset variables disappear rather than producing page=.
composer require may succeed (Symfony's HTTP kernel is installable), but the moment anything autoloads HautelookTemplatedUriBundle you get a missing-parent fatal, because Symfony\Component\HttpKernel\Bundle\Bundle is present but registerBundles() isn't a concept. Treat the package as documentation, not a dependency.symfony/* constraints will pull in a second wave of packages your symfony/console pin doesn't want.{?a,b} vs {&a,b} is not cosmetic. RFC 6570's ? operator emits the leading ?; & assumes a query already started. The bundle's own README shows /demo?{&page} — a literal ? followed by &-operators — because the generator can't know whether the route already carried a query string. If you use {?a} on a base URL that already has ?foo=bar, expect ?foo=bar?a=1.url()->current() includes the query string; route('x') doesn't. Always derive the base with route() (no params) before appending a template, or you get duplicated ?.UriTemplate::expand('{?q}', ['q' => '{page}']) gives ?q=%7Bpage%7D. The whole point of the Hautelook router is that braces in the template survive encoding — keep braces in the template literal, never in the variable array.{{ $template }} turns & into &; you need {!! !!} in HTML, or the client receives a broken URI. JSON/JsonResponse is safe.{+var} is an open-redirect footgun. Reserved expansion keeps /, :, // intact — which is what you want for path segments and exactly what lets a crafted value escape your host. For path parameters use {/var} or validate against an enum.expand() with a strict check.* (explode) is the only sane way to express repeatable filters. {?sort*} expands ['name','-price'] to ?sort=name&sort=-price (or comma-joined depending on the operator family); {?sort} would give you sort=Array semantics via string coercion. Pick * deliberately.guzzlehttp/uri-template (battle-tested, Guzzle-maintained) plus ~50 lines of your own — not on the bundle. If you later migrate to Symfony, the bundle drops straight in because your call site is already generate()/expand()-shaped.How can I help you explore Laravel packages today?