# 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 to use this

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.

## How it's wired

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.

## Strategies

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.

## Public API (LazyLoadedSiteContext)

| Method                                               | Returns    | Purpose                                                                                                                                                                                            |
| ---------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `__construct(Site $minimalSite, Language $language)` | —          | Create a lazy proxy with an unloaded site.                                                                                                                                                         |
| `site(): Site`                                       | `Site`     | Return 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(): Language`                               | `Language` | Return the language passed at construction (no hydration).                                                                                                                                         |
| `isFullyLoaded(): bool`                              | `bool`     | Check if the site has been fully loaded (`true` if `site()` has been called, `false` otherwise).                                                                                                   |
| `preloadSite(): void`                                | `void`     | Explicitly trigger site hydration by calling `site()`. Use in the view composer if you know a relation will be accessed.                                                                           |

## Detecting unintended hydration

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
<?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
<?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.

## Gotchas

- **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.

## Related

- [Fragment caching](fragment-caching.md) — caching Blade partials inside the view layer.
- [Cache invalidation](cache-invalidation.md) — strategies for invalidating caches across your application.
- [ETag and conditional responses](etag-and-conditional-responses.md) — HTTP caching headers for client-side deduplication.