CSS, JavaScript & images
Ship precompiled CSS, JS, fonts, and images under assets/, reference them with themeAsset(), and stay inside the docroot security allowlist.
On this page
CSS, JavaScript & images#
Your theme's Blade views need styling, scripts, fonts, and images. Because an uploaded theme runs on a customer's server with no build step available at runtime, everything you ship must already be compiled. This article covers where those files live, how you reference them, how Core publishes them into the docroot, and the security allowlist that decides what is allowed through.
The short version: put finished, plain files under assets/, and reference them with themeAsset().
Where assets live#
Everything a browser downloads goes under assets/ at the root of your theme directory. A typical layout:
resources/views/themes/acme/
assets/
css/
theme.css
js/
theme.js
fonts/
inter.woff2
img/
hero.svgThe reference salieno theme uses exactly this shape — its files live at assets/css/salieno.css and assets/js/salieno.js, assets/js/order.js, assets/js/bootstrap.js. Group by type or by feature; the structure is yours. Only the top-level assets/ name is fixed, because that is the directory Core publishes.
Referencing an asset: themeAsset()#
Never hardcode a URL. Use the themeAsset() helper, which turns a path relative to your assets/ directory into a real public URL for the active theme:
<link rel="stylesheet" href="{{ themeAsset('css/theme.css') }}">
<script src="{{ themeAsset('js/theme.js') }}" defer></script>
<img src="{{ themeAsset('img/hero.svg') }}" alt="">themeAsset('css/theme.css') expands to assets/themes/{active-theme}/css/theme.css and runs it through Laravel's asset() so it becomes an absolute URL on the correct host. You write the path once, relative to your own assets/ folder, and it resolves correctly no matter what the theme is named or where the install is hosted. See Helper & template reference for the full helper list.
The ?v=mtime cache-buster#
themeAsset() appends a query string like ?v=1723489200 to every URL. That number is the published file's modification time (filemtime). It exists because uploaded themes ship precompiled assets that are not content-fingerprinted the way a Vite build is — the file is always called theme.css, so without a changing URL, Cloudflare and browsers would serve a stale copy for up to 30 days after you push an update.
Because the suffix is the mtime of the published file, republishing a changed theme.css produces a new URL automatically and the cache is bypassed. You do nothing; just always go through themeAsset() and never write the raw /assets/themes/... path yourself, or you lose the buster.
Do NOT use @vite or runtime Tailwind#
The reference theme's layouts/app.blade.php contains an @vite([...]) call. Do not copy that into an uploaded theme. @vite works there only because salieno is the first-party theme bundled inside the application, built by the app's own toolchain. An uploaded theme has no Vite manifest, no dev server, and no build step at request time — @vite would throw.
For the same reason, your shipped CSS must be plain, final CSS. Do not ship a stylesheet that relies on runtime @tailwind, @apply, or any other directive that needs a compiler. Those are authoring-time constructs. If the browser receives @apply, nothing happens — it is not valid CSS.
So the rule is simple:
- Bundled theme (Core's own):
@vite,@tailwind,@apply— compiled by the app. - Uploaded theme (yours): precompiled
.cssand.jsonly, linked withthemeAsset().
Building Tailwind at authoring time#
You can absolutely use Tailwind — you just compile it yourself, on your machine, before packaging. The workflow:
- Keep a source stylesheet in your dev project (not shipped) with
@tailwind base; @tailwind components; @tailwind utilities;and any@applyrules. - Point your Tailwind
contentglob at your theme's Blade views so the scanner keeps the classes you actually use. - Run the Tailwind CLI to produce one finished, minified file:
npx tailwindcss -i ./src/theme.src.css -o ./assets/css/theme.css --minify- Ship the compiled
assets/css/theme.css. The@tailwind/@applydirectives are gone from the output — it is plain CSS.
Do the same for JavaScript: bundle and minify with your tool of choice (esbuild, Rollup, whatever), and ship the finished .js. Colour tokens and dark-mode handling are covered in Colours, dark mode & the mode switcher.
How publishing works#
On activation (and on install/update), App\Services\ThemeService::publishAssets() copies your theme's assets/ directory to public/assets/themes/{name}/ in the web docroot. That published copy is what themeAsset() points at and what the browser downloads. Your Blade views stay in resources/views/themes/{name}/; only the assets/ tree is copied out.
Publishing also copies a preview image if present — the first of preview.jpg, preview.png, or preview.webp found at your theme root — for the admin theme picker.
A few consequences worth knowing:
- Publishing is idempotent — it overwrites on every republish, so a changed file lands and its mtime (and therefore its
?v=URL) updates. - Publishing is best-effort and never blocks activation. If the docroot is not writable it is logged and skipped, not fatal — so an admin is never trapped on a broken theme. During local development, if assets do not appear, check the logs and directory permissions.
The security allowlist#
Core does not copy your assets/ directory wholesale. It publishes one file at a time through an allowlist, because the docroot is a place where the wrong file type becomes remote code execution. Anything not on the list is silently skipped and logged.
Allowed extensions (ThemeService::$allowedExtensions):
| Category | Extensions |
|---|---|
| Data / code | json css js mjs map |
| Images | jpg jpeg png svg webp gif ico avif |
| Fonts | woff woff2 ttf otf eot |
| Text | md txt html |
What is refused, and why:
- Standalone `.php` files are rejected. A
.phpin the docroot executes..blade.phpis allowed as a view (Blade templates are the core of a theme and are never served directly), but a bare.phpunderassets/is treated as an RCE risk and dropped. - Dotfiles are refused — anything whose path has a segment starting with
.:.htaccess,.user.ini,.git,.env. This matters most for.htaccess: on Apache/LiteSpeed a.htaccessone directory deeper can override the parent rule that keepsassets/non-executable, so a theme is never allowed to place one. - Symlinks are never followed — a symlink could point anywhere on the host, so it is skipped at publish time.
- Path traversal and absolute paths are rejected at upload — a zip entry containing
..or starting with/(zip-slip) is refused before anything is written.
The takeaway for you as an author: keep assets/ to genuine web assets. If you want to bundle a licence or notes, use .txt, .md, or an extensionless file (those are allowed). Do not rely on a .htaccess, a config .php, or a symlink — they will not survive publishing.
Fonts#
Ship font files under assets/fonts/ and reference them from your compiled CSS with a relative url() so they resolve inside the published tree:
@font-face {
font-family: "Inter";
src: url("../fonts/inter.woff2") format("woff2");
font-weight: 400;
font-display: swap;
}Because both the CSS and the fonts publish under the same assets/themes/{name}/ root, the relative path is stable. You may also load a web font from a provider in your layout <head>, but self-hosting keeps the theme self-contained and avoids an external request.
Next#
With assets in place, move on to Building sections — the sections.json manifest, the render pipeline, and how admin-edited content flows into your section blades.