Skip to content

Laravel and Blade

Laravel 11 or 12, PHP 8.2 or newer. Two commands to install, and one component wrapping your page.

A fresh laravel new ships vite.config.js, so the install command below turns on the Vite asset strategy for you automatically, and that means Node.js and npm are a prerequisite too, not just PHP. If you would rather have no build step at all, see Assets: the plain <link>/<script> strategy this package falls back to without a vite.config.js needs nothing beyond PHP and Composer, ever.

1. Install

bash
composer require waynestate/wayne-ui
php artisan wayne-ui:install

The install command asks two questions it cannot work out by looking at your application, daisyUI or shadcn-vue, and internal or public, then infers the rest and prints what it did. It is safe to run twice: every write is idempotent or asks first.

--stack=blade|inertia     detected from whether Inertia is installed
--ui=none|daisyui|shadcn  which component library, if any
--template=internal|public
--force                   overwrite files that already exist

What it does:

  • publishes config/wayne-ui.php
  • copies the stylesheet, theme runtime, sprite, marks and fonts into public/vendor/wayne-ui
  • adds the WayneUI import to resources/css/app.css if you have one
  • turns on the Vite asset strategy if you have a vite.config.js
  • prints the npm packages you still need, if any

If it did turn on the Vite strategy, the page you write in the next step will 500 with ViteManifestNotFoundException until you build:

bash
npm install
npm run build   # or npm run dev while you work

The command already told you which npm packages to add, in its own last line of output; install those first if npm run build complains about a missing module.

Why an install command

1.x asked you to run four vendor:publish calls with the right tags in the right order, add a stylesheet link by hand, and remember which of use-wsu-header and show-user-info had to agree with which. Every step was skippable and none of them failed loudly, so the usual outcome was a half-installed package that rendered a mostly-correct page with an unexplained blank where the header should be.

Nothing needs to be registered. The provider is discovered from composer.json, and config/wayne-ui.php is merged at register time, so config('wayne-ui.app_name') returns a working value whether or not you ever publish the file.

Publishing on its own

Every tag is prefixed, so --tag=wayne-ui-config publishes this package's config and not every other package's:

TagPublishes
wayne-ui-configconfig/wayne-ui.php
wayne-ui-assetspublic/vendor/wayne-ui
wayne-ui-viewsThe Blade components, into resources/views/vendor/wayne-ui

There is no tag for the Vue components. Install @waynestate/wayne-ui-vue from npm, the way Laravel, Inertia and Vue already shows. If you need to edit a chrome component in place rather than consume it as a dependency, the shadcn-vue registry copies the app shell, the app bar, the sidebar, the breadcrumbs, the page header, the footer and the environment ribbon into your repository as real source; the rest of the registry forwards to the same npm package rather than duplicating it.

2. Your first page

blade
{{-- resources/views/courses/index.blade.php --}}
<x-wsu::app-shell title="Select courses">
    <p>Your content.</p>
</x-wsu::app-shell>

That is the whole document, <html> to </html>: the pre-paint theme script, the viewport meta, the stylesheet, the skip link, the app bar, the sidebar, the breadcrumbs, the page header, <main id="main" tabindex="-1">, and the footer.

The shell owns the document rather than being a layout you extend. The <head> is where most of the accessibility and performance decisions live, and every one of them was something a 1.x consumer had to copy into their own layout and then keep in step with the package by hand. Owning the document means an application upgrades the chrome by upgrading the package.

An application that genuinely needs its own <html> can still compose the parts. <x-wsu::app-bar>, <x-wsu::sidebar> and the rest all work on their own.

The prefix is wsu, not wayne-ui. It is typed on every component in every template, and three characters everyone at the university already reads as "Wayne State" beats eight.

3. Configure the chrome

Most of what the bar and sidebar show comes from config/wayne-ui.php, so pages say nothing about navigation:

php
return [
    'template' => env('WAYNE_UI_TEMPLATE', 'internal'),

    'app_name' => null,   // falls back to config('app.name')
    'home'     => '/',

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

    'sidebar' => [
        'enabled' => true,
        'label'   => 'Sections',
        'items'   => [
            ['heading' => 'Registration', 'items' => [
                ['label' => 'Select courses', 'url' => '/register',
                 'route' => 'register.*', 'icon' => 'clipboard-list'],
                ['label' => 'My schedule', 'url' => '/schedule',
                 'route' => 'schedule.*', 'icon' => 'calendar'],
            ]],
        ],
    ],

    'search' => [
        'enabled' => true,
        'action'  => null,     // null submits to the current URL
        'name'    => 'q',
        'hotkey'  => true,     // Command or Control plus K
    ],

    'user' => [
        'enabled'    => true,
        // Tried in order; the first one present on the user model is shown.
        'attributes' => ['accessid', 'access_id', 'username', 'email', 'name'],
    ],
];

route is what decides which item is current. It takes a route name, a wildcard pattern such as courses.*, a path, or a list of any of those. Never a hand-maintained active flag.

The rendered attribute is aria-current="page" and the stylesheet keys off that attribute directly, so the highlighted item and the announced current page are one fact rather than two that can drift.

If you are writing your own navigation markup, the directive emits the whole attribute and emits nothing when the item is not current:

blade
<a href="/courses" @navActive('courses.*')>Courses</a>

4. Breadcrumbs, the heading and the title

Push crumbs in the controller. The layout says nothing about them.

php
use WSU\WayneUi\Facades\Breadcrumbs;

public function index()
{
    Breadcrumbs::push('Home', route('home'));
    Breadcrumbs::push('Registration', route('register.index'));
    Breadcrumbs::pushAsHeading('Select courses');

    return view('courses.index', ['courses' => Course::all()]);
}

pushAsHeading() sets the last crumb and the page <h1> at the same time, so the two cannot say different things. The document title is derived from the same trail.

The service is request-scoped and bound in the container, which is why it can be swapped and spied in tests:

php
Breadcrumbs::shouldReceive('push')->once();

If your page wants a heading the trail does not imply:

php
Breadcrumbs::setHeading('Select courses')
    ->setSubheading('Winter 2027, registration closes 8 November')
    ->setTitle('Course selection');

Rendering the trail does not consume it, so <x-wsu::breadcrumbs> and the page header can both read it.

Coming from 1.x

1.x's Breadcrumbs was a class of static properties with no reset, so under Octane or in a test suite the trail from request one was still there in request two. Its setTitle() never worked at all: getTitle() tested $title and then returned $subTitle, so setting a title made the browser tab title vanish. See Upgrading from 1.x.

5. Components

Everything is an anonymous Blade component under the wsu namespace. Each has its own page with the markup it renders, in Components.

blade
<x-wsu::app-shell title="Select courses">

    <x-slot:actions>
        <x-wsu::button variant="outline" icon="download">Export</x-wsu::button>
        <x-wsu::button variant="primary" icon="plus" href="/courses/new">Add course</x-wsu::button>
    </x-slot:actions>

    <x-wsu::alert variant="warning" title="Advising hold">
        Meet with an advisor before registering for more than 12 credits.
    </x-wsu::alert>

    <x-wsu::table caption="Available courses, Winter 2027">
        <x-slot:head>
            <tr>
                <th scope="col">Course</th>
                <th scope="col">Title</th>
                <th scope="col">Status</th>
            </tr>
        </x-slot:head>

        @foreach ($courses as $course)
            <tr>
                <th scope="row">{{ $course->number }}</th>
                <td>{{ $course->title }}</td>
                <td><x-wsu::badge :variant="$course->badge">{{ $course->status }}</x-wsu::badge></td>
            </tr>
        @endforeach
    </x-wsu::table>

</x-wsu::app-shell>

Forms

The form components read the validation error bag themselves, so the usual case needs no @error blocks:

blade
<form method="post" action="{{ route('overrides.store') }}">
    @csrf

    <x-wsu::form.error-summary />

    <x-wsu::card title="Request an override">
        <x-wsu::form.input name="course" label="Course number" required autocomplete="off" />
        <x-wsu::form.textarea name="reason" label="Reason" required />
        <x-wsu::form.select name="term" label="Term" :options="$terms" placeholder="Choose a term" />
        <x-wsu::form.checkbox name="advisor" label="I have spoken with my advisor" />

        <x-slot:footer>
            <x-wsu::button variant="primary" type="submit">Submit request</x-wsu::button>
            <x-wsu::button variant="ghost" href="{{ url()->previous() }}">Cancel</x-wsu::button>
        </x-slot:footer>
    </x-wsu::card>
</form>

<x-wsu::form.error-summary /> renders nothing when the bag is empty. When it is not, it lists every failure with a link to the control that failed and takes focus, which is what gets the errors to a screen reader user who has just submitted a long form.

Buttons default to type="button". An unqualified <button> inside a form is a submit button, and every Cancel that silently submits the form it was meant to abandon is that defect. Set type="submit" deliberately.

6. The public template

php
// config/wayne-ui.php
'template' => 'public',

or per page:

blade
<x-wsu::app-shell template="public" title="Course Catalog">

That switches the chrome, the stylesheet and the favicon together. You get the official wayne.edu masthead above your bar and the university footer below it, read at render time from the HTML fragments in public/vendor/wayne-ui. They are never copied into a Blade view.

7. Assets

By default the shell links public/vendor/wayne-ui/wayne-ui.css and the theme runtime directly, which needs no build step at all.

Pointing at the CDN instead of vendor:publish

Every path under assets in config/wayne-ui.php accepts an absolute URL and is passed through untouched:

php
'assets' => [
    'css'      => 'https://wayneui.apps.wayne.edu/v2/wayne-ui.css',
    'theme_js' => 'https://wayneui.apps.wayne.edu/v2/wayne-ui-theme.js',
    'icons'    => 'https://wayneui.apps.wayne.edu/v2/icons.svg',
    'marks'    => 'https://wayneui.apps.wayne.edu/v2/marks',
],

That skips vendor:publish and the assets in public/vendor/wayne-ui entirely. The sprite also defaults to a sibling of wherever theme_js loads from, so if every one of these keys points at the same CDN base, the icon injector needs no extra configuration to find it.

Moving icons on its own is safe to do. <x-wsu::app-shell> writes data-wsu-sprite on <html> from Asset::sprite(), and Asset::icon() builds every <use href> through that same call, so the two resolve to the same URL whatever you move. Point icons at the CDN while css and theme_js stay on public/vendor/wayne-ui, and the icons still render, because the shell tells the runtime exactly where to look rather than letting it guess a sibling of theme_js.

Mind the order if a page also mounts a Vue component inside the Blade layout. The Vue plugin's own sprite option writes data-wsu-sprite too, but only if <html> does not already have it. On a page rendered by <x-wsu::app-shell>, the shell has already set the attribute before Vue mounts, so the shell's value wins silently even if the plugin was configured with a different sprite. That is existing plugin behavior, not a new defect, but it is only worth knowing now that the shell has an opinion to disagree with.

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

Your own Content-Security-Policy needs the CDN host on style-src, script-src, font-src, img-src and connect-src. connect-src is easy to miss: wayne-ui-theme.js fetches the sprite with fetch(), and a policy with no connect-src of its own falls back to default-src, which blocks that request even though the <link> and <script> tags load fine.

img-src is easy to miss too. <x-wsu::app-shell> renders the favicon, the shield mark in the app bar (app-bar.blade.php), and the compact footer's Warrior Strong mark (footer.blade.php) as real <img> tags, and the public template adds the wordmark in the official masthead and the university footer on top of those. A policy with no img-src of its own falls back to default-src, same as connect-src, and every one of those marks comes back broken 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. <x-wsu::app-shell> 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. A Laravel application already has a per-response nonce facility built for exactly this, Illuminate\Support\Facades\Vite, so this is not a value <x-wsu::app-shell> needs you to pass in: call Vite::useCspNonce() once, put the value it returns on the header, and the shell picks the same value back up on its own through Vite::cspNonce(), because both read the one nonce the facade is holding for the length of the request.

php
// app/Http/Middleware/SetContentSecurityPolicy.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;

class SetContentSecurityPolicy
{
    public function handle(Request $request, Closure $next): Response
    {
        // One value per response. Vite::useCspNonce() mints it with
        // Str::random(40) and holds it for the rest of the request; calling it
        // more than once, or hardcoding a value, is not a nonce and protects
        // nothing 'unsafe-inline' would not.
        $nonce = Vite::useCspNonce();

        $response = $next($request);

        $response->headers->set('Content-Security-Policy',
            "default-src 'self'; "
            ."style-src 'self' https://wayneui.apps.wayne.edu; "
            ."style-src-attr 'unsafe-inline'; "
            ."style-src-elem 'self' 'unsafe-inline' https://wayneui.apps.wayne.edu; "
            ."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;"
        );

        return $response;
    }
}
php
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->append(\App\Http\Middleware\SetContentSecurityPolicy::class);
})

Nothing else on the page needs the nonce. @vite() reads the same Vite::cspNonce() and puts it on every tag it generates on its own; 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 a host expression cannot cover, and <x-wsu::app-shell>'s inline theme script is the only one this package writes.

Two things belong to style-src alone on this path, and neither is optional. style-src-attr 'unsafe-inline' covers an inline style="..." attribute, the pattern the reference application at apps/examples/laravel-blade/resources/views/registration.blade.php uses throughout for spacing between blocks. Without it, the browser accepts the attribute into the DOM and silently refuses to apply it: no broken image, no console error, only a margin that measures 0px where the markup plainly says otherwise.

style-src-elem 'self' 'unsafe-inline' covers something different: a page's own <style> block, written into <x-slot:head> to arrange that page's own layout against the semantic tokens, exactly the pattern registration.blade.php and catalog.blade.php both use for their .summary and .controls grids. A <style> element and a style attribute are governed by two separate directives, so permitting one does not permit the other, and either falls back to style-src itself when its own directive is absent, which is why the plain style-src line above carries no 'unsafe-inline' of its own: broadening it would also allow an injected stylesheet from anywhere style-src's host list reaches, which is not what either of these narrower allowances is for. 'self' stays on style-src-elem too, alongside 'unsafe-inline', because declaring the directive at all replaces the fallback to style-src rather than adding to it, and the published stylesheet <link> is itself an element style-src-elem now governs.

Running behind Laravel Octane? Add Vite::class to flush in config/octane.php. Its nonce is held in a container singleton for the length of one request under php artisan serve or PHP-FPM, but an Octane worker survives past the response, so a request that never called Vite::useCspNonce() would otherwise inherit the nonce, and the header, from whichever request ran on that worker before it.

If you have Vite, wayne-ui:install turns on the Vite strategy instead:

php
'vite' => [
    'enabled' => true,
    'entries' => ['resources/css/app.css', 'resources/js/app.js'],
],

and adds the import to resources/css/app.css:

css
@import "tailwindcss";
@plugin "daisyui" { themes: wsu --default, wsu-dark --prefersdark; }
@import "@waynestate/wayne-ui-css";

Then the stylesheet is fingerprinted and hot module reload works. See Theming for what the daisyUI and shadcn-vue lines actually do.

8. The environment ribbon

php
'environment' => [
    'ribbon'     => true,
    'production' => ['production', 'prod', 'live'],
],

Anything not on that list gets a strip across the top saying which environment it is. It renders nothing in production, so it can sit in the layout permanently.

1.x signaled this by turning the whole navigation bar maroon, which is information carried by color alone. The color is still there as the familiar cue, but the strip also says what it means.

Check it before you ship it

  • php artisan route:list and open three pages. Tab through each one.
  • Narrow to 320px and zoom to 400%. The page body must not scroll sideways.
  • Submit a form with everything empty. The summary should appear, take focus, and link to each field.
  • Read one page in dark.

Next