Skip to content

Virtual pages

A page with its own URL that is not a resource in the MODX tree. The route, the data and the template are yours; MODX only provides the request.

When you want one

  • A catalog of thousands of items, where a resource per item would be absurd
  • User profiles — /user/boshnik
  • Articles from your own table rather than site_content
  • Filters in the path — /catalog/sale/smartphones
  • API endpoints — /api/products/42

A product page, end to end

1. The route

Route files live in core/App/routes/ and are picked up by a glob, in alphabetical order. Write the URI without a leading slash:

php
<?php

use Boshnik\PageBlocks\Facades\Route;

Route::get('product/{alias}', 'ProductController@show')->name('product.show');

Alphabetical order decides who wins

web.php usually ends with Route::fallback(). A file whose name sorts beforeweb.php is matched first; one that sorts after it never gets the chance. Naming the file product.php is not cosmetic.

2. The controller

core/App/Http/Controllers/ProductController.php. Route parameters arrive first, the request last:

php
<?php

namespace PageBlocks\App\Http\Controllers;

use Boshnik\PageBlocks\Http\Request;
use PageBlocks\App\Models\PbProduct;

class ProductController extends BaseController
{
    public function show(string $alias, Request $request)
    {
        $product = PbProduct::query()
            ->where('alias', $alias)
            ->whereNotNull('published_at')
            ->first();

        if (!$product) {
            abort(404);
        }

        $this->shell($product->name, $product->description);

        return view('file:templates/product', [
            'product' => $product,
        ]);
    }
}

3. The template

core/App/elements/templates/product.tpl:

html
<h1>{$product->name}</h1>
<div>{$product->description}</div>
<p>Price: {$product->price}</p>

Which base class

There are two, and the choice decides whether you have to think about the shell at all:

BaseWhat it does for you
ControllerResolves the current MODX resource by URI, alias and context, and hydrates its properties. Use it when the page does correspond to a resource.
BaseControllerNothing of the sort. Use it for a page that has no resource — which is exactly the virtual case.

A virtual page has no resource to resolve, so BaseController plus the shell below is the honest combination.

The part nobody warns you about: the shell

A virtual page has no resource, so $modx->resource is not yours — and the header, the menu, the breadcrumbs and every meta tag in your layout read from it. Rendered as-is, the page comes out without a header, or with somebody else's.

The working answer is to borrow a resource for the shell and overwrite the fields that describe the page:

php
private function shell(string $title, string $description = ''): void
{
    // The section this page belongs to. Falling back to the home page:
    // a page with somebody else's header beats a page with none.
    $resource = Resource::find(4) ?: Resource::find((int) config('site_start', 1));

    if (!$resource) {
        return;
    }

    $this->modx->resource = $resource;
    $this->modx->resource->pagetitle   = $title;
    $this->modx->resource->seo_title   = $title;
    $this->modx->resource->longtitle   = $title;
    $this->modx->resource->description = $description;
    $this->modx->resource->seo_desc    = $description;
    $this->modx->resource->content     = '';
    $this->modx->resource->uri         = 'product/' . $alias;
}

Overwrite uri as well

Leave it and the canonical link points at the borrowed resource — your page tells search engines it is a copy of the section, and never gets indexed. Same for description: the page takes the section's shell, not its promise.

Paging and 404

A virtual list has to answer 404 past its last page itself. Nothing does it for you:

php
if ($paginator->currentPage() > $paginator->lastPage()) {
    abort(404);
}

Without it ?page=999 returns 200 and an empty table — for a crawler, another empty page in the section.

What you get

  • No dependency on the resource tree — the structure is yours
  • Any URL shape, with parameters
  • Access control, redirects and error handling in plain PHP
  • Data from anywhere: your tables, an API, a cache

© PageBlocks 2019-present