Light Services

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.

DirectoryHoldsDockerOn upgrade
resources/themes/The shipped default themeBaked into the imageReplaced every time
themes/Your own themesBind-mounted from the hostNever 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:

themes/my-theme/theme.json
{
	"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:

WhatResolved by
ViewsPath. views/products/show.blade.php in your theme replaces the parent's file of that name.
css/theme.cssThe nearest theme shipping the file. Import the parent's to extend it rather than replace it.
js/app.tsSame as the stylesheet.
settings, sectionsMerged key by key from the root theme down, so a theme lists only what it adds or changes.
Everything elseRead 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:

theme.json
price.blade.php

The shipped default theme is the reference for every file you can override. Its views/ directory is organised like this:

DirectoryHolds
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>:

AreaCovers
landingHero, featured products, carousels, stats, reviews and FAQ
catalogProduct listing filters, sorting and search
productEvery section of the product page and its dialogs
cartCart lines, totals, gateways and the added-to-cart modal
profileAccount pages, licenses, orders, security and notifications
navNavigation bar, catalog menu, search and mobile menu
footerFooter columns, social links, legal links and locale picker
authLogin, register and two-factor form pieces
cookie-consentThe cookie notice and its preferences
vendorStore 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:

themes/my-theme/views/components/product/price.blade.php
@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.

MethodReturns
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>
@endforeach

Page 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:

themes/my-theme/css/theme.css
@import "@themes/default/css/theme.css";

@theme {
	--font-display: ui-serif, Georgia, serif;
}
themes/my-theme/js/app.ts
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:

themes/my-theme/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:

TypeEditorValue
colorColor pickerA color string
stringText inputA string
htmlRich text editorAn HTML string, printed with {!! !!}
boolToggletrue or false
numberNumber inputAn integer or a float
selectDropdownOne key of its options map of value to label
iconIcon gridOne Lucide icon name from its options list
fileUploadA stored path; read its URL with $storefront->themeFileUrl()

Settings also accept:

KeyEffect
descriptionHelp text under the field
nullableLets the field be cleared, and gives an icon setting a "None" choice
requiredWithName of a bool setting; this field must be filled while that toggle is on
acceptAccepted file types of a file setting, image/* by default
langKeyMakes 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>
@endif

Saved 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

  1. Follow the steps for a remote SSH VS Code instance: https://code.visualstudio.com/docs/remote/ssh.
  2. Once connected, open your theme at {your-store-instance}/themes/{theme-name}.
    • {your-store-instance} will likely be /var/www/store or ~/store, depending on where you put the app when you set up hosting.
  3. Edit your theme files.
  4. Rebuild the assets to see the change, using the commands in the next section.

Using PhpStorm or other JetBrains IDEs

  1. Open your remote project at {your-store-instance}/themes/{theme-name} over SSH: https://www.jetbrains.com/help/phpstorm/remote-development-starting-page.html.
  2. Edit your theme files.
  3. 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 build
docker compose up -d --build

The 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:

  1. Rename your old theme directory, for example to themes/my-theme-26.
  2. Run php artisan make:theme my-theme, which creates a theme extending the new default.
  3. Add your theme.json settings, without the ones default already declares unless you change them.
  4. 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 under views/components/<area>/ now, so the file to copy is usually a component rather than the page.
  5. If you changed the stylesheet or the script, recreate only your changes in css/theme.css or js/app.ts and 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, $languages and 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 calling AddonManager.
  • Remove any @import of addon-safelist.css and your copy of the file. The safelist is now added to every theme at build time.
  • Remove the theme manager's preview code: the message listener in layouts/app.blade.php and every data-theme-* and data-variant-* attribute. The preview no longer needs them.

On this page