Search by

kareylo / laravel-entity-routing

Kareylo

Entity routing for Laravel: fill every route placeholder from a single model, array or object.

Package info

github.com/Kareylo/laravel-entity-routing

pkg:composer/kareylo/laravel-entity-routing

Statistics

Installs: 18

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.1 2026-10-07 13:23 UTC

README

Latest Version on Packagist Tests Quality Coverage PHP Version License

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
  • ArrayAccess objects
  • 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's UrlGenerationException, so existing handlers still catch it, and it exposes routeName, missingParameters and entityType:

    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's RouteNotFoundException like 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:

  • _entity is reserved in route parameters while the flag is on, and must be an object: arrays (for example request input forwarded to route()) throw InvalidArgumentException. Use entity_route() for arrays.
  • Calls without _entity keep Laravel's behavior, including route('posts.show', $post), which still fills placeholders by position.
  • Another package replacing the url service 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 _entity key.

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 ProvidesRouteParameters closures and URL::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 throws MissingEntityRouteParameterException during 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

  • null means "not resolved". A null value, 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 reads article.slug, not the array's own slug. Use {slug} or $extra instead.
  • 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 $extra or map them with ProvidesRouteParameters when 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...) throws InvalidEntityRouteParameterException. 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. with Str::slug()) before saving them.
  • route() is unchanged by default. Passing an entity to route() 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.