6 min read

Route Metadata in Laravel 13.17: Structured Data That Survives Route Caching

Laravel 13.17 promotes route metadata to a first-class attribute with dot-notation reads and group cascading that survives route:cache. Here is how to use it.

Featured image for "Route Metadata in Laravel 13.17: Structured Data That Survives Route Caching"

For years, the standard trick for attaching custom data to a route was to shove it into the action array and dig it back out later. It worked, but it was never really supported, and it broke in annoying ways the moment you ran route:cache. Laravel 13.17.0, released on June 23, 2026, fixes that by promoting metadata to a first-class attribute on routes. It flows through the entire route-building pipeline, survives caching, and cascades sensibly through route groups. Here is what landed and where it earns its keep.

The Old Way, and Why It Hurt

Say you wanted each route to carry a page title for your layout, or a flag telling middleware how to treat the request. Before 13.17 you would reach for something like defaults() or stuff a key into the controller action array, then fish it out of the route action inside a view composer or middleware. The data had no defined home, no merge semantics, and no guarantee it would serialize cleanly when the route cache kicked in. Two developers on the same team would each invent a slightly different convention, and neither would be documented.

Route metadata replaces all of that with one supported API.

Attaching Metadata

You add metadata to a route with the new metadata() method. It takes an array, and there are no restrictions on how you structure it:

use App\Http\Controllers\ArticleController;

Route::get('/articles', [ArticleController::class, 'index'])
    ->metadata(['head' => ['title' => 'Articles']]);

Reading it back happens on the resolved route via getMetadata(), which understands dot notation and accepts an optional default for keys that are not present:

$request->route()->getMetadata('head.title');            // 'Articles'
$request->route()->getMetadata('head.author', 'php[architect]');

That default argument is the small touch that makes this pleasant to use. You never have to guard against missing keys with isset() gymnastics; you just supply a sensible fallback inline.

Group Cascading

The feature that turns this from a convenience into a real tool is how metadata behaves across route groups. Metadata set on a group cascades to every route inside it. Associative arrays merge recursively, so a nested route can layer its own values on top of what the group provides. Lists and scalar values, on the other hand, replace what they inherit rather than merging.

Route::metadata(['head' => ['robots' => ['noindex'], 'author' => 'php[architect]']])
    ->group(function () {
        Route::get('/articles', [ArticleController::class, 'index'])
            ->metadata(['head' => ['title' => 'Articles']]);
    });

// The resolved route's getMetadata('head') returns:
// [
//     'robots' => ['noindex'],
//     'author' => 'php[architect]',
//     'title'  => 'Articles',
// ]

Notice what happened there. The group set robots and author, the individual route added title, and all three ended up on the resolved route because the head arrays merged recursively. If the inner route had also set robots, its list would have replaced the group’s list outright, because lists are treated as replace-not-merge. That distinction is worth committing to memory, since it is exactly the behavior you want for something like a robots directive where you rarely mean to append.

Resource and singleton routes are supported too, so you can annotate a whole Route::resource() block and let each generated route inherit the group defaults.

If you ever need to blow away inherited values instead of layering onto them, call setMetadata() on the route instance. Where metadata() merges, setMetadata() replaces.

A Practical Use: Per-Route SEO Head Data

The example that keeps showing up, and the one that motivated a lot of the design, is centralizing SEO and layout data next to the route definition rather than scattering it across controllers and Blade files. Here is a compact version you could drop into a real application.

Route::metadata(['head' => ['author' => 'php[architect]', 'robots' => ['index', 'follow']]])
    ->group(function () {
        Route::get('/articles', [ArticleController::class, 'index'])
            ->metadata(['head' => ['title' => 'Articles | php[architect]']]);

        Route::get('/articles/drafts', [ArticleController::class, 'drafts'])
            ->metadata(['head' => ['title' => 'Drafts', 'robots' => ['noindex']]]);
    });

Then a single view composer or middleware can read the resolved route and hand the data to your layout:

use Illuminate\Support\Facades\View;

View::composer('layouts.app', function ($view) {
    $head = request()->route()?->getMetadata('head', []);

    $view->with([
        'pageTitle' => $head['title'] ?? config('app.name'),
        'robots'    => implode(', ', $head['robots'] ?? ['index', 'follow']),
    ]);
});

The public articles index inherits index, follow, while the drafts route quietly overrides robots to noindex because its list replaces the inherited one. All of that intent lives in the routes file, where it is easy to audit, rather than being spread across templates.

It Survives route:cache

The detail that makes this trustworthy in production is that metadata is serialized as part of route:cache. This was a genuine weakness of the old action-array hacks: closures and certain structures would not cache cleanly, and you would find yourself debugging why data present in development vanished once the route cache was warm. Because metadata is a defined attribute that flows through the full route-building pipeline, it serializes with the rest of the route and behaves identically cached or uncached. Keep your metadata values to serializable data (arrays, strings, numbers, booleans) and you get the caching benefit for free.

Getting It

Route metadata shipped in Laravel 13.17.0. Update through Composer to pick it up:

composer update laravel/framework

The implementation lives in pull request #60530, which is worth a read if you want to understand the merge semantics in detail. The same 13.17 release also added Postgres transaction pooler support, a dev:list artisan command, and a per-exception retry() method for queued jobs, so there is more in this version than metadata alone.

Worth Adopting

Route metadata is not a headline feature, and that is precisely why it is easy to overlook. But if your codebase has any of those homegrown “stash data on the route” patterns, this replaces them with something documented, cache-safe, and predictable. Migrate one group of routes to it, wire up a single composer or middleware to read the values, and you will likely find a few more places where keeping structured data next to the route definition just makes the code easier to reason about.

Sources