webx-ui/routing
Routing utilities for Laravel that streamline route registration and organization, with resourceful helpers, route macros, and clean grouping for WebX UI apps. Simplifies named routes, middleware, and controller binding.
Pin it, don't float it. Zero stars, zero dependents, no Packagist signal in the description. Pull it as a pinned VCS dependency so a force-push or tag reshuffle can't silently change what runs in production:
"repositories": [{ "type": "vcs", "url": "https://github.com/webx-ui/webx-ui" }],
"require": { "webx-ui/routing": "dev-main#<full-commit-sha>" }
Read four files before writing a line of integration code. The description is a marketing paragraph, not a spec, and there is no external documentation to lean on. Open the repo and confirm, in this order:
composer.json — the real php and laravel/framework constraints. A mismatch with your app is a stop-the-line finding that costs ninety seconds to check.Wire it in one place. The integration point is a single fallback route at the bottom of routes/web.php. Everything else stays native Laravel:
// routes/web.php — last line, after auth/api/webhooks/health
Route::fallback(\App\Http\Controllers\RegistryController::class);
That controller resolves the incoming path and delegates. Nothing else in the file should change.
First use case: one entity type, not five. A Product with an editor-assigned slug is the cheapest honest spike. Prove four things end-to-end before committing anything:
GET /acme back to that product,The collision test is the one that tells you whether this is a routing layer or a liability. Budget two days for the spike.
Decide the namespace shape now. Global flat namespace, or scoped by site/locale/channel? A path-keyed table naturally grows site_id/locale columns later, but backfilling them after launch is a migration plus an SEO incident. If you are multi-site or multi-locale, add the columns during the spike even if they sit null.
Write to the registry on model events, never in route files. Paths are data, not code. Register in an observer so every entry point — admin form, importer, API, seeder — produces a row:
class ProductObserver
{
public function saved(Product $product): void
{
app(PathRegistry::class)->put(
path: $product->slug, // already normalized
owner: $product, // morph target
resolver: ProductResolver::class,
);
}
public function deleted(Product $product): void
{
app(PathRegistry::class)->forget($product);
}
}
Confirm the real method names against the source before assuming them. What matters is the shape: put() must be an idempotent upsert keyed on the morph pair, so re-saving an unchanged slug is a no-op rather than a collision.
Delegate via one resolver per owner type, dispatched through the morph map. This is the extensibility property that makes the design worth copying — new entity types become a new resolver plus a registration line, with no edits to a shared route file and no cross-team ticket:
class ProductResolver implements PathResolver
{
public function resolve(PathEntry $entry): mixed
{
return Product::findOrFail($entry->owner_id);
}
public function toRoute(mixed $owner): string
{
return route('products.show', $owner);
}
}
Order middleware deliberately. The registry lookup runs after tenancy and locale resolution and before controllers. Put it in dedicated middleware rather than inside the fallback controller, so you can test it in isolation and short-circuit cheaply:
// bootstrap/app.php or Kernel: append to the 'web' group
\App\Http\Middleware\ResolveRegisteredPath::class,
Getting this order wrong throws nothing — it produces wrong-tenant resolution and duplicate-content incidents that surface weeks later in Search Console.
Cache the lookup or don't ship it. The design puts a table read on the hot path for every public request that misses a static route. A Cache::remember("path:{$normalized}", 3600, ...) invalidated in the same transaction as put()/forget() is not an optimization, it's a requirement. Invalidate the redirect entry too, or a rename leaves the old mapping warm.
Treat renames as first-class writes. A rename should write a 301 row and retain the old path rather than overwrite it:
// on slug change: old path -> new path, permanent
Redirect::create(['from' => $old->path, 'to' => $new->path, 'status' => 301]);
Then the resolution order is: registry hit → redirect hit → 404 plus logging. Log the 404s with the requested path — that log is simultaneously your backfill backlog and your early-warning system.
Bind the resolved owner into the request so controllers stay ordinary. Once middleware resolves an owner, $request->attributes->set('owner', $owner) (or a small resolveOwner() helper) hands it to the controller. Controllers should not know the registry exists.
Use the named-route and group helpers for the static surface only. Prefix/namespace helpers and per-group middleware binding are genuinely nice for fixed endpoints — admin panels, API versions, auth walls. Use them there. Do not use them to model entity paths; those are rows.
Normalize at write, enforce in the database. Case, trailing slash, percent-encoding, and Unicode NFC/NFD are all "we'll handle it in the resolver" until the day they aren't. Pick rules, apply them in a Path value object before insertion, and back them with a unique index on the normalized column. A WHERE path = ? lookup against two spellings of the same slug is the classic duplicate-content bug.
Collision semantics must be explicit, not discovered. Last-write-wins is the default failure mode of a naive upsert, and it silently steals live URLs. Throw on collision at the registry layer and require an explicit replace() for the deliberate case. Write that test before you write the feature.
Route caching does not cache your registry. php artisan route:cache flattens the static router; the registry is runtime data queried (or cache-hit) per request. Don't let "route caching friendly" create a false expectation that path lookups are free. Measure p95 on path lookup — that number decides whether the design survives traffic.
The fallback route swallows everything. Registered last, it will happily eat /favicon.ico, sitemap paths, and anything the web server didn't serve. Add explicit early returns for file-extension and known-prefix paths, and confirm static assets are served before Laravel sees them.
SEO blast radius is the real risk. A missing or stale registry row turns an indexed URL into a 404 with no exception to alert on. Three cheap mitigations: a backfill command plus a crawl-and-diff against the current site before cutover, a per-entity-type feature flag so you can disable registry resolution for one type, and a 404 monitor as a hard requirement rather than a nice-to-have.
Don't adopt it for the Blade menu/active-class helpers. The description leads with route-aware link helpers and active-state detection. That's a ternary and a request()->is() call; adding an unmaintained dependency to avoid writing it is a bad trade and a second, unrelated failure surface on a package you're already pinning.
Build an inspection command on day one. php artisan registry:show {path} printing the row, the resolver class, cache state, and whether a redirect exists. Every debugging session in a registry system starts with "why did this path resolve to that?" and ends in thirty seconds with this command.
Wrap the resolver contract behind your own interface. Define App\Contracts\PathResolver, adapt the package contract to it, and call only your interface. Given zero downstream users and no release cadence to rely on, assume you own this within a year — structure the code so taking ownership is a file swap, not a refactor.
How can I help you explore Laravel packages today?