Skip to content

Lazy Page Hydration

Who is this for? Developers working on cached page rendering who want to understand why certain database queries fire (or don’t) on cache hits, and how to prevent unintended eager-loading from defeating cache savings.

TL;DR: Lazy page hydration defers loading of site relations until they’re actually accessed, letting cached HTML responses skip database queries entirely.


When a cached page response is served, you don’t need to load all site relations (menus, themes, metadata, etc.) — the HTML is already rendered. But if a Blade fragment or component accidentally accesses a relation, it forces a full site load on every cache miss (and potentially on every hit). Lazy hydration solves this by wrapping the site in a proxy that only loads relations on first access, not on middleware entry.

The problem it solves:

  • Full-page caching saves rendering time, but only if you don’t load the database during page renders.
  • Without lazy hydration, eager-loading all site relations happens before the view layer, defeating cache savings.

The solution:

  • Lazy proxy: On cache hits, the site is passed to Blade as a minimal object that defers relation loading.
  • Detect hydration: Use the isFullyLoaded() method or inspect query logs to know if a fragment forced full hydration.
  • Preload when needed: Call preloadSite() in the view composer if you know a relation will be accessed.

Three components work together:

  1. RenderingStrategyMiddleware (packages/frontend/src/Http/Middleware/RenderingStrategyMiddleware.php) — reads the page’s rendering_strategy meta field and sets an X-Rendering-Strategy response header. The middleware doesn’t short-circuit responses or inject context; it just reports which strategy the page uses.

  2. LazyLoadedSiteContext (packages/frontend/src/Support/Cache/LazyLoadedSiteContext.php) — a proxy around the Site model that keeps a minimal (unloaded) site and defers full hydration until site() is called. Other code instantiates this and passes it into the view layer.

  3. RenderingStrategyViewComposer (packages/frontend/src/Http/View/RenderingStrategyViewComposer.php) — registered on the capell::app view; it reads the rendering strategy and passes a livewireEnabled boolean to the view. If the page uses Livewire, it must request it during view composition to avoid hydrating the site too early.

The glue:

  • The middleware is registered as frontend.rendering_strategy (see packages/frontend/src/Providers/FrontendServiceProvider.php line ~505).
  • The view composer is registered in FrontendServiceProvider::registerViewComposers() (~549).
  • LazyLoadedSiteContext is instantiated elsewhere (likely in the frontend kernel or context builder) and passed through view data.

The RenderingStrategyEnum defines three page rendering modes. The middleware reports which one the page uses; the view composer uses the same enum to decide whether to boot Livewire:

  • BladeOnly ('blade') — plain Blade rendering, no Livewire. Fully compatible with lazy hydration; no JavaScript overhead. Use for static pages, blog posts, marketing content.

  • BladeWithIslands ('blade-islands') — Blade with isolated Livewire components (“islands”) for specific interactive features. Islands are lazy-loaded Livewire components (form-builder, filters, toggles) that don’t require full page hydration. More efficient than full-page Livewire.

  • FullLivewire ('livewire') — entire page is a Livewire component. Requires full site hydration and boots Livewire on every request. Use only for complex interactive pages (dashboards, real-time content) where the entire page needs reactivity.

The middleware sets the header; the view composer uses the strategy to decide whether to pass livewireEnabled => true to the view.

MethodReturnsPurpose
__construct(Site $minimalSite, Language $language)Create a lazy proxy with an unloaded site.
site(): SiteSiteReturn the fully-loaded site if hydration has occurred, or the minimal site passed at construction if hydration hasn’t happened (or failed silently). Subsequent calls return the cached instance.
language(): LanguageLanguageReturn the language passed at construction (no hydration).
isFullyLoaded(): boolboolCheck if the site has been fully loaded (true if site() has been called, false otherwise).
preloadSite(): voidvoidExplicitly trigger site hydration by calling site(). Use in the view composer if you know a relation will be accessed.

To know if your Blade fragment or component is forcing hydration on cache hits:

Option 1: Check the lazy proxy state

LazyLoadedSiteContext is instantiated internally during request handling and passed through view data. You typically don’t construct it yourself in application code. To inspect the state during local development, create a middleware that checks if the context in view data has been hydrated:

<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Capell\Frontend\Support\Cache\LazyLoadedSiteContext;
use Closure;
use Illuminate\Http\Request;
final class DebugHydrationMiddleware
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// Check during response if the context was hydrated
// (requires passing context through view data and reading it back)
if ($context = $request->attributes->get('lazy_context')) {
if ($context instanceof LazyLoadedSiteContext && $context->isFullyLoaded()) {
error_log('WARNING: Site was hydrated on this request.');
}
}
return $response;
}
}

Option 2: Monitor database queries

In a service provider, enable query logging and watch for site-relation queries on requests that should be cache hits:

<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
final class DebugQueryProvider extends ServiceProvider
{
public function boot(): void
{
DB::listen(function (QueryExecuted $query): void {
// Log queries that load site relations
if (str_contains($query->sql, 'site') || str_contains($query->sql, 'language')) {
error_log("HYDRATION QUERY: {$query->sql}");
}
});
}
}

Option 3: Use Laravel Telescope (production-safe)

Install Telescope and inspect the Database panel to see all queries executed. Filter by request; on cache-hit requests, you should see zero site-relation queries if lazy hydration is working.

  • Accessing $site->relation in a cached fragment forces hydration on every miss. If your fragment cache is invalidated or misses, and the fragment accesses a site relation, it triggers a full site load. Move the relation access outside the fragment cache scope, or preload the site in the view composer.

  • site() is not idempotent in terms of performance. The first call hydrates; subsequent calls return the cached instance. But code that doesn’t expect hydration will be surprised if a deeply nested component calls site().

  • Lazy context only defers relation loading, not basic site attributes. Properties like id, slug, and name on the minimal site are always available. Only relations (menus, themes, settings, etc.) trigger hydration.

  • FullLivewire pages bypass lazy hydration. If a page uses FullLivewire strategy, full site hydration happens before the view is composed, so the lazy proxy provides no benefit (the site is already fully loaded). Use lazy hydration only with BladeOnly or BladeWithIslands.

  • The lazy proxy doesn’t cache loaded relations separately. Once hydrated, the entire site object is loaded into memory. Fragment caching doesn’t gain anything from lazy hydration if the site is already loaded. Use both strategies together: lazy hydration for cache hits, fragment caching for expensive Blade partials that compute output.