UnifyCart Developers

Building a Theme

Directory layout, the manifest, template resolution, every variable and function available, and what you must handle.

A theme is a directory of Twig templates plus a manifest describing what a merchant can configure. It controls how the shop looks and nothing about how it behaves.

Directory Layout

my-theme/
  theme.json             the manifest
  screenshot.jpg         what the theme chooser shows
  home.twig              the homepage
  layouts/
    app.twig             wraps nearly everything
    checkout.twig        wraps the checkout only
  shop/
    index.twig           listings, categories, search results
    show.twig            a product
    brands.twig          the list of brands
    brand-show.twig      one brand
    tag.twig             one tag
    compare.twig         product comparison
  blog/
    index.twig           the article listing
    category.twig        one category
    show.twig            an article
    _list-list.twig      the listing as rows
    _list-grid.twig      the listing as tiles, optional
  cart/
    index.twig           the basket, optional
    _recommendations.twig  the Items You May Like strip, optional
  pages/
    default.twig         a content page
    sections.twig        a page the merchant built from sections
    landing.twig
    contact.twig
    faq.twig
  partials/
    _section-hero.twig   one per section you declare
    header.twig          your own includes
    footer.twig
  content/               default page content as HTML, optional

pages/sections.twig is what opts your theme into the Page Builder. Declare page sections without it and a merchant can build a layout that is then silently ignored.

partials/_section-{type}.twig is where a section's partial is looked for unless the manifest says otherwise, with underscores in the type written as hyphens. A section called feature_grid draws from partials/_section-feature-grid.twig.

blog/_list-{value}.twig is one per layout your blog offers. The list is the fallback, so build that one first.

cart/index.twig is optional, and the shared basket is used when you do not ship one. Ship it when the basket needs to look like the rest of your theme rather than like the platform's. cart/_recommendations.twig is the Items You May Like strip on its own, so you can restyle that without reimplementing the basket, the totals and the coupon box to reach it. How many products it suggests and how many to a row are settings rather than markup, so most themes never need to touch the file.

A theme whose pages are built from sections ships no content/ directory at all: the pages travel in the manifest as layouts. The default theme works this way from 1.1.0.

layouts/checkout.twig is separate on purpose. A checkout carrying full navigation, a search bar and a newsletter signup leaks shoppers at the worst possible moment.

Where the screenshot actually lives

Name it in the manifest with "screenshot": "screenshot.jpg". In an uploaded theme it travels in the ZIP; a platform theme serves it from public/themes/{slug}/. A JPEG, not a PNG: it is a photograph of a page, and the same shot is several times smaller.

Shoot the homepage at about 1280px wide. The card shows it small, and clicking it opens it full size, so it is worth taking at a size that rewards the click.

How a Template Is Found

The tenant's own copy of your theme first, then your theme, then the shared storefront fallback. Anything you leave out still works and simply will not match your design.

Which templates exist, and what each one receives, is in Template Reference.

The Manifest

{
  "name": "Minimal",
  "slug": "minimal",
  "version": "1.0.0",
  "author": "You",
  "description": "Clean, product-first design.",
  "screenshot": "screenshot.png",
  "supports": {
    "colors": true,
    "typography": true,
    "custom_css": true,
    "homepage_sections": true,
    "mega_menu": false,
    "dark_mode": false
  },
  "default_colors": {},
  "templates": ["default", "landing", "contact", "faq"],
  "settings": [],
  "sections": [],
  "pages": []
}

supports declares capabilities. Declare them honestly: claiming dark_mode without implementing it puts a switch in the merchant's admin that does nothing, and they will report it as a fault.

templates are the page layouts a merchant can choose for a content page, matching files in pages/.

pages can ship default content, so a new shop starts with an About and a Contact page rather than an empty menu.

Settings

Settings are grouped, and each field becomes a control in the merchant's theme settings.

{
  "label": "Product Grid",
  "fields": [
    {
      "key": "products_per_row_desktop",
      "type": "number",
      "label": "Products Per Row",
      "default": 4,
      "min": 2,
      "max": 6
    }
  ]
}

Field Types

Type Control
toggle On or off
text A single line
textarea Several lines
number A number, with min and max
range A slider
select A list of options
color A colour picker
image An upload
richtext A formatted editor
categories Pick categories from the shop
repeater A repeating group of fields

A field can carry description for help text, and can be shown conditionally on another field's value.

Reading a Setting

{% set perRow = themeConfig.products_per_row_desktop|default(4) %}

Always default in the template as well as the manifest

A setting you add in version 2 does not exist on a shop still holding version 1 settings until the merchant re-saves. Without |default() that is a blank page rather than a missing feature, and it happens to every merchant who has not opened theme settings since upgrading.

Groups Worth Having

The settings that merchants actually change, taken from a working theme: colours, typography, logo and favicon, hero content, header behaviour, products per row and per page, image aspect ratio, which shop filters appear, what is shown on a product page, and footer text.

Make your labels settings

A bookshop wants Publishers where a clothes shop wants Brands. A text setting costs you nothing and saves the merchant a support request they would otherwise send to you.

Sections

A section is a block of a page that a merchant can add, reorder and configure without touching your markup. You declare it in the manifest and write the partial; they decide where it goes and what it says.

There are no standard sections. Every theme brings its own, under its own names, and the merchant is only ever offered what the active theme declares. That is deliberate: it means a merchant can never build a page the theme has no way to draw.

{
  "type": "hero",
  "label": "Hero Banner",
  "description": "The full width band at the top.",
  "partial": "partials/_section-hero.twig",
  "templates": ["home"],
  "default": true,
  "fields": [
    { "key": "title",    "type": "text",  "label": "Heading", "default": "Welcome" },
    { "key": "bg_image", "type": "image", "label": "Background" },
    { "key": "bg_color", "type": "color", "label": "Background Colour" }
  ]
}
Key What it does
type The identifier, unique within your theme. Required
label What the merchant sees in the section list
description Help text under the label
partial The template that draws it. Defaults to partials/_section-{type}.twig, with underscores as hyphens
templates Where it may be used: home, page, or both. Defaults to ["home"]
fields The settings, which become the form the merchant fills in
default Install it when the theme is activated, rather than only offering it
default_active Whether it starts switched on. Defaults to true
settings_from Map a field to a theme setting, to carry an existing value across

Declare `templates` even when it is the default

A section with no templates is treated as homepage only, because that is all sections were before pages could have them. Writing it out means the next person reading your manifest does not have to know that history.

The Fields

The same field types as a theme setting, plus form and products. Each becomes a control in the merchant's admin, generated from what you declare here, so the form and your partial can never drift apart.

Type Control
text A single line, with optional max_length
textarea Several lines, with optional rows
richtext A formatted editor
number A number, with min and max
toggle On or off
select A list of options
color A colour picker
image An upload
form Pick one of the shop's forms
products Pick products from the catalogue
categories Pick categories
repeater A repeating group, with its own nested fields and an add_label
blocks Sections inside this section, for a container such as a row

A field's default is written into the section when it is first created, which is what stops a freshly installed theme being a correctly ordered page of empty blocks.

Sections Inside Sections

A blocks field holds other sections, which is how the default theme's Columns section gives each column its own content. Nesting stops at one level: a section that declares a blocks field anywhere in its fields is a container, and containers are left out of the list offered inside one, so a row cannot be put inside a row.

Render a nested section with the same partial it would use at the top of the page:

{% for child in col.blocks|default([]) %}
    {% include 'themes/my-theme/' ~ section_partial(child.type)
        ignore missing with { section: child } %}
{% endfor %}

Do not build a responsive class from a value

lg:col-span-{{ n }} compiles and renders and does nothing, because the stylesheet build only ships classes it can see in your templates. Use a fixed map of whole class names, or emit the widths as CSS. The Columns section writes a grid-template-columns track list into a style block scoped to the row's id, which also lets it express proportions no twelfth grid can, such as 60/40.

The Partial

Your partial receives the section as section, with its settings on section.settings.

{% set s = section.settings|default({}) %}
{% set title = s.title is defined ? s.title : 'Welcome' %}

{% if title %}
  <section style="{{ section_style(s) }}">
    <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8" style="{{ section_box(s) }}">
      <h2>{{ title }}</h2>
    </div>
  </section>
{% endif %}

Three things in those six lines are worth more than they look, and each one is a defect the default theme shipped before it was fixed.

`|default` is not a fallback for an unset value

Twig's default filter fires on any empty value, and false and "" are both empty. A toggle the merchant switched off reads back as on. A heading they deliberately cleared comes back the next time the page renders.

Use ?? for a value that has never been set, and is defined where an empty answer is still an answer. A heading is the second kind: once the section has been saved the key is there, so an empty one is a decision.

Never build a style attribute inside an expression

{{ cond ? 'style="..."' : '' }} is escaped on the way out, so the browser receives style=&quot; and the rule is silently dropped. Write the attribute in the markup and put only its value through Twig, or use {% if %}style="..."{% endif %}.

section_style(s) returns the padding and background for a section, and section_box(s) returns where it sits across the page. Use both rather than writing your own, so that a tint chosen on one section comes out the same colour as a tint chosen on another. A page where each band interprets "tinted" slightly differently looks accidental, and the merchant cannot tell you why.

What Every Section Should Offer

A merchant moving between your sections should not have to learn each one. The default theme gives every section the same four controls, and generating them from the manifest costs you four field definitions:

Field Values What it does
width narrow, medium, wide, full 48rem, 64rem, 72rem, edge to edge. Also governs how wide the words run
background none, tinted, grey, custom Painted by section_style(), with bg_color for custom
space_above, space_below none, tight, normal, roomy Grouped under a group field labelled Spacing & Alignment
box_align left, center, right Where a section that is not full width sits. In the same group

Put the last three in a group so they read as one idea:

{
  "key": "spacing", "type": "group", "label": "Spacing & Alignment", "columns": 2,
  "fields": [
    { "key": "space_above", "type": "select", "label": "Space Above", "default": "normal",
      "options": { "none": "None", "tight": "Tight", "normal": "Normal", "roomy": "Roomy" } },
    { "key": "space_below", "type": "select", "label": "Space Below", "default": "normal",
      "options": { "none": "None", "tight": "Tight", "normal": "Normal", "roomy": "Roomy" } },
    { "key": "box_align", "type": "select", "label": "Alignment", "default": "center",
      "options": { "left": "Left", "center": "Centered", "right": "Right" } }
  ]
}

A section inside a column starts with no spacing and full width, because the row sets both. The merchant can still change them.

:::danger A control that does nothing is worse than no control Every one of these has to be read by your partial. The default theme shipped a Background select on its Call To Action that kept its value under a key nothing painted, so the control sat there doing nothing for weeks and nobody could say why their band had no colour. If you declare a field, render it. :::

Sizing and Breakpoints

Every section is handed a uid: its own id at the top of a page, and a stable one derived from where it sits when nested in a column. Use it to scope a style block to one section.

{% set id = 'mytheme-' ~ (section.uid|default('x')) %}

<style>
  #{{ id }} { display: grid; grid-template-columns: 1fr; }
  @media (min-width: 768px) { #{{ id }} { grid-template-columns: {{ a }}fr {{ b }}fr; } }
</style>

That is how the Columns and Image With Text sections take any proportion a merchant asks for. A track list of 60fr 40fr is exact, keeps the gap out of the arithmetic, and expresses shares no twelfth grid can.

Draw nothing rather than drawing an empty box

A testimonials section with no testimonials, or a blog row on a shop that has never posted, should render nothing at all. An empty bordered box with a heading in it reads as a fault, and you will be told about it.

Homepage Sections

Set "templates": ["home"] and order them in the manifest as you want them laid out. Which ones a new shop starts with is either the per-section default flag, or a homepage arrangement that also carries their settings, covered under Shipping Your Pages and Your Homepage. The merchant rearranges from there under Settings, Appearance, Homepage Builder.

Your home.twig walks the merchant's arrangement rather than hard-coding the order:

{% extends 'themes/my-theme/layouts/app.twig' %}

{% block content %}
  {% for section in sections %}
    {% include 'themes/my-theme/' ~ section_partial(section.type)
        ignore missing with { section: section } %}
  {% endfor %}
{% endblock %}

sections holds what the merchant has configured, in their order, already filtered to the ones that are switched on. Never hard-code a band into home.twig: a band the builder cannot see is one the merchant cannot move, hide or explain.

Four behaviours to design around:

Activation only fills gaps. Sections a merchant already has are never touched, so switching back to a theme they used before keeps the arrangement they left, and a shop part way through setting up does not lose its work.

The same type can appear more than once. A merchant can duplicate a featured row to show new arrivals and bestsellers separately, so write the partial as though there will be several and scope anything id-based with section.uid.

A section with nothing to show should draw nothing. The default theme's blog row renders only when a post exists, and featured products only when products match. An empty band with a heading reads as a fault, an absent one reads as a decision.

default_active: false installs a block switched off, which is how you ship something seasonal or optional without putting it on the page.

settings_from maps a field to a theme setting, for a theme moving a homepage that used to be driven entirely from Theme Settings. A shop's existing value then carries across and the page does not change on the day it moves.

Page Sections

Set "templates": ["page"] and ship a pages/sections.twig. The merchant then gets a Page Builder tab on each page where they build it from your sections.

{% extends 'themes/my-theme/layouts/app.twig' %}

{% block content %}
  {% for section in sections %}
    {% include 'themes/my-theme/' ~ section.partial ignore missing with { section: section } %}
  {% endfor %}
{% endblock %}

sections arrives in the merchant's order, each carrying its type, settings and resolved partial.

This is entirely optional. Declare no page sections and the Page Builder tab tells the merchant so, and every page uses the templates in your pages/ directory exactly as before. A theme can also do both: offer sections while keeping a hand written pages/contact.twig, which is used until a merchant adds a section to that page.

Ship `pages/sections.twig` if you declare page sections

Without it the merchant can build a layout that is then silently ignored, and the page falls back to pages/default.twig. Nothing errors, so nobody finds out until a merchant asks why their layout does nothing.

A page is either written or built, never both. Once a page has a layout the merchant's Content tab is switched off and page.content is not drawn, so a section should not try to output it. Words on a built page come from a text section your theme provides. The content itself is kept, and comes back if every section is removed.

Where a Merchant's Layout Lives

Not in your theme. A homepage arrangement and a page layout belong to the shop, stored in its own database, so an update to your theme never overwrites them and a shop's content is never inside a theme directory.

A page layout is a JSON document naming sections by id, with an order:

{
  "sections": {
    "intro": { "type": "rich_text",    "settings": { "heading": "Get in touch" } },
    "form":  { "type": "contact_form", "settings": { "form_slug": "contact" } }
  },
  "order": ["intro", "form"]
}

A section whose type your theme does not declare is skipped rather than rendered, so a layout left behind by another theme gives a shorter page, never a broken one.

Each theme keeps its own. A shop's homepage blocks, its theme settings and its built page layouts are all stored per theme. A shop switching from your theme to another and back finds its arrangement exactly as it left it, and a shop arriving at your theme for the first time is laid out from the homepage and pages your manifest ships. Nothing you declare can reach another theme's work, and nothing another theme stored can reach yours: a setting key means whatever your manifest says it means, even where another theme uses the same word for something else.

That is worth knowing because it was not always true. A theme measuring a hero in pixels was once handed "tall" by a theme that names its heights, and multiplied it.

Shipping Your Pages and Your Homepage

A theme can carry the shop it was designed around, so activating it reproduces that shop rather than leaving a merchant to rebuild it.

Content pages. Each entry in pages takes a content_file, a layout, or both:

{
    "slug": "returns",
    "title": "Returns & Refunds",
    "template": "default",
    "layout": {
        "sections": { "policy": { "type": "rich_text", "settings": { "heading": "Return Policy" } } },
        "order": ["policy"]
    }
}

A layout is the same document the Page Builder saves. Ship a layout and the page is built from sections; ship only HTML and it behaves as it always has. Shipping both installs words the Content tab then refuses to show, so pick one.

The homepage. A homepage array is the arrangement a shop starts with: an ordered list of blocks with the settings they were tuned to.

"homepage": [
    { "type": "hero", "is_active": true, "settings": { "heading": "..." } },
    { "type": "featured_products", "is_active": true, "settings": { "limit": 8 } }
]

Where it is present it replaces the per-section default flag, and unlike a list of flags it can place the same block twice. A block the theme no longer declares is skipped rather than installed as a row that renders nothing.

Nothing here overwrites a merchant's work. Importing fills in pages and blocks they do not have, and a shop that has arranged its own homepage keeps it.

Capturing it. Rather than writing all of that by hand, build the shop and read it back:

php artisan themes:capture my-theme --from=that-shop

It rewrites three things: each setting's default from the shop's values, the homepage arrangement, and each declared page's layout. It changes what a field starts at, never what fields exist, and it reports any page the shop has built that the theme does not declare instead of inventing an entry for it. Use --dry-run first.

Functions Available in Templates

Around forty five of them, covering money, the catalogue, content, the customer, assets and plugin hooks. They are listed in Functions Available in Templates.

Three are worth knowing before you start: money() for anything monetary, price() rather than product.price so group pricing is respected, and app_scripts() in your layout so apps a merchant installs are not silently broken.

Shared Components

Templates under components/storefront/ are shared across all themes: product badges, breadcrumbs, social icons, the order summary.

{% include 'components/storefront/product-badges.twig' with { product: product } %}

Include rather than reimplement. A platform fix, such as a new badge for pre-orders, reaches your theme without you touching it.

The Shop's Own Switches

A merchant pays for features and turns them on and off in their admin, not in your theme. Your templates have to ask, or a shop changing to your theme loses things it was using.

Setting What it governs
feature_reviews the reviews on a product
feature_product_questions the questions and answers
feature_wishlists saving a product for later
feature_compare setting products side by side
feature_social_sharing the share row
show_product_weight printing a weight
show_stock_quantity printing stock at all
hide_stock_from_public printing stock to anyone but a wholesale buyer

Read them with tenant_setting('feature_reviews', true).

Two of these are not presentation and are not yours to override. hide_stock_from_public is a commercial decision: a shop that shows its stock only to wholesale buyers must not have it drawn for everyone because a theme preferred a stock bar. A theme setting of your own may decide how a thing looks, and only ever narrows what the shop has already allowed:

{% if (themeConfig.show_stock_bar ?? true)
    and tenant_setting('show_stock_quantity', true)
    and (not tenant_setting('hide_stock_from_public', false) or is_wholesale_customer()) %}

The shop decides whether it may be shown and to whom. The theme decides whether it is a bar or a line of text.

The Platform's Credit

Every storefront carries a line of credit in its footer, and what it says is not the theme's decision. Include the shared component and place it; do not write the words yourself.

{% include 'components/storefront/powered-by.twig' ignore missing %}

It draws nothing at all for a shop on a plan that includes White Label, and it credits the reseller rather than the platform for a shop sold under a reseller's own brand. A theme that hard codes the line gets both of those wrong. If you need it to sit in a block of its own, guard the wrapper with platform_credit().show so an empty element is not left behind.

A Light and a Dark Page

A theme may offer the shopper a dark page. Declare it with "dark_mode": true in supports, and write your colours as two sets of the same variables, the second under :root.dark:

:root       { --x-ink: #3B3833; --x-paper: #FCFAF7; }
:root.dark  { --x-ink: #EDE8E0; --x-paper: #211F1C; }

Three things are worth knowing before you build it.

The merchant's palette belongs to the light page only. A shop picks its colours against white. Dropping those same values into a dark page gives grey on grey. Bind the shop's colours inside :root:not(.dark) and let the dark scheme be your own.

Set the class before the page paints. Applied by Alpine after load, a shopper who chose dark watches the shop flash white on every navigation, which is worse than not offering the choice. A short inline script in <head> that reads the stored preference and sets the class is the whole fix.

Anything whose colour is fixed must stay fixed. A button that sits on a photograph is white with dark text in both schemes. Keying it to your paper variable turns it into a dark pill with dark text the moment the shopper switches.

The preference is stored under unifycart_dark_mode, the same key the platform's own toggle uses, so a shop that draws that component elsewhere agrees with yours.

What You Must Handle

Most theme faults reported by merchants are one of these rather than a bug in logic.

A product with no image, in a tile and on its own page. A category with no description. A product with no variants, and one with twenty. An out of stock product, and one on pre-order. A long product name, in a tile, a heading and a breadcrumb. A basket with one item and with forty. A shop with three products and one with five thousand. Prices in another currency, and a shop whose currency symbol goes after the number.

Accessibility and Mobile

Most shoppers arrive on a phone. Build the mobile layout first and widen it, rather than the reverse.

Keep buttons reachable with a thumb, keep contrast real rather than fashionable, and make sure the checkout works without a mouse. A checkout that is awkward on a phone loses orders silently.

Testing

Five pages, then the same five on a mobile device: homepage, a category, a product, the basket, the checkout.

Then test against a real catalogue rather than demo data. A theme that looks right with eight tidy products often falls apart with two thousand real ones, where names are long, images are inconsistent and half the products have options.

Uploading and Review

A theme is uploaded in the merchant's admin and reviewed before it can be used. The review is for security rather than design: what your templates do, not how they look.

Keep the source somewhere you control. A theme that exists only on the platform is one you cannot rebuild, and a merchant who edits their copy has forked it without telling you.

Versioning

A merchant can sync with the platform to take your update, which overwrites their template edits. Say so in your description.

Keep settings backward compatible. Removing a setting, or changing what its values mean, breaks shops already using it, and they will not find out until a page renders wrongly.