nette/routing
Nette Routing is a standalone two-way router for PHP that maps SEO-friendly URLs to presenter/action pairs and back. It supports route masks, parameters, localization, and is used by the Nette Framework.
Be honest about the fit first: Laravel's router already owns dispatch, middleware, URL generation and signed URLs. nette/routing is not a Laravel package — it ships no service provider, no facade, no middleware, and no Illuminate contracts. You pull it in when you need a second, independent two-way URL engine alongside Laravel's:
Install:
composer require nette/routing # v3.1.x, PHP 8.1+ (v3.1.0 changelog)
Look first at Nette\Routing\Router — the whole API is two methods: match(IRequest $httpRequest): ?array and constructUrl(array $params, UrlScript $refUrl): ?string. Then RouteList (definition + composition) and Route (mask parsing, encoding).
First use case — a legacy redirect resolver in a Laravel app:
use Nette\Http\Request as NetteRequest;
use Nette\Http\UrlScript;
use Nette\Routing\RouteList;
$legacy = new RouteList;
$legacy->addRoute('', ['target' => 'home']);
$legacy->addRoute('eshop/<category>/<product>', ['target' => 'legacy.product']);
$legacy->addRoute('eshop/<category>', ['target' => 'legacy.category']);
// inbound: parse the URL into params
$request = new NetteRequest(new UrlScript($laravelRequest->fullUrl()));
$params = $legacy->match($request); // ['target' => 'legacy.product', 'category' => 'knihy', 'product' => 'php'] or null
// outbound: build a legacy URL from params
$url = $legacy->constructUrl(
['target' => 'legacy.product', 'category' => 'knihy', 'product' => 'php'],
new UrlScript($laravelRequest->fullUrl()),
);
That pair — one definition, both directions — is the entire value proposition. Everything else is glue.
Wrap it in one adapter service. Do not let Nette\Routing types leak into controllers. A single LegacyUrlResolver (constructor-injected RouteList, plus the built-in Nette\Http\RequestFactory or a hand-built Nette\Http\Request) that exposes resolve(string $url): ?array and generate(array $params): string is the right boundary. Swap it for a fake in tests without touching Laravel's container.
Route to the Laravel request only if you must. Nette\Http\Request::fromGlobals() works inside a real HTTP request; for tests, jobs and sub-requests build it explicitly. Routing only reads the URL and method, so a minimal construction is enough:
new NetteRequest(
new UrlScript($request->fullUrl()),
$request->query->all(),
$request->request->all(),
[], [], [],
$request->getMethod(),
);
Define routes in config, not code. Return a fully built RouteList from config/legacy_routes.php and bind it as a singleton so the regex compilation happens once per process:
$this->app->singleton(RouteList::class, fn () => require config_path('legacy_routes.php'));
Scope with withPath() / withDomain() / addList(). Both return clones, so capture the result:
$api = $router->withPath('api/v1'); // trailing slash optional since 3.0.5
$api->addRoute('<resource>/<id \d+>', ['target' => 'api.show']);
$router->addList($api);
This is how you model multi-tenant subdomains or versioned APIs without duplicating masks.
Strangler pattern. A tiny middleware tries the legacy router first; on a match, redirect (301) or dispatch to a legacy bridge controller; on null, $next($request). Keep it after Laravel's own routes have had a chance where they overlap, and never let it own 404s — Laravel's exception handler should.
Use one-way routes deliberately. A route that only generates (sitemap entries) or only matches (inbound redirects) prevents an outbound constructUrl() from accidentally picking an inbound-only pattern. Check the Route flag constants for the one-way flag in your version.
Subclass for tenant logic. Since 3.0.5 RouteList::match() is split into prepareRequest() and completeParameters() — override those rather than match() to inject tenant/domain state between the two phases. Subclassing Route gives you a hook to normalise parameters before encoding.
Test it as plain PHP. No HTTP kernel, no database:
$router = require config_path('legacy_routes.php');
$this->assertSame(
['target' => 'legacy.product', 'category' => 'knihy', 'product' => 'php'],
$router->match(new NetteRequest(new UrlScript('https://shop.test/eshop/knihy/php'))),
);
These run in milliseconds and are the best documentation of your URL contract you'll ever have.
constructUrl() returns ?string. It is null — not an exception — when no route can satisfy the parameter set (missing mask parameter, failed constraint). Every call site must null-guard; throw your own exception containing the params so the failure is debuggable.urlencode()/rawurlencode() before constructUrl() produces double encoding. Since v3.1.1 Route::param2path() covers all RFC 3986 characters (@ ! ~ etc.) — pin ^3.1.1 if your slugs contain them. param2path() is public, so reuse it for your own slug encoding instead of duplicating rules.UrlScript, not Url. The signature type-hints UrlScript because generation needs the script/base path. new Url($url) will fail; and if you deploy under a subdirectory (php artisan serve aliases, XAMPP), the script path changes how / is generated. Derive it from config, never hardcode https://prod.example.com — queue workers will leak the wrong host into sitemaps.<param> is greedy: [^/]+, dots and spaces included. file/<name> matches file/report.php. Constrain everything user-facing: <id \d+>, <slug [\p{L}\p{N}_-]+>. It won't match empty or slashes, so optional segments need bracket syntax: product/<id>[/<tab>].posts/new must precede posts/<id>, or you'll silently get a "post" named new. Walk RouteList::getRoutes() and log each mask to verify ordering; to bisect a mismatch, call Route::match($request) on individual routes instead of the list.withPath() / withDomain() return clones. $router->withPath('admin')->addRoute(...) works, but $router->withPath('admin'); $router->addRoute(...) silently registers at the root.withPath() treats a trailing slash as optional (3.0.5), but masks themselves should be written consistently. If Nginx or a Laravel middleware also normalises slashes you can end up in a 301 loop — pick one owner.addRoute($mask, ['target' => 'x', 'page' => 1])), and any extra keys become available parameters. Keep them scalar/stringable — objects and closures as defaults were fixed only for SimpleRouter in 3.0.3 and still stringify badly in URL building.dd() a RouteList. Compiled regexes and deep object graphs make dumps unreadable and huge. Map getRoutes() through a small toArray() using getPath(), getDomain(), getFlags() and the mask.nette/utils/nette/http to 4.0. nette/http is a hard dependency — expect it in your lockfile even though you only wanted routing.SimpleRouter is a useful oddity: one fixed parameter set, matched against every request. Handy as a catch-all fallback or a "maintenance mode" URL generator, and its 3.0.3 fix made closure defaults survivable.Route::getRoutes()->match(Request::create($url)) parses a URL back into a Laravel route. If your need is only inbound parsing of Laravel-registered routes, you don't need this package at all — reach for nette/routing when you need a URL space Laravel doesn't own.How can I help you explore Laravel packages today?