Skip to main content
Wintertrace
Browse the documentation

Building modules

Assets & frontend

Ship CSS and JavaScript from a module and have the core load them: where assets live, how they are published, and how they are referenced.

A module can bring its own CSS and JavaScript. The application loads these files automatically during the boot process, with no core files to touch. This page traces the path from a compiled file to the <link> or <script> tag in the finished HTML — and explains why most modules can get by without any custom CSS at all.

How module assets are loaded

The process consists of five steps and runs automatically when the module boots:

  1. The module places compiled CSS/JS in its dist/ directory.
  2. A dist/manifest.json lists those files.
  3. The ModuleAssetRegistry reads the manifest during boot.
  4. A symlink is created: public/modules/{slug}/modules/{slug}/dist/.
  5. The @moduleAssets Blade directive outputs the <link> and <script> tags.

The reason for this symlink-and-manifest approach: asset files stay inside the module directory but become reachable under the public public/ path. Each module remains self-contained, and the core does not need to know which files a module brings — it reads them from the manifest.

Directory structure

modules/my-module/
  dist/
    manifest.json
    my-module-abc123.css
    my-module-abc123.js

manifest.json

The manifest is a plain list of files to serve:

[
    {"type": "css", "file": "my-module-abc123.css"},
    {"type": "js", "file": "my-module-abc123.js"}
]

Each entry needs exactly two fields:

  • type: either "css" or "js"
  • file: filename within the dist/ directory

The hash suffix in the filename (e.g. abc123) is recommended because it handles cache busting: when the content changes, the filename changes, and browsers fetch the new file rather than a stale cached version.

Asset URLs

Assets are served from /modules/{slug}/{file}:

/modules/my-module/my-module-abc123.css
/modules/my-module/my-module-abc123.js

Blade directives

@moduleAssets

This directive outputs all registered module CSS and JS. It is already included in the core layouts — you do not need to add it yourself.

The output it generates:

<link rel="stylesheet" href="/modules/my-module/my-module-abc123.css">
<script src="/modules/my-module/my-module-abc123.js" defer></script>

module_asset() helper

To reference an individual asset file within a view, module_asset() returns the correct path:

module_asset('my-module', 'my-module-abc123.css');
// Returns: '/modules/my-module/my-module-abc123.css'

@extensionSlot / @lifecycleFields

Two further core directives let modules inject markup into existing pages rather than ship a whole page of their own:

@extensionSlot('admin.dashboard.top')                 {{-- render a template slot --}}
@lifecycleFields(\App\Enums\LifecyclePoint::JobEnd)   {{-- render driver lifecycle fields --}}

@extensionSlot renders everything modules appended or replaced through the SlotRegistry (see Template slots). @lifecycleFields renders the form fields modules contributed through the LifecycleFieldRegistry for that lifecycle point (see Registries reference). The key thing to understand: both directives are already placed in the core layouts and forms. A module registers content for them — it never calls these directives itself.

Building assets

The core application uses Tailwind CSS. For your own assets there are three approaches — from simplest to most involved.

Option A: Plain CSS (simplest)

Write CSS in a .css file and place it in dist/. Nothing more is needed.

Option B: Build with Vite/Webpack

If your module has more complex frontend requirements, build assets with a bundler. The manifest is then generated by the build process:

modules/my-module/
  resources/
    css/
      module.css
    js/
      module.js
  vite.config.js   (or webpack.config.js)
  package.json
  dist/
    manifest.json  (auto-generated by build)
    my-module-abc123.css
    my-module-abc123.js

Option C: Tailwind classes

Because the core already loads Tailwind, a module’s Blade views can use Tailwind classes directly — with no extra CSS at all:

<div class="bg-white rounded-lg shadow p-4 mb-4">
    <h3 class="text-sm font-medium text-gray-500">{{ $label }}</h3>
    <p class="mt-2 text-lg">{{ $value }}</p>
</div>

For most module views this is the recommended approach: no custom CSS, no build step, no manifest required.

The ModuleManager creates the symlink automatically:

public/modules/my-module → modules/my-module/dist

You do not need to set it manually. The rules for symlinks are:

  • Created when a module is enabled or booted.
  • Removed when a module is disabled.
  • The parent directory public/modules/ is created automatically if it does not exist.

Example: widget view with Tailwind

A complete, runnable widget — built entirely with Tailwind classes, without a single line of custom CSS:

{{-- resources/views/widgets/status-card.blade.php --}}
<div class="bg-white rounded-lg shadow p-4">
    <div class="flex items-center gap-3">
        <div class="flex-shrink-0">
            <svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
            </svg>
        </div>
        <div>
            <h3 class="text-sm font-medium text-gray-500">{{ $label }}</h3>
            <p class="text-lg font-semibold text-gray-900">{{ $data['status'] ?? 'Unknown' }}</p>
        </div>
    </div>
</div>

How to register a widget like this on the dashboard is covered under Navigation & dashboard. The full API of the ModuleAssetRegistry and the other registries is documented under Registries.