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
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
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:
<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:
| Base | What it does for you |
|---|---|
Controller | Resolves the current MODX resource by URI, alias and context, and hydrates its properties. Use it when the page does correspond to a resource. |
BaseController | Nothing 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:
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:
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