System Pages
Some pages have to appear while the application is broken, blocked or switched off. Exactly two of them can still come from your theme.
Overview
A missing page and a closed site are ordinary, and your design belongs on screen. A fatal error or a blocked address is not: the page builder may be what failed, so those carry their own shell.
Your theme owns 404 and maintenance. The second is unlike every other view you write: a complete document, not a page body.
Structure
| Surface | Comes from | When it appears |
|---|---|---|
| Not found | Your theme, views/404 | Any unmatched address, and every failed view guard |
| Maintenance | Your theme, views/maintenance | Every address in maintenance mode, unless an administrator is recognised |
| Maintenance fallback | Core, templates/system/maintenance.php | Only when the active theme ships no maintenance view |
| Application error | Core, templates/system/application-error.php | A fatal error on a non AJAX request, answered with HTTP 500 |
| Blocked address | Core, templates/system/blocked-ip.php | The address blocker rejected the request before routing |
The three core pages share one shell, so a style fix lands in all three. Each uses its helpers and nothing else.
Step by Step
Build the Not Found Page
- Extend the default layout: this page comes through the normal request path.
- Offer the two escape routes behind their switches: knowledge base when enabled, support or contact form by ticket system.
- Never print the address that was not found. It is attacker supplied text.
- Every failed view guard lands here too — this is what a half installed theme shows.
Build the Maintenance Document
The one view that does not extend a layout. Turn the maintenance switch on, or you cannot see it.
- Open with your own doctype, root element and head, taking language and direction from the template variables.
- Copy your first paint guard in verbatim, final fallback value included.
- Print your skin tokens from the theme settings.
- Include only the logo, the message and the switcher — no navigation, cart or account menu.
- Keep the four injection points, so a module can still reach a closed site.
Touch the Core Pages Correctly
- Require the shared shell and use its helpers; never copy its style block.
- Take text from the locale files: the English strings in the PHP are only the translator's fallback.
Reference
The Shell Helpers
$sys = require __DIR__ . DIRECTORY_SEPARATOR . 'inc' . DIRECTORY_SEPARATOR . 'shell.php';
// Values
$sys['lang']; // 'en', 'tr', ... from the language package
$sys['dir']; // 'ltr' or 'rtl'
$sys['app']; // the installation's base address
// Callables
$sys['e']($text); // htmlspecialchars, quotes included, UTF-8
$sys['l']($file, $key, $fallback); // system/{file}/{key}, or $fallback when unavailable
$sys['head'](); // local font link + the shared stylesheet
$sys['mark']($shape); // inline icon: 'alert', 'shield' or 'tools'
$sys['brand'](); // product signature + the version file
$sys['mask']($text); // administrator directory replaced by a placeholder
// Typical use, with the per-page locale file bound once:
$e = $sys['e'];
$L = static fn (string $k, string $fallback): string => $sys['l']('error', $k, $fallback);
What the Error Page May Show
| Situation | Message | Technical block |
|---|---|---|
| An engine error: a type or parse failure | Generic: the original text was written for a developer | Development only |
| An exception raised on purpose | Shown: the operator needs to read it | Development only |
| A fatal caught at shutdown, so no class is known | Generic, as an engine error | Development only |
| A source excerpt around the failing line | Not applicable | Development only; readable, unreadable or encoded, and too large handled |
| The request data | Not applicable | Development only, passwords and tokens redacted first |
Theme Side Contract
// True when the manifest loaded AND the theme directory is really there.
public function exists(): bool;
// True when views/<view>.<ext> is a real file. The extension follows the manifest engine.
public function viewExists(string $view): bool;
// Rendering a system surface outside a request (a CLI health check, a probe):
// chose("website") would skip the theme, so call the theme directly.
public function render(string $view, array $data = []): string;
$page_title, $meta_robots, and the switches $support_enabled and $kbase_enabled that pick the escape routes. The full client data package is prepared here.
What the Maintenance View Receives
The whole list. The client data package is not prepared: menus, cart, announcements and account variables are absent; reading one shows nothing and logs a warning.
ltr or rtl for your root element. Added by the template engine, so they survive a missing data package.
key, name, link, selected and flag-img. Print the control only when the count is above one.
$setting reaches every themed view, so a closed site keeps operator colours.
Example
<!DOCTYPE html>
{* NOT layouts/default.tpl: the site is closed, so no nav, cart or account chrome
may link back into it. The language switcher is the only control, and it
re-renders THIS page in the chosen language. *}
<html lang="{$ui_lang|default:'en'}" dir="{$ui_dir|default:'ltr'}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{lang key='system/maintenance/meta'}</title>
{* Copy the guard VERBATIM from the main layout. Its final fallback value must
match the theme script's, or the page paints in one mode and flips to the
other one frame later. The bug only shows on a visitor with no stored value,
which is why a developer's own browser never reproduces it. *}
<script>
var t = localStorage.getItem('wstyle-theme') || 'light';
document.documentElement.setAttribute('data-bs-theme', t);
</script>
<link rel="stylesheet" href="{asset path='css/theme.css'}">
<link rel="stylesheet" href="{asset path='css/maintenance.css'}">
{* Operator colours still apply on a closed site. *}
<style>:root { --brand: {$setting.primary_color}; }</style>
{hook name='ui:client.head.css'}
{hook name='ui:client.head.js'}
</head>
<body class="maintenance-page">
{hook name='ui:client.body.begin'}
<header>
<a href="{link route='home'}"><span>{$company_name}</span></a>
{* The ONLY control on the page. *}
{if $lang_count > 1}
{foreach $lang_list as $l}
<a href="{$l.link}">{$l.name}</a>
{/foreach}
{/if}
</header>
<main id="maintenance-content">
<p>{lang key='system/maintenance/text'}</p>
</main>
{hook name='ui:client.body.end'}
</body>
</html>
public function main(): void
{
// Minimal on purpose: a closed page needs no menus, cart or announcements.
$this->takeDatas(["language", "website_logos", "company_name", "lang_list"]);
$this->addData("current_year", date("Y"));
// BOTH checks are required. Without viewExists a theme that ships no
// maintenance view falls through to the plain-PHP path, finds no file
// and returns an empty string: a blank page with no error anywhere.
$theme = \Theme::active();
if ($theme->exists() && $theme->viewExists("maintenance"))
$html = $this->view->chose("website")->render("maintenance", $this->data, true);
else
$html = $this->view->chose("system")->render("maintenance", $this->data, true);
// Injected after rendering, so it reaches the theme view and the fallback alike.
$injection = implode('', \Hook::run('ui:client.maintenance.body', $this->data));
if ($injection) $html = preg_replace('/<\/body>/i', $injection . '</body>', $html, 1);
echo $html;
}
Pitfalls
Both read the same stored value and need the same final fallback. A mismatch paints one mode and flips a frame later, seen only by a visitor with no stored preference.
It reaches the request address, the dumped request data and a retry button's target, so a screenshot leaks it. Mask everything; the retry link stays empty.
They appear when the server is unwell, so a content network buys nothing. Icons are inline, the typeface is local with a system fallback, and there is no framework.
An engine failure carries developer text, so a generic sentence replaces it. An exception raised on purpose is shown: the operator has to read it.
Every section arrives open: the reader solves the problem here. Centre with automatic margins; centring alignment crops the top edge once content overflows.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.