kareylo / laravel-entity-routing
Entity routing for Laravel: fill every route placeholder from a single model, array or object.
Requires
- php: ^8.2
- illuminate/contracts: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.9.3
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.8|^4.1
- pestphp/pest-plugin-laravel: ^3.2|^4.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-07 13:24:31 UTC
README
Generate URLs by passing a whole model, array or object: every route placeholder ({id}, {slug}, {category}...) is filled from it.
Route::get('/articles/{id}/{slug}', ShowArticle::class)->name('articles.show'); entity_route('articles.show', $article); // https://example.com/articles/42/my-title
Why
Laravel fills one placeholder from a model: route('posts.show', $post) works for {post} or {post:slug}. As soon as a URL has several placeholders, every call site has to list them:
route('articles.show', ['id' => $article->id, 'slug' => $article->slug]);
Add a {category} for SEO later and every one of those calls has to change.
CakePHP solves this with entity routing: you pass the entity and the router extracts each placeholder from it. This package brings the same idea to Laravel. Change the URL structure, and no call site needs to change.
It only affects URL generation. Incoming requests and route model binding are untouched, nothing is stored in a database and no route is added.
Requirements
| Laravel | PHP |
|---|---|
| 12.x | 8.2 to 8.5 |
| 13.x | 8.3 to 8.5 |
Installation
composer require kareylo/laravel-entity-routing
The service provider is registered automatically through package discovery. Publishing the configuration file is only needed for the native route helper:
php artisan vendor:publish --tag=entity-routing-config
Usage
use Illuminate\Support\Facades\URL; entity_route('articles.show', $article); // https://example.com/articles/42/my-title entity_route('articles.show', $article, ['slug' => 'other'], absolute: false); // /articles/42/other entity_route('articles.show', $article, ['page' => 2]); // https://example.com/articles/42/my-title?page=2 URL::entityRoute('articles.show', $article); url()->entityRoute('articles.show', $article); return redirect()->toEntityRoute('articles.show', $article); return redirect()->toEntityRoute('articles.show', $article, [], 301, ['X-Reason' => 'moved']);
All four share the same signature:
entity_route(string $name, mixed $entity, array $extra = [], bool $absolute = true): string
redirect()->toEntityRoute() takes $status and $headers instead of $absolute, like redirect()->route().
Values in $extra take precedence over the entity. Keys that match no placeholder become the query string, exactly like route().
Supported entities
Anything data_get() can read:
- Eloquent models, including accessors
- arrays, including nested arrays
ArrayAccessobjects- plain objects with public properties (or
__get/__isset)
Resolution order
For each route placeholder, the first rule that gives a non-null value wins:
| # | Rule | Example |
|---|---|---|
| 1 | Explicit value in $extra |
['slug' => 'other'] |
| 2 | Mapping declared by the entity (ProvidesRouteParameters) |
'category' => 'category.slug' |
| 3 | Binding field | {article:slug} reads $article->slug, {category:slug} reads $article->category->slug |
| 4 | Route key, when the placeholder is named after the entity and it is UrlRoutable |
{article} on Article uses $article->getRouteKey() |
| 5 | Entity attribute of the same name | {slug} reads $article->slug |
| 6 | Default set with URL::defaults() |
URL::defaults(['locale' => 'fr']) |
A placeholder is "named after the entity" when it is the camel or snake case class name: {blogPost} or {blog_post} for BlogPost.
Declaring a mapping
Implement ProvidesRouteParameters when a placeholder does not match an attribute. Values are dot notation paths, or closures receiving the entity:
use Illuminate\Database\Eloquent\Model; use Kareylo\EntityRouting\Contracts\ProvidesRouteParameters; class Article extends Model implements ProvidesRouteParameters { public function routeParameters(): array { return [ 'category' => 'category.slug', 'year' => fn (Article $article) => $article->published_at->year, ]; } }
Route::get('/blog/{category}/{year}/{slug}', ShowArticle::class)->name('blog.show'); entity_route('blog.show', $article); // https://example.com/blog/news/2026/my-title
Optional and missing parameters
-
An optional placeholder (
{slug?}) that cannot be resolved is left out of the URL. -
A required placeholder that cannot be resolved throws
Kareylo\EntityRouting\Exceptions\MissingEntityRouteParameterException. It extends Laravel'sUrlGenerationException, so existing handlers still catch it, and it exposesrouteName,missingParametersandentityType:Missing required parameters for [Route: articles.show] [URI: articles/{id}/{slug}] [Entity: App\Models\Article] [Missing parameters: id, slug]. -
An unknown route name throws
Kareylo\EntityRouting\Exceptions\EntityRouteNotFoundException(Route [name] not defined.), which extends Symfony'sRouteNotFoundExceptionlike Laravel's own error.
Domain placeholders ({account}.example.com) are resolved like any other, and everything works with php artisan route:cache.
Native route helper (opt-in)
Laravel's own helpers can accept an entity too, under the reserved _entity parameter. Enable it in config/entity-routing.php:
'native_route_helper' => true,
Then:
route('articles.show', ['_entity' => $article]); // https://example.com/articles/42/my-title route('articles.show', ['_entity' => $article, 'slug' => 'other', 'page' => 2], false); // /articles/42/other?page=2 to_route('articles.show', ['_entity' => $article]); redirect()->route('articles.show', ['_entity' => $article], 301); URL::signedRoute('articles.show', ['_entity' => $article]); URL::temporarySignedRoute('articles.show', now()->addHour(), ['_entity' => $article]);
<a href="{{ route('articles.show', ['_entity' => $article]) }}">Read</a>
The other keys of the array behave like $extra. Resolution rules, exceptions and route:cache support are the same as with entity_route().
How it works: when the flag is on, the url service is replaced by Kareylo\EntityRouting\EntityAwareUrlGenerator, a subclass of Laravel's UrlGenerator that keeps the original state (forced root, signing key, defaults...). Its route() method handles _entity and passes every other call to Laravel unchanged. With the flag off (the default), Laravel's generator is not touched.
Things to know:
_entityis reserved in route parameters while the flag is on, and must be an object: arrays (for example request input forwarded toroute()) throwInvalidArgumentException. Useentity_route()for arrays.- Calls without
_entitykeep Laravel's behavior, includingroute('posts.show', $post), which still fills placeholders by position. - Another package replacing the
urlservice conflicts with this one: whichever registers last wins. - Code holding the url generator before this package registers keeps the original instance.
- IDEs and static analysis do not know the
_entitykey.
Frontend and Inertia.js
Frontend route helpers (Ziggy, Wayfinder) fill each placeholder on their own and do not know these resolution rules. The simplest way to get identical URLs on the frontend is to resolve them on the server and send them as strings.
Add HasEntityUrls to a model and declare its routes:
use Illuminate\Database\Eloquent\Model; use Kareylo\EntityRouting\Concerns\HasEntityUrls; class Article extends Model { use HasEntityUrls; public function entityRoutes(): array { return [ 'show' => 'articles.show', 'edit' => 'articles.edit', ]; } }
entity_urls is then appended whenever the model is serialized, so it reaches Inertia props, JSON responses and API resources:
return Inertia::render('Articles/Index', ['articles' => Article::all()]);
// [{ id: 42, slug: 'my-title', entity_urls: { show: 'https://example.com/articles/42/my-title', edit: '...' } }] <a href={article.entity_urls.show}>Read</a>
- URLs are absolute and resolved with every rule above, including
ProvidesRouteParametersclosures andURL::defaults(). - Leave them out with
$article->makeHidden('entity_urls'). - A model that is not saved yet gives
[]. A declared route of a saved model that cannot be resolved throwsMissingEntityRouteParameterExceptionduring serialization. - For arrays, plain objects or a single URL, call
entity_route()where you build the props.
The PHP package does not depend on Inertia or any frontend tool.
Limitations
nullmeans "not resolved". Anullvalue, including['slug' => null]in$extra, falls through to the next rule. An empty string is a value and is used as is.- Arrays have no class name. Rule 4 never applies to them, and
{article:slug}on an array readsarticle.slug, not the array's ownslug. Use{slug}or$extrainstead. - Only public data is read. Private and protected properties of plain objects are invisible; expose them with
ProvidesRouteParameters. - Hidden attributes are never used implicitly. Attributes an Eloquent model hides from serialization (
$hidden, or missing from$visible), on the entity or on a related model, are skipped by binding fields and attribute lookup. Pass them in$extraor map them withProvidesRouteParameterswhen a url really needs them. - Domain values must be host labels. A value placed in a domain placeholder may only contain letters, digits,
.and-; anything else (evil.com/,user@evil.com...) throwsInvalidEntityRouteParameterException. Use punycode (xn--...) for internationalized domains. - Path values are not sanitized. Like
route(),/and..are kept in path values. Validate user-chosen slugs (e.g. withStr::slug()) before saving them. route()is unchanged by default. Passing an entity toroute()keeps Laravel's native behavior, unless you enable the native route helper and use_entity.- Tested versions. CI installs the lowest versions Composer allows, which are Laravel 12.69 and 13.30: older releases are blocked by security advisories. Earlier 12.x and 13.x releases are allowed by the constraints but not tested. Laravel 12.0 to 12.3 encode
%,?and#inside values differently from later versions; this package passes values to Laravel's generator as is, so it follows whatever your version does.
Contributing
Contributions are welcome. See CONTRIBUTING.md for the setup, the workflow (tests first, then code) and the conventions. Report security issues privately as described in SECURITY.md.
composer test # Pest composer test-coverage # Pest with coverage, fails below 80% composer analyse # Larastan composer format # Pint
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.