skip to content

In Laravel 13, how do {project:slug}, getRouteKeyName() and the #[RouteKey] attribute change which column route model binding queries?

level: middleimportance: should knowfreq 50%

answer

  1. per route versus per model
  2. {project:slug} sets the binding field
  3. getRouteKeyName defaults to the primary key
  4. #[RouteKey('slug')] on the model class
  5. unique, indexed column

basics

~20 s

{project:slug} makes one route query the slug column. getRouteKeyName() sets the default column for every binding of that model; in Laravel 13 the #[RouteKey('slug')] class attribute does the same declaratively. Without either, the primary key is used.

solid answer

~40 s

Two scopes exist. **Per route**, `/projects/{project:slug}` sets a *binding field*: `resolveRouteBinding($value, 'slug')` queries `slug` for that route only, and a nested child with a custom key is also scoped to its parent automatically. **Per model**, `getRouteKeyName()` names the default column for every binding of the class; it returns the primary key unless overridden. Laravel 13 adds the `#[RouteKey('slug')]` attribute, which `getRouteKeyName()` reads before falling back to the primary key, so you no longer need to override the method. The model-wide key also drives `getRouteKey()`, so `route('projects.show', $project)` emits the slug. Either way, the column should be unique and indexed: binding takes the first match.

code

php · 13 lines
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\RouteKey;
use Illuminate\Database\Eloquent\Model;

#[RouteKey('slug')]
class Project extends Model
{
    // /projects/{project} now binds by slug,
    // and route('projects.show', $project) emits the slug.
}

go deeper

for a junior

Recall that {project:slug} changes the lookup column for one route and that the model can set a default column for all routes.

for a middle

Explain the precedence: a per-route binding field beats getRouteKeyName(), which reads #[RouteKey] in Laravel 13 before falling back to the primary key.

for a senior

Insist on unique indexed keys, plan for renamed slugs, and use per-route fields where admin and public URLs need different identifiers.

for a principal

Choose the public identifier strategy — slugs, ULIDs or ids — weighing enumeration risk, URL stability and SEO across the whole product.

## Why change the key By default route model binding looks records up by primary key, so a project tracker exposes `/projects/42`. Teams switch to another column for readable URLs (`/projects/website-relaunch`), to avoid leaking row counts through sequential ids, or to expose a public identifier such as a ULID. Laravel offers two scopes for this. ## Per route: `{project:slug}` Writing the column after a colon in the segment sets a **binding field** for that route only: ```php Route::get('/projects/{project:slug}', [ProjectController::class, 'show']); ``` - Binding calls `resolveRouteBinding($value, 'slug')`, so the query is `where('slug', $value)->first()`. - Other routes that bind `Project` keep using the model's default key. - When the custom key sits on a **nested** segment, such as `/projects/{project}/tasks/{task:slug}`, Laravel also scopes the child query through the parent's relationship. That is a side effect worth remembering; scoping has its own rules. - When a model is passed to `route()` for such a route, Laravel reads the binding field from the model (`$project->slug`) to build the URL. ## Per model: `getRouteKeyName()` and `#[RouteKey]` `getRouteKeyName()` is the model's answer to "which column identifies me in URLs?". In Laravel 13 its implementation is: 1. If the class carries the `#[RouteKey('...')]` attribute (`Illuminate\Database\Eloquent\Attributes\RouteKey`), return that column. 2. Otherwise return the primary key name, normally `id`. You can set it declaratively or by overriding the method: ```php #[RouteKey('slug')] class Project extends Model {} // equivalent, and the only option before the attribute existed public function getRouteKeyName(): string { return 'slug'; } ``` A model-wide key changes two things at once: - **Resolution**: every implicit binding of `Project` without an explicit binding field queries `slug`. - **Generation**: `getRouteKey()` returns the value of that column, so any link built from the model — `route('projects.show', $project)` — contains the slug. ## Choosing between them | Situation | Better choice | |---|---| | Only the public page uses slugs; the admin area keeps ids | `{project:slug}` on the public routes | | Every URL for the model should use the same public identifier | `#[RouteKey]` or `getRouteKeyName()` | | You want links generated from the model to follow automatically | Model-wide key | | The lookup needs more than one column or extra conditions | Neither — customise `resolveRouteBinding()` | ## A worked example Suppose `Project` carries `#[RouteKey('slug')]` and one admin route is written `/admin/projects/{project:id}`: | Request | Query run by binding | |---|---| | `/projects/website-relaunch` | `where('slug', 'website-relaunch')` | | `/admin/projects/42` | `where('id', '42')` — the binding field wins | | `/projects/42` | `where('slug', '42')` — usually a 404 | Links follow the same rules: `route('projects.show', $project)` emits `website-relaunch` from `getRouteKey()`, while `route('admin.projects.show', $project)` emits `42` because that route names its binding field. Teams that want non-guessable public identifiers often store a ULID in a separate `public_id` column and point the route key at it, keeping the integer primary key for joins. ## Pitfalls - **Uniqueness**: binding runs `first()`, so a non-unique slug silently returns whichever row the database gives first. Put a unique index on the column; if slugs are only unique per parent, use scoped binding so the parent narrows the search. - **Indexing**: every request runs this lookup, so the column needs an index. - **Mutable keys**: if titles can change and slugs follow them, old links break. Many teams keep slugs stable or redirect old ones. - **No fallback**: `/projects/{project:slug}` requested with `/projects/42` looks for a slug equal to `"42"`; it does not retry by id, so it usually 404s. - **Custom key vs custom query**: `getRouteKeyName()` changes *which column* is compared. Filtering ("only active projects") or case-insensitive matching belongs in `resolveRouteBinding()` or `resolveRouteBindingQuery()` instead. ## Version note The attribute form is part of Laravel 13's broader move to attribute-first configuration on Eloquent models. On earlier releases the same effect requires overriding `getRouteKeyName()`, which still works in 13.

  • With #[RouteKey('slug')] on Project, how do you still bind one admin route by id?
    Give that route an explicit binding field: `/admin/projects/{project:id}`. A binding field passed to `resolveRouteBinding()` takes precedence over `getRouteKeyName()`, so only that route queries `id` while every other `Project` binding keeps using the slug.
  • Why should the column behind a custom route key carry a unique index?
    Binding runs `where($column, $value)->first()`. Without uniqueness two rows can share a slug and the request silently gets whichever one the database returns first; without an index, every request scans the table. A unique index fixes both, or scope the binding to a parent when slugs are only unique within it.

saying these in an interview costs you the question

  • {project:slug} falls back to the id when no slug matches
  • getRouteKeyName() only changes URL generation, not lookups
  • A custom route key must be the table's primary key
  • Route::pattern('project', ...) chooses the lookup column
  • Changing the route key also filters out inactive records