Overview
Larapen's theme system allows you to create fully custom front-end designs. Each theme is a self-contained package with its own views, stylesheets, JavaScript, and configuration. This guide walks you through creating a theme from scratch.
Prerequisites
- PHP 8.3+ and a working Larapen installation.
- Node.js 18+ and npm installed (for asset compilation via Vite).
- Basic knowledge of Laravel Blade templating, Bootstrap 5.3, and SCSS.
- A code editor (VS Code, PhpStorm, etc.).
Step 1: Create the theme directory
All themes live under extensions/themes/. Create a directory for your theme using a lowercase, URL-safe name:
extensions/themes/my-theme/
Step 2: Create the manifest (theme.json)
Create extensions/themes/my-theme/theme.json: this file tells Larapen about your theme:
{
"name": "my-theme",
"display_name": "My Custom Theme",
"version": "1.0.0",
"author": "Your Name",
"license_type": "free",
"description": "A custom theme for my website",
"thumbnail": "assets/images/thumbnail.png",
"supports": ["pages", "portfolio", "blog", "shop"],
"settings": {
"primary_color": "#3b82f6",
"layout_style": "wide"
},
"sku": "",
"item_id": "",
"marketplace_item_ids": {
"envato": "",
"gumroad": "",
"shop": ""
}
}
Manifest field reference
| Field | Type | Description |
|---|---|---|
name | string | Required. Unique machine name: must match directory name. |
display_name | string | Required. Human-readable name shown in admin panel. |
version | string | Semantic version (e.g., 1.0.0). |
author | string | Author name. |
license_type | string | "free" or "premium". |
description | string | Short description for the admin panel. |
thumbnail | string | Relative path to a preview screenshot (recommended: 1200×900px). |
supports | array | Feature flags: pages, portfolio, blog, shop. |
settings | object | Default setting values (overridden by config.php). |
sku | string | SKU of the matching license product on the License API (e.g. larapen-theme-my-theme): the identifier sent first when verifying a purchase code. Leave empty for themes that are not sold. |
marketplace_item_ids | object | Identifier of the theme on each sales platform: envato (Envato Market item ID), gumroad (Gumroad product ID, or its short permalink such as abcde or custom permalink) and shop (Shop product SKU). A theme declaring at least one identifier requires a valid purchase code, bought on any of these platforms, to be activated. Leave all empty for themes that are not sold. |
item_id | string | Legacy flat Envato item ID, kept for older cores. Keep it equal to marketplace_item_ids.envato. |
Step 3: Create the settings file (config.php)
Create extensions/themes/my-theme/config.php: this defines all customizable theme settings with their default values:
<?php
return [
// Colors
'primary_color' => '#3b82f6',
'secondary_color' => '#1e40af',
'accent_color' => '#f59e0b',
'text_color' => '#1e293b',
// Typography
'heading_font' => 'DM Sans',
'body_font' => 'Inter',
'base_font_size' => '16px',
// Layout
'layout_style' => 'wide', // 'wide' | 'boxed'
'container_width' => '1320px',
'wide_container_width' => '1250px',
// Header: Sectioned pages (home + pages built with dynamic sections).
// These pages typically open with a hero, so the header can overlay it.
'header_sectioned_wide' => false,
'header_sectioned_style' => 'default',
'header_sectioned_height' => '',
'header_sectioned_bg_color' => '',
'header_sectioned_bg_image' => '',
'header_sectioned_text_color' => '',
'header_sectioned_btn_label_color' => '',
'header_sectioned_btn_bg_color' => '',
'header_sectioned_text_shadow' => false,
'header_sectioned_logo_height' => '',
'header_sectioned_border_bottom_width' => '',
'header_sectioned_border_bottom_color' => '',
// Header: Standard pages (content-only pages + every add-on front page,
// e.g. blog, shop, forum). These have no hero, so the header is solid.
'header_standard_wide' => false,
'header_standard_style' => 'default',
'header_standard_height' => '',
'header_standard_bg_color' => '',
'header_standard_bg_image' => '',
'header_standard_text_color' => '',
'header_standard_btn_label_color' => '',
'header_standard_btn_bg_color' => '',
'header_standard_text_shadow' => false,
'header_standard_logo_height' => '',
'header_standard_border_bottom_width' => '',
'header_standard_border_bottom_color' => '',
// Footer
'footer_style' => 'default',
'footer_bg_color' => '',
'footer_bg_image' => '',
'footer_title_color' => '',
'footer_text_color' => '',
'footer_link_color' => '',
'footer_border_top_width'=> '',
'footer_border_top_color'=> '',
// UI Behavior
'show_back_to_top' => true,
'default_mode' => 'light', // 'light' | 'dark'
'allow_mode_toggle' => true,
// Custom code
'custom_css' => '',
'custom_js' => '',
];
These settings are editable by the admin at Appearance → Theme Settings and are merged with any overrides stored in the database. You can add your own custom settings keys here; they will be available in all theme views via the $themeSettings array.
header_sectioned_*: applied to sectioned pages: the homepage and any page built with the dynamic section/page builder. These pages usually open with a hero, so this set lets the header overlay it (e.g. a transparent style).header_standard_*: applied to standard pages: content-only pages and every add-on front page (blog, shop, forum, etc.). These have no hero, so this set is typically a solid header.
wide, style, height, bg_color, bg_image, text_color, btn_label_color, btn_bg_color, text_shadow, logo_height, border_bottom_width, border_bottom_color). The legacy single header_* keys no longer exist: a custom theme must read whichever set matches the page being rendered (see Step 5).
Step 4: Create the directory structure
Here is the recommended directory structure for a complete theme:
extensions/themes/my-theme/
├── theme.json
├── config.php
├── assets/
│ ├── images/
│ │ └── thumbnail.png
│ ├── scss/
│ │ ├── theme.scss ← Main SCSS entry (REQUIRED)
│ │ └── pages/ ← Page-specific SCSS (optional)
│ │ ├── home.scss
│ │ ├── contact.scss
│ │ ├── portfolio.scss
│ │ └── ...
│ └── js/
│ ├── theme.js ← Main JS entry (REQUIRED)
│ └── pages/ ← Page-specific JS (optional)
│ ├── home.js
│ └── ...
└── views/
├── layouts/
│ └── master.blade.php ← Main layout (REQUIRED)
├── partials/
│ ├── header.blade.php
│ ├── footer.blade.php
│ └── seo.blade.php
├── sections/ ← Section templates (for page builder)
│ ├── hero.blade.php
│ ├── features.blade.php
│ └── ...
├── components/ ← Reusable Blade components
│ └── page-header.blade.php
└── pages/ ← Full page views
├── home.blade.php
├── page.blade.php
├── sectioned.blade.php
├── contact.blade.php
├── portfolio/
│ ├── index.blade.php
│ └── show.blade.php
└── auth/
├── login.blade.php
└── register.blade.php
theme.json, assets/scss/theme.scss, assets/js/theme.js, and views/layouts/master.blade.php. Without these, the theme cannot be activated.
Step 5: Create the master layout
The master layout (views/layouts/master.blade.php) is the HTML shell for every page. Here is a minimal starting point:
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}"
dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}"
data-bs-theme="{{ $themeSettings['default_mode'] ?? 'light' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
@yield('seo')
{{-- Google Fonts --}}
@php
$headingFont = $themeSettings['heading_font'] ?? 'DM Sans';
$bodyFont = $themeSettings['body_font'] ?? 'Inter';
$families = collect([$headingFont, $bodyFont])
->unique()->map(fn ($f) => urlencode($f) . ':wght@300;400;500;600;700')
->implode('&family=');
// Resolve which header settings set applies to the current page.
// Sectioned pages (pages/sectioned.blade.php) declare
// @section('headerVariant', 'sectioned'); every other page, content-only
// pages and all add-on front pages, falls back to 'standard'.
$headerVariant = trim($__env->yieldContent('headerVariant')) === 'sectioned' ? 'sectioned' : 'standard';
$headerPrefix = 'header_' . $headerVariant . '_';
$headerSettings = collect([
'wide', 'style', 'height', 'bg_color', 'bg_image', 'text_color',
'btn_label_color', 'btn_bg_color', 'text_shadow', 'logo_height',
'border_bottom_width', 'border_bottom_color',
])->mapWithKeys(fn ($key) => [$key => $themeSettings[$headerPrefix . $key] ?? null])->all();
@endphp
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family={{ $families }}&display=swap"
rel="stylesheet">
{{-- Theme CSS variables --}}
<style>
:root {
--color-primary: {{ $themeSettings['primary_color'] ?? '#3b82f6' }};
--color-secondary: {{ $themeSettings['secondary_color'] ?? '#1e40af' }};
--color-accent: {{ $themeSettings['accent_color'] ?? '#f59e0b' }};
--font-heading: '{{ $headingFont }}', sans-serif;
--font-body: '{{ $bodyFont }}', sans-serif;
}
</style>
{{-- Vite assets (update path to match YOUR theme name) --}}
@vite([
'extensions/themes/my-theme/assets/scss/theme.scss',
'extensions/themes/my-theme/assets/js/theme.js'
])
@stack('styles')
</head>
<body>
@include('partials.header')
<main>
@yield('content')
</main>
@include('partials.footer')
@stack('scripts')
</body>
</html>
Resolving the header settings set
Because the header is configured by two independent sets (header_sectioned_* and header_standard_*: see Step 3), the master layout must pick the right one for the page being rendered and expose it as a single, prefix-free $headerSettings array. The @php block above does exactly that:
- It reads the
headerVariantsection. A page opts into the sectioned set by declaring@section('headerVariant', 'sectioned'); anything else defaults to'standard'. - It maps the matching
header_{variant}_*keys down to their short names (style,bg_color,text_color, …) so the header partial never has to know which set it came from.
Your sectioned page view (views/pages/sectioned.blade.php: the homepage and section-builder pages) must therefore declare:
@extends('layouts.master')
{{-- Opt into the header_sectioned_* settings set --}}
@section('headerVariant', 'sectioned')
@section('content')
{{-- hero + dynamic sections --}}
@endsection
All other page views (content pages, and every add-on front page such as blog or shop) omit this section and automatically receive the header_standard_* set. Your partials/header.blade.php then consumes the resolved array by its short keys: for example $headerSettings['style'], $headerSettings['bg_color'], $headerSettings['text_color'], $headerSettings['logo_height']: rather than the old header_* keys.
Available variables in all views
| Variable | Type | Description |
|---|---|---|
$themeSettings | array | All theme settings (merged config.php defaults + admin overrides). |
$headerSettings | array | The resolved header set for the current page (short keys: style, bg_color, …), taken from header_sectioned_* or header_standard_*. Resolved in master.blade.php (see above). |
$activeTheme | string | Name of the currently active theme. |
$isPreviewMode | bool | true when using ?theme= query parameter for preview. |
$currentLocale | string | Current language code (e.g., en, fr). |
$availableLocales | Collection | All available languages for the language switcher. |
Step 6: Create the main SCSS file
Create assets/scss/theme.scss: this is the main stylesheet entry point:
// 1. Override Bootstrap variables BEFORE importing Bootstrap
$primary: var(--color-primary);
$secondary: var(--color-secondary);
// 2. Import Bootstrap (full build)
@import "bootstrap/scss/bootstrap";
// 3. Import Bootstrap Icons
@import "bootstrap-icons/font/bootstrap-icons.css";
// 4. Your custom theme styles
:root {
--font-heading: 'DM Sans', sans-serif;
--font-body: 'Inter', sans-serif;
}
body {
font-family: var(--font-body);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
}
// Add your custom component styles, animations, etc.
Step 7: Create the main JavaScript file
Create assets/js/theme.js: this is the main JS entry point:
// Import Bootstrap JS
import * as bootstrap from 'bootstrap';
// Import shared core utilities
import '../../../../resources/js/confirm-dialog.js';
import '../../../../resources/js/select2-init.js';
import '../../../../resources/js/lightbox.js';
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => {
initHeader();
initMobileMenu();
initThemeToggle();
// ... your custom initializations
});
function initHeader() {
const header = document.querySelector('.site-header');
if (!header) return;
window.addEventListener('scroll', () => {
header.classList.toggle('scrolled', window.scrollY > 50);
});
}
function initMobileMenu() {
// Your mobile menu toggle logic
}
function initThemeToggle() {
// Dark/light mode toggle logic
const toggle = document.querySelector('[data-theme-toggle]');
if (!toggle) return;
toggle.addEventListener('click', () => {
const html = document.documentElement;
const current = html.getAttribute('data-bs-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-bs-theme', next);
localStorage.setItem('theme-preference', next);
});
}
Step 8: Create page views
Page views go in views/pages/ and extend the master layout:
{{-- views/pages/home.blade.php --}}
@extends('layouts.master')
@section('seo')
@include('partials.seo', ['seo' => $seo ?? []])
@endsection
@section('content')
{{-- Hero section --}}
<section class="py-5 bg-primary text-white">
<div class="container text-center">
<h1>{{ $page->title }}</h1>
<p class="lead">{{ $page->excerpt }}</p>
</div>
</section>
{{-- Page content --}}
<section class="py-5">
<div class="container">
{!! $page->content !!}
</div>
</section>
@endsection
@push('styles')
@vite('extensions/themes/my-theme/assets/scss/pages/home.scss')
@endpush
Step 9: Override add-on views
Your theme can override views from any add-on by placing files in the matching path. For example, to override the blog listing page:
extensions/themes/my-theme/views/pages/blog/index.blade.php
extensions/themes/my-theme/views/pages/shop/index.blade.php
extensions/themes/my-theme/views/pages/faq/index.blade.php
The theme view resolution order is:
- Active theme:
extensions/themes/{active-theme}/views/{view} - Default theme fallback:
extensions/themes/default/views/{view} - Standard Laravel:
resources/views/{view}
Step 10: Build assets with Vite
Larapen's Vite configuration automatically discovers theme assets. After creating your SCSS and JS files, build them:
# Build all theme assets
npx vite build
# Or use the Artisan command to build a specific theme
php artisan theme:build my-theme
npx vite build after creating any new SCSS or JS files. The Vite manifest (public/build/manifest.json) must be rebuilt for @vite() directives to resolve your theme's assets.
Vite auto-discovery
Vite automatically picks up these file patterns from your theme:
| Pattern | Purpose |
|---|---|
assets/scss/theme.scss | Main stylesheet entry point |
assets/js/theme.js | Main JavaScript entry point |
assets/scss/pages/*.scss | Per-page stylesheets |
assets/js/pages/*.js | Per-page JavaScript |
Step 11: Activate your theme
- Go to Appearance → Themes in the admin panel.
- Click "Sync" to discover new themes from the filesystem.
- Your theme should appear in the list. If assets are not built yet, you'll see a "Build" button.
- Click "Activate" to switch to your theme.
Step 12: Publish static assets (optional)
If your theme has static assets (images, fonts) that need to be publicly accessible outside of Vite:
php artisan theme:publish my-theme
This copies extensions/themes/my-theme/assets/ to public/themes/my-theme/. Reference them in Blade using:
<img src="{{ theme_asset('images/logo.png') }}" alt="Logo">
Section templates
If your site uses the page builder (sectioned pages), your theme should include section templates in views/sections/. The page builder supports 40+ section types organized into categories:
| Category | Section Types |
|---|---|
| Layout | Hero, CTA, Divider, Spacer |
| Content | Text, Rich Text, Quote, Accordion, Tabs, Timeline |
| Titles | Section Title, Page Title, Subtitle |
| Media | Image, Gallery, Video, Carousel, Before/After |
| Data | Features, Stats, Pricing, Comparison Table, Icon Grid |
| Engagement | Testimonials, Team, Partners, FAQ, Contact Form, Newsletter |
| Embed | HTML, Map, Social Feed, Code Block |
| Add-ons | Blog Posts, Products, Portfolio, Events (if add-ons are active) |
Each section type needs its own Blade partial. The section renderer passes these variables:
| Variable | Description |
|---|---|
$section | The section model with all its configured data. |
$resolvedData | Pre-resolved data (e.g., fetched portfolio items, team members). |
Per-theme styling overrides
Each section supports per-theme styling overrides: admins can customize 14 CSS properties per section per theme without modifying the section content. Your theme's section templates automatically receive these styling overrides applied via the section's style attributes.
Useful Artisan commands
| Command | Description |
|---|---|
php artisan theme:status | Shows all themes with build status (source files, built, active). |
php artisan theme:build {name} | Builds a specific theme's assets via Vite. |
php artisan theme:publish {name} | Publishes static assets to public/themes/. |
Using AI to create themes
Larapen includes an MCP (Model Context Protocol) server that lets AI assistants understand the theme system and generate valid theme files. Instead of creating files manually, you can describe your theme in plain language and the AI will scaffold all necessary files following Larapen conventions. See the "Using AI to Create Themes" article for detailed setup instructions and workflows.
Tips for high-quality themes
- Responsive first: Design mobile-first and use Bootstrap's responsive grid and breakpoints.
- Use CSS custom properties: Define all colors and fonts as
:rootvariables so admins can customize easily. - Leverage Bootstrap utilities: Minimize custom CSS by using Bootstrap's spacing, flexbox, typography, and color utilities.
- Accessible: Follow WCAG AA guidelines: proper heading hierarchy, color contrast, keyboard navigation,
altattributes. - Dark mode support: Use Bootstrap's
data-bs-theme="dark"attribute for automatic dark mode support. - Translatable strings: Never hardcode text. Always use
{{ __('key') }}for all visible strings. - Distinctive design: Each theme should have its own visual identity: unique font pairings, color palette, and layout philosophy.