Theming Light Store
A guide on building your own theme for Light Store.
Overview
Light Store renders its storefront through a theme. Themes are plain Blade views styled with Tailwind CSS, with a little TypeScript for the interactive parts.
A theme does not have to be complete. It extends another theme, usually the shipped default, and
only contains the files it changes: one Blade component, a stylesheet, a few settings. Everything
else comes from the theme it extends, so your theme keeps receiving fixes and new features from
default on every upgrade.
Where themes live
Themes live in two separate directories, and the difference between them matters as soon as you upgrade.
| Directory | Holds | Docker | On upgrade |
|---|---|---|---|
resources/themes/ | The shipped default theme | Baked into the image | Replaced every time |
themes/ | Your own themes | Bind-mounted from the host | Never touched |
Never edit the default theme in place
resources/themes/ comes out of the release image and is overwritten by every upgrade, so any change you make there
is lost the next time you update. Create your own theme with the command below and override the files you want to
change in it.
Your own themes sit in a top-level themes/ directory, which docker-compose.yml mounts as
./themes:/var/www/themes. Because it is a bind mount, it lives on your host machine and upgrades
leave it alone.
If a theme in themes/ has the same name as a shipped one, the user copy wins.
Creating a theme
Create a theme with the convenience command:
php artisan make:theme {theme-name}docker compose exec app php artisan make:theme {theme-name}It creates themes/{theme-name} holding nothing but a theme.json that extends default:
{
"name": "My Theme",
"extends": "default"
}Selected as it is, the new theme looks exactly like default. Pass --extends={theme} to extend
another theme instead, such as one of your own.
Theme names may only contain lowercase letters, digits, dashes and underscores, and have to start
with a letter or a digit. The name default is reserved.
Selecting your theme
In the admin panel, go to Settings, then the General tab, and open the Theme section. Pick your theme under Active Theme and save the settings. The dropdown lists shipped and user themes together.
Inheritance
The extends key of theme.json names the theme's parent. A parent can extend a theme of its own,
so a chain such as my-store extends my-base extends default works too. Whatever a theme does
not define is taken from the nearest theme up that chain that does:
| What | Resolved by |
|---|---|
| Views | Path. views/products/show.blade.php in your theme replaces the parent's file of that name. |
css/theme.css | The nearest theme shipping the file. Import the parent's to extend it rather than replace it. |
js/app.ts | Same as the stylesheet. |
settings, sections | Merged key by key from the root theme down, so a theme lists only what it adds or changes. |
| Everything else | Read from the theme's own theme.json only, such as name and description. |
The chain stops at a parent that does not exist, and at a theme that already appeared in it, so a
missing or circular extends never breaks the store.
What is in a theme
A new theme only has theme.json. Every other file is optional and overrides the matching file of
the parent:
The shipped default theme is the reference for every file you can override. Its views/
directory is organised like this:
| Directory | Holds |
|---|---|
layouts/ | app for the storefront, auth for login and registration, profile for the account area |
welcome.blade.php, ... | Pages: landing, cart, reviews, vendor, thank-you and custom pages |
products/, profile/, auth/ | Pages of the catalog, the product page, the account area and authentication |
components/<area>/ | The pieces each page is composed of, grouped by area |
components/ | Shared building blocks such as button, card, input and product-card |
shared/, partials/ | The navigation, footer, cookie consent, head and other includes |
Overriding a view
To change one part of the storefront, find the file that renders it under
resources/themes/default/views/, copy that single file into your theme at the same relative path,
and edit the copy. Your theme now uses its own version of that file, and still takes every other
file from default.
Pages are thin compositions of components, so the file to override is usually small. Components
live in views/components/<area>/ and are used as <x-theme::area.piece>:
| Area | Covers |
|---|---|
landing | Hero, featured products, carousels, stats, reviews and FAQ |
catalog | Product listing filters, sorting and search |
product | Every section of the product page and its dialogs |
cart | Cart lines, totals, gateways and the added-to-cart modal |
profile | Account pages, licenses, orders, security and notifications |
nav | Navigation bar, catalog menu, search and mobile menu |
footer | Footer columns, social links, legal links and locale picker |
auth | Login, register and two-factor form pieces |
cookie-consent | The cookie notice and its preferences |
vendor | Store pages of a vendor |
To restyle the price on the product page, for example, copy
views/components/product/price.blade.php into your theme and edit it:
@props(['product', 'addonViews' => []])
<div class="mb-6 flex items-baseline gap-2">
<p class="text-3xl font-bold text-primary">{{ $product->formatted_price }}</p>
@foreach ($addonViews as $addonView)
@include($addonView->view, $addonView->data)
@endforeach
</div>Every component declares its inputs with @props and receives its data from the page that uses it.
Keep the same props when you override one, as the parent's pages still pass them. Inline scripts
live in their own *-script components, so you can change markup without touching the JavaScript
that drives it.
Keep the addon views
Some views render what addons add to the page, such as $addonViews above or
$storefront->cartItemViews($item). Keep those loops in your overrides, or addons stop showing
up in that part of your storefront.
Theme data
Theme views only render data; they never fetch it. Controllers hand each page ready-made values, and
every view also gets a $storefront object holding the data the layout needs. A view therefore
decides how things look and nothing else, which is what lets a theme override any file without
reimplementing logic.
Do not call models, services, AddonManager, request() or session() from a theme view. Reading
theme settings with r_theme(), the shared settings objects ($generalSettings and friends) and
route() is fine.
The storefront object
$storefront is available in every theme view. Its methods are cached per request, so calling
$storefront->cart() in both the desktop and the mobile navigation runs one query.
| Method | Returns |
|---|---|
brand() | Site name, logo URLs and whether the theme shows the logo and the name |
head() | Title, description, canonical URL, favicon, structured data, analytics, flash messages |
navigation() | The navbar links, as NavLink objects |
catalogMenu() | Categories for the catalog menu |
cart() | The cart summary shown in the navbar |
notifications() | The signed-in user's notification feed, or null |
searchQuery() | The current search term |
adminDashboardUrl(), creatorDashboardUrl() | Panel links for staff and creators, or null |
banner() | The top banner's text, icon and dismissal key |
footer(), legalLinks() | Footer columns, social links and legal links |
localePicker() | Languages and currencies the visitor can switch between |
cookieConsent() | Cookie notice categories, or null when the notice is off |
profileNavigation() | Account area tabs, grouped, addon tabs included |
authBackground() | The login and register background image and its overlay |
themeAsset($file) | Path of a theme asset, taken from the nearest theme that ships it |
themeFileUrl($setting) | URL of the image uploaded to a file theme setting |
responsiveImage($path) | srcset sources for an uploaded image |
productSidebarViews(), productActionViews(), productCardPriceViews() | Addon views for a product |
cartItemViews(), cartSidebarTopViews(), cartTotalViews(), cartSidebarViews() | Addon views for the cart |
licenseCardViews() | Addon views under each license on the licenses page |
The objects these return are small read-only classes from App\View\Data, read as properties:
@foreach ($storefront->navigation() as $link)
<a href="{{ $link->url }}" @class(['font-semibold' => $link->active])
@if ($link->external) target="_blank" @endif>
{{ $link->label }}
</a>
@endforeachPage data
Pages receive their own variables, prepared the same way. The product page gets $purchase,
$gallery, $optionGroups, $tabs and the rest of what it shows; the cart gets $lines as
CartLine objects and $checkout for the payment step; the catalog gets $filters, $heading
and $sortOptions. The fastest way to see what a page has to work with is to open the matching
file in resources/themes/default/views/ and follow the props it passes down to its components.
Stylesheets and scripts
css/theme.css is the Tailwind entry point of a theme, and js/app.ts its script. Both are
optional: a theme without them uses its parent's. Shipping your own replaces the parent's file, so
import the parent's to build on it instead. Every theme is available under the @themes/{name}
alias:
@import "@themes/default/css/theme.css";
@theme {
--font-display: ui-serif, Georgia, serif;
}import "@themes/default/js/app.ts";
document.addEventListener("DOMContentLoaded", () => {
console.log("My theme loaded");
});As long as your stylesheet imports default's, Tailwind finds the classes used in your own views
without any configuration. Utilities used only by addon views are added to every theme's stylesheet
at build time, whatever it imports. If you override
partials/head.blade.php, keep loading the assets through themeAsset(), which resolves the file
from the nearest theme that has it:
@vite($storefront->themeAsset('css/theme.css'))
@vite($storefront->themeAsset('js/app.ts'))Theme settings
A theme can expose its own settings, so the store owner can recolor it or change its wording
without touching any code. They are declared in theme.json:
{
"name": "My Theme",
"extends": "default",
"sections": {
"Promotions": {}
},
"settings": {
"primary_color": {
"default": "#0ea5e9"
},
"show_promo_strip": {
"type": "bool",
"label": "Show Promo Strip",
"description": "Show the promotional strip under the hero.",
"default": true,
"section": "Promotions"
},
"promo_text": {
"type": "html",
"label": "Promo Text",
"default": "Free updates for life",
"requiredWith": "show_promo_strip",
"section": "Promotions"
}
}
}Settings and sections merge with the parent's key by key, and so do the fields of each setting.
Above, primary_color keeps the label, type and section default gives it and only changes its
default, while the other two settings are new. Open resources/themes/default/theme.json to see
every setting you inherit.
Each setting takes a type, a label, a default and the section it is grouped under:
| Type | Editor | Value |
|---|---|---|
color | Color picker | A color string |
string | Text input | A string |
html | Rich text editor | An HTML string, printed with {!! !!} |
bool | Toggle | true or false |
number | Number input | An integer or a float |
select | Dropdown | One key of its options map of value to label |
icon | Icon grid | One Lucide icon name from its options list |
file | Upload | A stored path; read its URL with $storefront->themeFileUrl() |
Settings also accept:
| Key | Effect |
|---|---|
description | Help text under the field |
nullable | Lets the field be cleared, and gives an icon setting a "None" choice |
requiredWith | Name of a bool setting; this field must be filled while that toggle is on |
accept | Accepted file types of a file setting, image/* by default |
langKey | Makes the text translatable, see below |
Sections are declared separately and can set a columns count to control their layout.
Read the values back in your Blade templates with the r_theme() helper, which works like
Laravel's own config():
@if (r_theme('show_promo_strip'))
<div class="bg-primary/10 py-2 text-center text-sm">{!! r_theme('promo_text') !!}</div>
@endifSaved values live in the theme_settings table, keyed by theme, and theme.json supplies the
default whenever a setting has never been saved. Values are saved per theme, so a newly selected
theme starts from its defaults rather than the values saved for its parent.
Colors
The palette settings are output as CSS custom properties by partials/theme-colors, which both
layouts include: --color-primary, --color-secondary, --color-base-100, --color-base-200,
--color-base-300 and --color-base-content, with separate values for light and dark mode.
Tailwind classes such as bg-primary and text-base-content read them directly, so recoloring a
theme only takes new defaults for those settings.
Translatable text
A string or html setting with a langKey edits a line of the store's language files instead of
a row in theme_settings, so it can say something different in every language. The theme manager
shows a language picker above it, and saved text is stored as a translation override, the same as an
edit on the Translations page. Read it with __() and the key, not with r_theme():
"headline": {
"type": "html",
"label": "Hero Headline",
"default": "Welcome to the store",
"langKey": "app.hero_headline",
"section": "Hero"
}<h1>{!! __('app.hero_headline') !!}</h1>Until it is edited, the storefront shows whatever the language files hold for that key, and the
default only fills the editor. Point langKey at a key the store's language files already define,
which in practice means overriding the label, section or default of one of default's translatable
settings rather than adding your own.
The theme manager
Settings are edited in the theme manager, a dedicated page at /admin/theme-manager. You can reach
it from the Theme Manager button in the header of the Settings page. It shows every setting the
active theme declares or inherits, grouped into their sections, next to a live preview.
The preview renders the real storefront with your unsaved values applied, and keeps them applied as you navigate inside it, so every setting previews without any code in your theme. Changes are kept as a draft only you can see until you Publish them, or throw them away with Discard changes. Drafts survive closing the tab.
Editing locally
If you run the store locally for development, edit your theme directly at themes/{theme-name}.
Running composer run dev starts the Vite dev server alongside the app, which gives you
hot-module reloading so changes appear as soon as you save.
Editing on a server
If your store is hosted remotely, connect an editor over SSH.
Because themes/ is a bind mount, the files are on the host machine rather than inside the container. You can edit
them straight from the host, and the running container sees the change.
Using VS Code
- Follow the steps for a remote SSH VS Code instance: https://code.visualstudio.com/docs/remote/ssh.
- Once connected, open your theme at
{your-store-instance}/themes/{theme-name}.{your-store-instance}will likely be/var/www/storeor~/store, depending on where you put the app when you set up hosting.
- Edit your theme files.
- Rebuild the assets to see the change, using the commands in the next section.
Using PhpStorm or other JetBrains IDEs
- Open your remote project at
{your-store-instance}/themes/{theme-name}over SSH: https://www.jetbrains.com/help/phpstorm/remote-development-starting-page.html. - Edit your theme files.
- Rebuild the assets to see the change.
Rebuilding assets
Blade changes show up on the next page load, but any change to a theme's CSS or TypeScript, and any Tailwind class used in a view for the first time, needs the assets rebuilt.
pnpm run builddocker compose up -d --buildThe app container ships no Node.js. Assets are built in a separate stage while the image is built,
so rebuilding the image is what picks up theme changes. Your themes/ directory is part of the
build context, so your own themes are compiled alongside the shipped one.
Upgrading
Upgrades replace resources/themes/ wholesale and leave themes/ untouched. A theme extending
default picks up every change to the files it does not override automatically. The files it does
override stay as you left them, so after an upgrade, compare each of them with the new version in
resources/themes/default/views/ and port across anything you want. The release changelog lists
every change to the theme API under Theme API Changes.
Themes from 26.x
On 26.x, make:theme copied the whole default theme, and theme views queried data themselves.
Neither works on 27.0, as the variables and components such a copy relies on have changed. The
quickest way to move a 26.x theme across is to start over from inheritance:
- Rename your old theme directory, for example to
themes/my-theme-26. - Run
php artisan make:theme my-theme, which creates a theme extending the newdefault. - Add your
theme.jsonsettings, without the onesdefaultalready declares unless you change them. - For each view you had changed, copy the matching file from the new
resources/themes/default/views/into the new theme and redo your edit there. Most page sections are components underviews/components/<area>/now, so the file to copy is usually a component rather than the page. - If you changed the stylesheet or the script, recreate only your changes in
css/theme.cssorjs/app.tsand import the parent's file at the top.
A few changes to keep in mind while porting:
- Read layout data from
$storefront.$links,$cart,$categories,$currencies,$languagesand the other layout variables are gone, and nav links are objects ($link->label,$link->url,$link->active) instead of arrays. - Render addon views from the lists you are given, such as
$storefront->productSidebarViews($product), instead of callingAddonManager. - Remove any
@importofaddon-safelist.cssand your copy of the file. The safelist is now added to every theme at build time. - Remove the theme manager's preview code: the
messagelistener inlayouts/app.blade.phpand everydata-theme-*anddata-variant-*attribute. The preview no longer needs them.