Skip to content

HandleAuthenticatedInertiaRequests eagerly builds the full CP nav (Nav::build()) on every /cp/* request, even ones that never render Inertia #15475

Description

@SteBram

Bug description

Statamic\Http\Middleware\CP\HandleAuthenticatedInertiaRequests::handle() calls:

// handle()
Inertia::share($this->share($request));

share() unconditionally computes alwaysProps(), which unconditionally calls the private nav() method (Nav::build()), regardless of whether the current route will ever produce an Inertia response. This middleware sits at the end of the statamic.cp.authenticated group, so it runs on every authenticated CP route — including ones whose controllers return something other than Inertia::render(), e.g.:

  • GET /cp/thumbnails/{encoded_asset} (ThumbnailController) — returns a raw StreamedResponse
  • SVG/PDF asset routes
  • various action endpoints that return JSON directly

For those routes, the entire nav-building result is thrown away unused — Inertia::share() just stores it into an array; nothing ever calls Inertia::render() to consume it.

Nav::build() is expensive in proportion to the number of sites/navigations/global sets configured, because it walks every navigation tree and (indirectly, via the "Globals" sidebar section) every global set, once per site, to build the sidebar and permission-gated "create" menu. On a multi-site install this becomes a real N+1: one query per (navigation × site) combination, plus a Gate check per creatable content type.

How to reproduce

  1. Set up a Statamic install with multiple sites (the more sites/navs/global sets, the more visible this is — we reproduced it with 16 sites, 6 navigations, 25 global sets).
  2. Log into the Control Panel.
  3. Hit any CP route whose controller does not call Inertia::render() — easiest repro is a Bard/Replicator set preview image: GET /cp/thumbnails/{encoded_asset} (the URL Statamic generates via Asset::thumbnailUrl() for statamic.assets.set_preview_images).
  4. Profile the request (Laravel Debugbar, Xdebug, or just log query count/timing). You'll see Nav::build()'s full query fan-out execute even though the response is a binary image.

Logs

Debugbar capture of a single, non-concurrent `GET /cp/thumbnails/{encoded_asset}` request on our install (16 sites, 6 navs, 25 global sets), before applying our workaround:


Queries:  140 (94.62ms total)
  - 98 of these are `select * from trees where handle = ? and type = 'navigation' and locale = ?`
    — one query per (navigation handle × site), no batching across sites
Models:   592 hydrated
  - 400 are Statamic\Eloquent\Globals\VariablesModel (25 global sets × 16 sites)
  - 96 are Statamic\Eloquent\Structures\TreeModel
Gates:    277 Gate::allows() checks (mostly "create <Contract>" for every
          collection/taxonomy/nav/global, per the sidebar's "+ Create" menu)
Views:    1 (statamic::nav.updates — the "check for updates" sidebar partial)
Total request time: 1.55s


Note the gap: SQL itself is only 94ms of the 1.55s. The rest is PHP building/authorizing ~592 model instances and evaluating 277 gates — for a request that returns a PNG.

After our workaround (see Additional details), the same request:


Queries: 15 (19.27ms), Models: 84, Gates: 5, Views: 0, Total: 228ms

Environment

Environment
Laravel Version: 13.32.0
PHP Version: 8.4.23
Composer Version: 2.10.2
Environment: local
Debug Mode: ENABLED
Maintenance Mode: OFF
Timezone: Europe/Amsterdam
Locale: en

Cache
Config: NOT CACHED
Events: NOT CACHED
Routes: NOT CACHED
Views: CACHED

Drivers
Broadcasting: log
Cache: redis
Database: mysql
Logs: stack / single
Mail: smtp
Queue: redis
Scout: collection
Session: database

Storage
public/storage: NOT LINKED

Filament
Blade Icons: NOT CACHED
Packages: filament, forms, notifications, support, tables, actions, infolists, schemas, widgets
Panel Components: NOT CACHED
Version: v5.8.2
Views: NOT PUBLISHED

Livewire
Livewire: v4.4.5

Spatie Permissions
Features Enabled: Default
Version: 6.25.0

Statamic
Addons: 27
License Key: Not set
Sites: 16 (United Kingdom, United States, France, and 13 more)
Stache Watcher: Enabled
Static Caching: inertia
Version: 6.27.1 PRO

Statamic Addons
jacksleight/statamic-bard-texstyle: 4.2.2
rias/statamic-redirect: 4.2.1
statamic/eloquent-driver: 5.11.1
steets/inertia-statamic: 4.6.0
steets/statamic-addon-browser-lang-redirect: 0.1.11
steets/statamic-addon-form-fields-field: 0.2.1
steets/statamic-addon-form-handlers: 0.4.18
steets/statamic-addon-full-sitemap: 0.3.0
steets/statamic-addon-google-address: 0.2.0
steets/statamic-addon-iplocation: 1.1.0
steets/statamic-addon-listable: 1.2.0
steets/statamic-addon-markdown-text-field: 0.2.1
steets/statamic-addon-meilisearch: 0.3.0
steets/statamic-addon-permissions: 0.1.4
steets/statamic-addon-recaptcha: 0.3.2
steets/statamic-addon-repository: 0.1.8
steets/statamic-addon-responsive-image-field: 0.2.1
steets/statamic-addon-search-index-transformers: 0.3.0
steets/statamic-addon-special-assets-field: 0.1.2
steets/statamic-addon-special-grid-field: 0.1.1
steets/statamic-addon-special-text-field: 0.2.0
steets/statamic-addon-structured-data-tag: 0.1.11
steets/statamic-addon-submit-button-field: 0.2.0
steets/statamic-addon-translation-manager: 1.0.4
steets/statamic-cart: 1.1.0
steets/statamic-starter-kit: 0.1.19
visuellverstehen/statamic-picturesque: 2.2.0

Statamic Eloquent Driver
Addon Settings: eloquent
Asset Containers: eloquent
Assets: file
Blueprints: eloquent
Collection Trees: eloquent
Collections: eloquent
Entries: eloquent
Fieldsets: file
Form Submissions: eloquent
Forms: eloquent
Global Sets: eloquent
Global Variables: eloquent
Navigation Trees: eloquent
Navigations: eloquent
Revisions: file
Sites: eloquent
Taxonomies: eloquent
Terms: eloquent
Tokens: eloquent

Installation

Starter Kit using via CLI

Additional details

Environment: Statamic v6.31.0, statamic/eloquent-driver v5.11.1, Laravel v13.32.0, PHP 8.4.23, inertiajs/inertia-laravel v2.0.27, navigations/globals on the eloquent driver.

We believe the fix is straightforward and backward-compatible: Inertia already supports lazy/deferred shared props — a plain Closure value nested anywhere in the shared-props tree is only invoked by Response::resolvePropertyInstances()/resolveArrayableProperties() when an actual page response is built (confirmed by reading inertiajs/inertia-laravel v2's ResponseFactory/Response). Inertia::share() itself performs zero evaluation — it just stores the value. So wrapping the expensive parts in closures costs nothing for routes that already work today, and skips the wasted work for ones that don't:

// vendor/statamic/cms/src/Http/Middleware/CP/HandleAuthenticatedInertiaRequests.php

private function alwaysProps()
{
    return [
        'version' => Statamic::version(),
        'isPro' => Statamic::pro(),
        'nav' => fn () => $this->nav(),   // was: $this->nav()
        'cmsName' => __(Statamic::pro() ? config('statamic.cp.custom_cms_name', 'Statamic') : 'Statamic'),
    ];
}

private function protectedProps()
{
    if (Statamic::$isRenderingCpException || ! Gate::allows('access cp')) {
        return [];
    }

    return [
        'supportUrl' => config('statamic.cp.support_url'),
        'selectedSiteUrl' => Site::selected()->url(),
        'licensing' => fn () => $this->licensing(),        // was: $this->licensing()
        'sessionExpiry' => fn () => $this->sessionExpiry(), // was: $this->sessionExpiry()
    ];
}

Since this is the vendor class itself, there's no visibility problem calling $this->nav()/$this->licensing() from within its own methods — the closures can call the private methods directly.

Our interim workaround (until a core fix lands), for anyone else hitting this: bind a replacement middleware via the container, since nav()/protectedProps() are private and not overridable by subclassing. We used Closure::bind to reuse Statamic's own private methods rather than reimplementing them, so the workaround stays correct across upgrades as long as those method names don't change:

// app/Http/Middleware/LazyCpInertiaShare.php
class LazyCpInertiaShare
{
    public function handle(Request $request, Closure $next)
    {
        Inertia::share($this->share($request));
        return $next($request);
    }

    private function share(Request $request): array
    {
        $vendor = new VendorMiddleware; // Statamic\Http\Middleware\CP\HandleAuthenticatedInertiaRequests

        $bind = fn (string $method) => Closure::bind(
            fn () => $vendor->$method(), $vendor, VendorMiddleware::class
        );

        return [
            '_statamic' => [
                ...(Inertia::getShared('_statamic') ?? []),
                'version' => Statamic::version(),
                'isPro' => Statamic::pro(),
                'cmsName' => __(Statamic::pro() ? config('statamic.cp.custom_cms_name', 'Statamic') : 'Statamic'),
                'nav' => $bind('nav'),
                ...($request->inertia() ? [] : $bind('protectedProps')()),
            ],
        ];
    }
}

// AppServiceProvider::register()
$this->app->bind(HandleAuthenticatedInertiaRequests::class, LazyCpInertiaShare::class);

We verified this produces byte-identical nav/licensing/sessionExpiry/supportUrl output for real CP page loads (both full page load and Inertia XHR navigation), and eliminates the cost entirely for non-page CP routes.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions