Virtual Pages

Render pages backed by external JSON/API data through the same routing and template pipeline as regular content, with no new framework code required.

What a virtual page is

A virtual page is a Page instance backed by data that was never read from disk, useful for exposing an external API (events, products, anything JSON) as a real, renderable page.

PhlatPage pages still create every route. A virtual page is never injected globally. It only exists because a real page (with a real folder and index.json) explicitly routes to it, the same constraint that already governs addons. PhlatPage stays a flat-file CMS first; API integration is a capability layered on top of that, not a replacement for it.

No new classes needed

App::page(string $uri, array $data) already supports this. Pass a non-empty $data array and every disk read is skipped:

  • resolvePageIndex() has no early throw. Its final fallback always returns a path string, even when nothing on disk matches.
  • DataFile::__construct uses $data ?? Json::decodeArray(...), so the disk read is never evaluated once $data is given.
  • File::__construct tolerates a non-existent path.
  • view in $data still resolves the correct view-bound Page subclass, exactly as for a real page.

$app->page('events/summer-fest', $syntheticData) returns a real, fully-formed Page: same class, same template resolution, just backed by data that was never on disk.

Building one, in a router

A real page's own router handler builds the synthetic data and returns it, using the existing rule that a route handler may return a different Page:

// site/views/events/router.php, belongs to the real page at site/pages/events/
use Phlat\App;
use Phlat\Page;
use Phlat\Exceptions\NotFoundException;
use Phlat\Toolkit\Hash;

return function (App $app, Page $page): void {
    $page->router->get('{slug}', function ($slug) use ($app, $page) {
        $record = (new EventsSource())->find($slug);
        if (!$record) throw new NotFoundException();

        return $app->page("{$page->url()}/{$slug}", [
            'uuid' => Hash::sha256("events:{$slug}"),
            'view' => 'event',
            'data' => $record,
        ]);
    });
};

Passing the URI as "{$page->url()}/{$slug}", matching the real, conventional path shape, means url(), template resolution, and everything else built on path derivation just work. The code never distinguishes a real path from a synthetic one; it only ever looks at the string.

Listing many

For an index view, build each virtual page the same way and collect them with PageCollection, which already supports appending instances directly, the same mechanism Page::parents() uses:

$events = new PageCollection($app);
foreach ($source->all() as $slug => $record) {
    $events->append($app->page("{$page->url()}/{$slug}", [
        'uuid' => Hash::sha256("events:{$slug}"),
        'view' => 'event',
        'data' => $record,
    ]));
}

What's deliberately not included

  • No provider interface. A data source is a plain class with whatever lookup methods make sense (find(), all()); nothing to standardize until a second real source needs a shared shape.
  • No registry indexing. Virtual pages aren't written into the SQLite registry, so find() and search/sitemap don't see them yet. That's a deliberate deferral, not an oversight. Add a source column and a boot-time load only if that's actually needed later.
  • No save() guard. Nothing stops save() being called on a virtual page and writing real files as if it were page creation. In practice this can't happen: virtual pages are only ever returned from a GET router handler, never routed through the admin edit flow. Add a guard only if that assumption turns out to be wrong.
<?php

use Phlat\App;
use Phlat\Page;
use Phlat\Exceptions\NotFoundException;
use Phlat\Toolkit\Hash;

return function (App $app, Page $page): void {
    $page->router->get('{slug}', function ($slug) use ($app, $page) {
        $record = (new EventsSource())->find($slug);
        if (!$record) throw new NotFoundException();

        return $app->page("{$page->url()}/{$slug}", [
            'uuid' => Hash::sha256("events:{$slug}"),
            'view' => 'event',
            'data' => $record,
        ]);
    });
};