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
- 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).
- Log into the Control Panel.
- 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).
- 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.
Bug description
Statamic\Http\Middleware\CP\HandleAuthenticatedInertiaRequests::handle()calls:share()unconditionally computesalwaysProps(), which unconditionally calls the privatenav()method (Nav::build()), regardless of whether the current route will ever produce an Inertia response. This middleware sits at the end of thestatamic.cp.authenticatedgroup, so it runs on every authenticated CP route — including ones whose controllers return something other thanInertia::render(), e.g.:GET /cp/thumbnails/{encoded_asset}(ThumbnailController) — returns a rawStreamedResponseFor those routes, the entire nav-building result is thrown away unused —
Inertia::share()just stores it into an array; nothing ever callsInertia::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
Inertia::render()— easiest repro is a Bard/Replicator set preview image:GET /cp/thumbnails/{encoded_asset}(the URL Statamic generates viaAsset::thumbnailUrl()forstatamic.assets.set_preview_images).Nav::build()'s full query fan-out execute even though the response is a binary image.Logs
Environment
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-laravelv2.0.27, navigations/globals on theeloquentdriver.We believe the fix is straightforward and backward-compatible: Inertia already supports lazy/deferred shared props — a plain
Closurevalue nested anywhere in the shared-props tree is only invoked byResponse::resolvePropertyInstances()/resolveArrayableProperties()when an actual page response is built (confirmed by readinginertiajs/inertia-laravelv2'sResponseFactory/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: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 usedClosure::bindto 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:We verified this produces byte-identical
nav/licensing/sessionExpiry/supportUrloutput for real CP page loads (both full page load and Inertia XHR navigation), and eliminates the cost entirely for non-page CP routes.