Skip to content

Raw PHP

A .php file in a web root, an Apache alias, a script that has been correct for fifteen years. WayneUI 1.x shipped as a Laravel package and nothing else, which told every application that was not a modern Laravel application to rewrite itself first. Most of them did not, so the design system covered the newest tenth of the estate. This path is for the other nine tenths.

Requires PHP 8.2 or newer. There is nothing to install.

1. Copy one thing, and point the rest at the CDN

wayne-ui.php runs on your server, so it is always a local copy. The stylesheet, the runtime and the sprite do not have to be:

bash
curl -o wayne-ui.php \
  https://git.wayne.edu/enterprise-apps/wayne-ui/-/raw/main/php/standalone/wayne-ui.php

The GitLab project is private, so that curl only works from a session already signed in to git.wayne.edu with at least Reporter on enterprise-apps/wayne-ui (D-004 in DECISIONS.md). If you do not have that yet, ask whoever administers the group rather than guessing at a public URL: there is no unauthenticated way to fetch this file today, and https://wayneui.apps.wayne.edu/v2 serves the built stylesheet, runtime and sprite but not the PHP source.

php
echo wsu_shell_start([
    'app_name' => 'Course Registration',
    'assets'   => 'https://wayneui.apps.wayne.edu/v2',
]);

wsu_asset() passes an absolute URL through untouched, so setting assets to the CDN base is the entire change. Every wsu_icon(), wsu_mark() and wsu_stylesheet() call resolves against it, and the sprite defaults to a sibling of the theme script the same way it does everywhere else, which is why this is the whole change and not one of several.

If the machine has Composer, composer require waynestate/wayne-ui gives you the same file at vendor/waynestate/wayne-ui/php/standalone/wayne-ui.php, and from v2.0.1 onwards it installs the PHP source, plus the same built assets step 1 has you curl from the CDN, under php/resources/dist: 724 kB of the roughly 1.3 MB is PHP, the rest is wayne-ui.css, wayne-ui-theme.js, the icon sprite, the marks, the fonts, and the three public-template files, already on disk if Composer got you here. That means you can point assets at vendor/waynestate/wayne-ui/php/resources/dist and skip the curl block below entirely, rather than fetching a second copy of files you already have. The v2.0.0 archive carried the whole repository, about 19 MB against the 724 kB that is actually PHP, because the export-ignore rules postdate that tag and Composer serves the archive built from the tag that exists. Composer is convenient here, not required.

Content-Security-Policy has to name the CDN, and let the theme script run

If the application sends a policy, it needs the CDN host on style-src, script-src, font-src, img-src and connect-src. connect-src is the one that gets missed: wayne-ui-theme.js fetches the icon sprite with fetch() rather than an ordinary tag load, because a cross-origin <use> never resolves, whatever the response headers say. A policy with no connect-src of its own falls back to default-src, which blocks that fetch while the stylesheet link and the script tag load without complaint, so icons go blank with nothing in the console to explain why.

img-src is easy to miss too, and this path needs it in three places the static HTML guide's page does not: the favicon, which defaults to the university wordmark; the shield mark in the app bar, a real <img>; and the footer's Warrior Strong mark, which is a CSS background image rather than an <img> but is still governed by img-src, not style-src. A policy with no img-src of its own falls back to default-src, same as connect-src, and all three come back as broken images with nothing louder than a console warning to explain why.

The public template needs img-src to allow data: too, not only the CDN host. The official masthead's own search icon, styled by the third-party @waynestate/wsuheader stylesheet this package ships as wayne-ui-official.css, is a base64-encoded background image rather than a file the CDN host would cover, and img-src governs a CSS background-image exactly as it governs an <img> tag.

That is not the whole policy, though. wsu_shell_start() also writes an inline <script> into <head>, the pre-paint theme script that sets data-theme before the stylesheet can paint the wrong one. A script-src with no 'unsafe-inline', which is the point of sending a policy at all, blocks that script exactly as it blocks anyone else's: a securitypolicyviolation on every load, and the flash the script exists to prevent is back, because the DOM is only right once the rest of the page has already painted.

Do not reach for 'unsafe-inline' to fix that. It allows every inline script on the page, not just this one, which is the protection the policy exists to provide. Unlike the static HTML path, raw PHP has a server, so a nonce is the right tool here: mint a random value once per response, put the same value in the header and on the tag, and never reuse it.

php
<?php
require_once __DIR__.'/wayne-ui.php';

// One value per response. base64 of 16 random bytes is what every worked CSP
// nonce example uses; a nonce reused across requests, or hardcoded, is not a
// nonce, and protects nothing that 'unsafe-inline' would not.
$nonce = base64_encode(random_bytes(16));

header("Content-Security-Policy: "
    ."default-src 'self'; "
    ."style-src 'self' https://wayneui.apps.wayne.edu; "
    ."style-src-attr 'unsafe-inline'; "
    ."script-src 'self' 'nonce-{$nonce}' https://wayneui.apps.wayne.edu; "
    ."font-src 'self' https://wayneui.apps.wayne.edu; "
    ."img-src 'self' data: https://wayneui.apps.wayne.edu; "
    ."connect-src 'self' https://wayneui.apps.wayne.edu;");

echo wsu_shell_start([
    'app_name'  => 'Course Registration',
    'assets'    => 'https://wayneui.apps.wayne.edu/v2',
    'csp_nonce' => $nonce,
]);

csp_nonce is the one option that puts that value on the tag: wsu_shell_start() passes it straight through to wsu_theme_script(), which writes <script nonce="..."> instead of a bare <script> when it is set, and leaves the tag exactly as it was, byte for byte, when it is not. Nothing else on the page needs the nonce. The module script that loads wayne-ui-theme.js is matched by the CDN host already on script-src, the same way any other external, non-inline script is; a nonce only has to sit on a script with no src, because that is the one kind of script a host expression cannot cover.

Calling wsu_theme_script() directly, because you own your own <head>, takes the same argument: wsu_theme_script($nonce).

style-src-attr 'unsafe-inline' is a separate allowance from style-src itself, and it belongs in the header above regardless of the nonce: an inline style="..." attribute, the pattern the reference applications at apps/examples/raw-php/index.php and public.php use throughout for spacing between blocks, is silently discarded without it. Nothing on screen explains this: no broken image, no console error, only a margin that measures 0px where the markup plainly says otherwise. This is narrower than 'unsafe-inline' on style-src, which stays off above: it permits only the style attribute, not a <style> block or an injected stylesheet, which style-src still governs.

Or copy the assets too, for a machine with no network

bash
mkdir -p public/assets/wayne-ui
BASE=https://wayneui.apps.wayne.edu/v2
curl -o public/assets/wayne-ui/wayne-ui.css       $BASE/wayne-ui.css
curl -o public/assets/wayne-ui/wayne-ui-theme.js  $BASE/wayne-ui-theme.js
curl -o public/assets/wayne-ui/icons.svg          $BASE/icons.svg

You also need marks/ and fonts/ from the same place. The static HTML guide lists them. Point assets at /assets/wayne-ui instead of the CDN URL and nothing else about the page changes.

Using the public template as well? It needs three more files that nothing above this line fetches:

bash
# Only if you use the public template (step 6):
curl -o public/assets/wayne-ui/official-header.html  $BASE/official-header.html
curl -o public/assets/wayne-ui/official-footer.html  $BASE/official-footer.html
curl -o public/assets/wayne-ui/wayne-ui-official.css $BASE/wayne-ui-official.css

Per-asset overrides

assets moves everything at once. Five overrides move one file without disturbing the rest: assets_css, assets_css_public, assets_theme_js, assets_icons and assets_marks, plus assets_favicon for the icon in the tab. Each falls back to assets when left null, and each takes an absolute URL exactly the way assets does.

php
echo wsu_shell_start([
    'app_name'     => 'Course Registration',
    'assets'       => '/assets/wayne-ui',
    'assets_icons' => 'https://wayneui.apps.wayne.edu/v2/icons.svg',
]);

Moving assets_icons on its own is safe to do. wsu_shell_start() writes data-wsu-sprite on <html> from wsu_sprite_url(), and every <use href> that wsu_icon_href() builds resolves the sprite through that same function, so the two can never point at different URLs. The attribute is emitted every time, not only when you override assets_icons, so the runtime never has to guess a sprite location from wherever its own script tag happened to load.

2. The shortest page that works

php
<?php
require_once __DIR__.'/wayne-ui.php';
// Used Composer instead of the curl in step 1? Comment out the line above and
// uncomment this one; Composer puts the file under vendor/, not next to yours.
// require_once __DIR__.'/vendor/waynestate/wayne-ui/php/standalone/wayne-ui.php';

echo wsu_shell_start([
    'app_name' => 'Course Registration',
    'assets'   => '/assets/wayne-ui',
    'nav'      => [
        ['label' => 'Term',    'url' => '/register'],
        ['label' => 'Catalog', 'url' => '/catalog'],
    ],
]);
?>

<p class="wsu-prose">Your content.</p>

<?php
echo wsu_shell_end();

That is a complete page: doctype, language, viewport, favicon, stylesheet, the pre-paint theme script, the skip link, the app bar, <main> with tabindex="-1", the footer and the closing tags. Every accessible detail is already in it, and none of it is something you have to remember.

That one require_once is the entire installation, whichever path matches how you got the file in step 1. Everything in the file is a plain function in the global namespace behind a wsu_ prefix, because that is what a PHP application written in 2009 can consume.

3. How the API is shaped

Every wsu_* function returns a string and prints nothing, including wsu_shell_start(). A library where some functions print and some return is a library that produces output in the wrong order at four o'clock on a Friday. So the call is always echo or <?= ?>, and anything returned can be captured, tested, cached or concatenated.

Loading the file twice is a no-op. Everything is declared inside a function_exists guard rather than behind a defined() early return, because PHP binds unconditionally declared top-level functions when it compiles an included file. A defined() guard fatals with "Cannot redeclare" before it is ever reached. This matters if your application has a home-grown loader that says require rather than require_once.

Escaping

Every value is passed through htmlspecialchars() on the way out. There is no unescaped-by-default mode and no global switch. The one way to emit raw HTML is an option key ending in _html, named that way so it is visible at the call site and greppable across a codebase:

php
'title'      => '<b>escaped</b>',   // renders the tags as text
'title_html' => '<b>trusted</b>',   // renders bold

URLs additionally go through wsu_url(), because htmlspecialchars() does not make javascript:alert(1) safe to put in an href. wsu_url() already returns the value with its quotes escaped, ready to sit inside an attribute, so building the attribute itself goes through wsu_url_attr($name, $url) rather than handing that result to wsu_attrs() as well. wsu_attrs() escapes whatever it is given, so a URL that has already been through wsu_url() and is then passed to wsu_attrs() gets escaped a second time, and every & in a query string comes out as &amp;amp;, silently dropping everything after the second parameter. Writing your own call site with a url option means wsu_url_attr(), never wsu_attrs(), for that one value.

4. A real page

php
<?php
require_once __DIR__.'/wayne-ui.php';

$route = '/register';

echo wsu_shell_start([
    'app_name'    => 'Course Registration',
    'description' => 'Register for Winter 2027 courses.',
    'assets'      => '/assets/wayne-ui',

    // Anything not on the production list gets the ribbon.
    'env'   => getenv('APP_ENV') ?: 'production',

    'user'  => $_SERVER['REMOTE_USER'] ?? null,
    'route' => $route,

    'search_label'       => 'Search courses and students',
    'search_placeholder' => 'Search courses, students, sections',
    'search_action'      => '/search',

    'nav' => [
        ['label' => 'Term',     'url' => '/register'],
        ['label' => 'Catalog',  'url' => '/catalog'],
        ['label' => 'Advising', 'url' => '/advising'],
    ],

    // Grouped: each heading names the list beneath it with aria-labelledby.
    'sidebar' => [
        ['heading' => 'Registration', 'items' => [
            ['label' => 'Select courses', 'url' => '/register', 'icon' => 'clipboard-list'],
            ['label' => 'My schedule',    'url' => '/schedule', 'icon' => 'calendar'],
        ]],
        ['heading' => 'Records', 'items' => [
            ['label' => 'Transcripts', 'url' => '/transcripts', 'icon' => 'file-text'],
        ]],
    ],

    // The last crumb becomes the h1 and the document title, so the heading and
    // the trail cannot end up saying different things.
    'breadcrumbs' => [
        ['label' => 'Home',         'url' => '/'],
        ['label' => 'Registration', 'url' => '/register'],
        ['label' => 'Select courses'],
    ],
    'subheading' => 'Winter 2027, registration closes 8 November',

    'actions_html' =>
        wsu_button(['variant' => 'outline', 'icon' => 'download', 'label' => 'Export'])
        .wsu_button(['variant' => 'primary', 'icon' => 'plus', 'label' => 'Add course']),
]);

echo wsu_alert([
    'variant' => 'warning',
    'title'   => 'Advising hold',
    'body'    => 'Meet with an advisor before registering for more than 12 credits.',
]);

// The sort control is authored by the page, because which columns sort is the
// page's decision. It is a real button, so Space and Enter both work.
$head = '<tr>'
    .'<th scope="col"><button class="wsu-table__sort" type="button">Course '
        .wsu_icon('arrow-up-down').'</button></th>'
    .'<th scope="col">Title</th>'
    .'<th scope="col">Credits</th>'
    .'<th scope="col">Status</th>'
    .'</tr>';

echo wsu_table_start('Available courses, Winter 2027', ['head_html' => $head]);

foreach ($courses as $course) {
    // wsu_e() on every value, every time. In your application this came from
    // Banner, and Banner is not your friend.
    echo '<tr>'
        .'<th scope="row">'.wsu_e($course['number']).'</th>'
        .'<td>'.wsu_e($course['title']).'</td>'
        .'<td>'.wsu_e($course['credits']).'</td>'
        .'<td>'.wsu_badge($course['status'], ['variant' => $course['variant']]).'</td>'
        .'</tr>';
}

echo wsu_table_end();
echo wsu_shell_end();

Forms

wsu_field() derives the id from the name, aria-describedby from the hint and the error, and aria-invalid from whether there is an error. You give it the facts and it does the wiring.

php
echo wsu_card_start(['title' => 'Request an override']);

echo wsu_field([
    'name'         => 'course',
    'label'        => 'Course number',
    'required'     => true,
    'value'        => $old['course'] ?? '',
    'autocomplete' => 'off',
]);

echo wsu_field([
    'name'     => 'reason',
    'type'     => 'textarea',
    'label'    => 'Reason',
    'required' => true,
    'error'    => $errors['reason'] ?? null,
]);

echo wsu_field([
    'name'    => 'term',
    'type'    => 'select',
    'label'   => 'Term',
    'value'   => 'winter-2027',
    'options' => [
        'winter-2027' => 'Winter 2027',
        'fall-2027'   => 'Fall 2027',
    ],
]);

echo wsu_field([
    'name'     => 'advisor',
    'type'     => 'checkbox',
    'label'    => 'I have spoken with my advisor',
    'required' => true,
    'hint'     => 'Required before you can register for more than 12 credits.',
]);

echo wsu_field([
    'name'     => 'delivery',
    'type'     => 'radio',
    'label'    => 'Delivery',
    'required' => true,
    'value'    => $old['delivery'] ?? '',
    'options'  => [
        'in-person' => 'In person',
        'online'    => 'Online',
        'hybrid'    => 'Hybrid',
    ],
    'error'    => $errors['delivery'] ?? null,
]);

echo wsu_card_end([
    'footer_html' =>
        wsu_button(['variant' => 'primary', 'type' => 'submit', 'label' => 'Submit request'])
        .wsu_button(['variant' => 'ghost', 'label' => 'Cancel']),
]);

5. The function list

Set up and configuration:

FunctionReturns
wsu_configure(array $options)Sets defaults for every later call on this request
wsu_option(string $key, $default = null)Reads one resolved option
wsu_reset()Back to the built-in defaults. Useful in tests

Page structure:

FunctionReturns
wsu_shell_start(array $opts)Doctype through the opening of <main>
wsu_shell_end()Closing </main>, the footer and the closing tags
wsu_skip_link(?string $target, string $label)The bypass link
wsu_app_bar(array $opts)The application bar on its own
wsu_sidebar(array $opts)The drawer and its navigation
wsu_breadcrumbs(?array $crumbs, string $label)The trail
wsu_page_header(array $opts)The h1 block
wsu_footer(array $opts)The compact footer
wsu_masthead(array $opts) / wsu_university_footer(array $opts)The university chrome. Official by default, 'variant' => 'native' for WayneUI's own
wsu_env_ribbon(array $opts)The non-production strip
wsu_theme_script(?string $nonce = null)The pre-paint script, if you own your own <head>

Components:

FunctionReturns
wsu_button(array $opts)A button, or an anchor when url is set
wsu_badge(string $label, array $opts)A status pill
wsu_alert(array $opts)A status message with its icon and role
wsu_stat(string $label, $value, array $opts)A figure with its label
wsu_card_start(array $opts) / wsu_card_end(array $opts)A bounded region
wsu_table_start(string $caption, array $opts) / wsu_table_end(array $opts)A scrollable, named table region
wsu_field(array $opts)Input, textarea, select, checkbox or radio, fully wired
wsu_pagination(array $links, array $opts)Page links
wsu_empty(array $opts)The nothing-here state
wsu_icon(string $name, array $attributes)One sprite reference
wsu_avatar(string $name, array $opts)A person, as a photo or initials
wsu_status_dot(string $label, array $opts)A colored dot with its label
wsu_kbd(string $label)A literal keyboard key or shortcut
wsu_divider(string $label, array $opts)A labeled rule between blocks of content
wsu_progress(string $label, ?float $value, array $opts)A determinate, or indeterminate, progress bar
wsu_loading(string $label, array $opts)An in-progress marker with its label
wsu_skeleton(array $opts)A loading placeholder shape
wsu_timeline(array $items)A sequence of dated events, read as one history
wsu_accordion(array $items)A set of disclosures, each a native <details>
wsu_tabs(array $items, array $opts)Tabs: real links by default, or a scripted widget with js
wsu_carousel(array $slides, string $label)Slides moved between with Previous, Next or a row of dots
wsu_chat(array $items, string $label)A conversation transcript, read in the order it happened
wsu_toasts_start(array $opts) / wsu_toasts_end()The named region a wsu_toast() sits inside
wsu_toast(array $opts)One notification, meant to sit inside the region above
wsu_dialog_start(array $opts) / wsu_dialog_end(array $opts)A modal dialog, built on the native <dialog> element
wsu_error_summary(array $errors, array $opts)The list of failures at the top of a submitted form
wsu_countdown(string $target, string $label, array $opts)A count to a fixed moment, with a pause control
wsu_text_rotate(array $items, string $label, array $opts)A rotating announcement strip, with a pause control
wsu_code_block(string $code, array $opts)A block of code with a button that copies it
wsu_terminal(array $lines, array $opts)A recorded terminal session, not code to copy
wsu_browser_start(array $opts) / wsu_browser_end()A browser window frame around whatever comes between the two calls
wsu_window_start(array $opts) / wsu_window_end()A generic window frame around whatever comes between the two calls
wsu_phone_start(array $opts) / wsu_phone_end()A phone body frame around whatever comes between the two calls

wsu_error_summary() takes focus by default and renders no role, because a freshly focused element and a role="alert" on that same element announce the same content twice. Pass 'autofocus' => false for the rare page that moves focus elsewhere on purpose: the summary then renders role="alert" instead, so it is still announced, and automatically carries data-wsu-error-summary="component", which is what stops the shared runtime from also trying to focus it. Combining role="alert" with the runtime's own focus call is the exact defect this replaces, so the option turns both on together rather than leaving the second for a caller to remember.

Helpers, if you are writing markup yourself:

FunctionReturns
wsu_e($value)htmlspecialchars with the settings the rest of the file uses
wsu_url(?string $url, string $fallback = '#')A URL safe to put in an href
wsu_url_attr(string $name, ?string $url, string $fallback = '#')One finished name="..." attribute, escaped once
wsu_attrs(array $attributes)An attribute string, escaped
wsu_classes(array $classes)A class string, from a list or a condition map
wsu_asset(string $path, ?array $opts)A URL under your configured assets folder

wsu_asset()'s $opts is the resolved options array it reads assets from. Every call site above leaves it out and gets the request's own configuration, set once by wsu_shell_start() or wsu_configure(); pass an array only to resolve a path against a different configuration than the one already in effect for this request.

6. The public template

php
echo wsu_shell_start([
    'template' => 'public',
    'app_name' => 'Course Catalog',
    'assets'   => '/assets/wayne-ui',
]);

That one key switches the chrome, the stylesheet and the favicon together, so the three cannot end up inconsistent. You get the official wayne.edu masthead above your bar and the university footer below it, read at render time from official-header.html and official-footer.html in your assets folder. They are never copied into a template, because that is exactly how 1.x's masthead went nine years out of date without anyone noticing.

Those two files, plus wayne-ui-official.css, have to already be on disk under your assets folder before you render this template; fetch them with the block in step 1. There is no CDN fallback the way there is for the stylesheet and the sprite, because wsu_masthead() and wsu_university_footer() resolve them against your document root, not against an absolute assets URL. Miss one and wsu_shell_start() throws a RuntimeException naming the file it could not find, rather than rendering a page with no university identity.

The native variant, if you are not using the official chrome

Both take 'variant' => 'native', which renders WayneUI's own masthead and university footer instead of the vendored fragments:

php
<?= wsu_masthead([
  'variant' => 'native',
  'search' => true,
  'links' => [
    ['label' => 'Admissions', 'href' => 'https://wayne.edu/admissions'],
  ],
]) ?>

Nothing to fetch and no RuntimeException to avoid, since there is no vendored file behind it. This is the same markup the Blade and Vue paths emit for Masthead and UniversityFooter, so a page that uses it agrees with the other three paths rather than with the two that render the official fragment.

Note the landmark difference, which is deliberate on both sides. The official fragment is a div and carries no landmark, because on a public page the app bar below it is the banner and a page may only have one. The native variant carries role="region" with a label, because a wordmark linking home is not a navigation menu, and without a landmark it would be unreachable to anyone moving by region.

Where to look next

A complete working application is in the repository at apps/examples/raw-php:

bash
php -S localhost:8000 -t apps/examples/raw-php

index.php is the internal template and public.php is the public one. Both are the static HTML examples generated rather than typed, and php/tests/StandaloneTest.php renders them and checks the output against contract/markup-contract.json. If the templates change, that test fails until this path changes with them.