Skip to content
Grav 2.0 is officially stable. Read the announcement →
Showcase

Converting a modular page into an HTMX fragment

Started by kskt 4 hours ago · 1 replies · 27 views
4 hours ago

Hello.

By nature, modular pages are not routable. However, when performing partial updates with HTMX, you might sometimes want a fragment that only renders the modular page's Twig template. Here is how I implemented the logic for this case with the help of Generative AI.

PHP
    public function onPagesInitialized(): void
    {
        $request = $this->grav['request'];

        // 1. Verify if the request originates from htmx
        $isHtmx = $request->hasHeader('Hx-Request') 
            && strtolower($request->getHeaderLine('Hx-Request')) === 'true';
        if(!$isHtmx) return;

        $pages = $this->grav['pages'];
        $uri = $this->grav['uri'];

        // 2. Fetch the target page safely using the current route
        $page = $pages->find($uri->path());
        if(!$page) return;

        // Ensure the requested route points to a valid module page
        if(!$page->isModule()) return;

        // 3. Check if htmx serving is explicitly enabled in the page's frontmatter
        if(empty($page->header()->htmx)) return;

        // 4. Enforce authorization for write/delete operations (non-GET methods)
        if($uri->method() !== 'GET') {
            $isAuth = $this->grav['login']->isAuthenticated('htmx.super');
            // TODO: Return a 403 Forbidden response instead of a silent return?
            if(!$isAuth) return;
        }

        // 5. Enable routability now that all validation checks have passed
        $page->routable(true);
    }
29 minutes ago

Nice writeup, and you picked the right event, which is the part that usually trips people up here.

onPagesInitialized fires in PagesProcessor before $grav['page'] is resolved, and that service is what calls $pages->dispatch(). Since find() and dispatch() both hand back the same object out of the pages index, flipping routable(true) in your hook is genuinely seen by the dispatch that follows. Worth saying explicitly, because if you'd hooked onPageInitialized instead it would already be too late.

A few things I'd change before running this on a live site.

Pass true to find(). With the default $all = false, Pages::find() does something you don't want here: when the page isn't routable (exactly the state a module is in when your hook runs) it falls through to findSiteBasedRoute(), so a site redirect or wildcard matching that path can hand you back a completely different page. Your isModule() check then fails and the whole thing silently does nothing. $pages->find($uri->path(), true) skips that lookup.

Guard $grav['login']. If the Login plugin is disabled or missing, $this->grav['login'] throws and takes the site down on every non-GET request to any URL.

htmx.super has to actually exist. isAuthenticated($permission) runs the string through $user->authorize(), so unless you've added htmx.super: true to a group or an account it returns false for everyone except super admins, who pass everything. That may be what you meant, but the name reads like "super admins only" when it's really a custom permission you have to define yourself. Something like htmx.write is less confusing.

On your TODO about the 403: I wouldn't. Leaving the page non-routable means Grav serves its normal 404, which doesn't confirm the fragment is there. A 403 tells an unauthenticated caller "something exists at this URL, come back with credentials." For a fragment endpoint the 404 is the better answer. And returning from the hook wouldn't give you a 403 anyway, you'd have to throw a RequestException and let the error handler take it.

Hx-Request isn't a security boundary. Anyone can send it, so a plain curl with that header set gets your fragment. That's fine, and your frontmatter opt-in is the real gate, which is the right design. Just don't put anything in an htmx: true module you wouldn't put on a public URL.

The one that will actually bite you: full page caching. You now have a single URL returning two different bodies depending on a request header, and nothing varies the cache on it. Grav core is fine, since it caches parsed page content rather than final output, but advanced-pagecache keys on language, route and username only. So the first HTMX fetch caches the bare fragment against that route and the next normal browser hit to the same URL gets the fragment back. Same story with Cloudflare, Varnish or any CDN in front. If you run any of them, either exclude these routes or add Vary: HX-Request.

Here's the version I'd run:

PHP
public function onPagesInitialized(): void
{
    $request = $this->grav['request'];

    if (strtolower($request->getHeaderLine('Hx-Request')) !== 'true') {
        return;
    }

    $uri = $this->grav['uri'];

    // $all = true, so a site redirect matching this path can't substitute a different page
    $page = $this->grav['pages']->find($uri->path(), true);
    if (!$page || !$page->isModule() || empty($page->header()->htmx)) {
        return;
    }

    // Writes need a real permission. Staying non-routable means Grav 404s,
    // which doesn't confirm the fragment exists.
    if (!in_array($uri->method(), ['GET', 'HEAD'], true)) {
        $login = $this->grav['login'] ?? null;
        if (!$login || !$login->isAuthenticated('htmx.write')) {
            return;
        }
    }

    $page->routable(true);
}

I've folded HEAD in with GET as well, since a HEAD is a read and your version was treating it as a write.

Thanks for posting this. It's a genuinely useful pattern and not one I've seen written up before.

Suggested topics

Topic Participants Replies Views Activity
Showcase · by Anthony L., 1 week ago
0 157 1 week ago
Showcase · by Andy Miller, 1 month ago
2 602 3 weeks ago
Showcase · by milkboy, 4 weeks ago
1 331 4 weeks ago
Showcase · by chrisschm, 1 month ago
0 303 1 month ago
Showcase · by Julien Perret, 5 months ago
3 921 4 months ago