---
title: "Customize the theme templates"
description: "Edit Éclair's theme templates (layout.php, single.php, list.php…) while respecting the CSP, updates and the integrity check."
url: "https://eclair.academy/en/paths/expert/theme-customization"
lang: "en"
author: "Thierry Gustin"
published: "2026-11-02"
updated: "2026-11-02"
---

# Customize the theme templates

> **Key takeaways** — The theme templates are the PHP files in app/theme (layout.php, single.php, list.php…) that receive a $v array and produce the public HTML. Prefer custom CSS when it's enough, because it's stored in data/ and survives updates. A modified template must respect the CSP, with no inline script or style, and has to be reapplied after every update.

The theme templates decide the HTML of every public page: header, menus, post, lists, product page. Editing them opens up everything custom CSS can't do, at the cost of maintenance on every update. This lesson details the files in `app/theme`, the variables they receive, the security constraints and this academy's own example.

> **Before you start** — Time: 40 minutes. Prerequisites: PHP and HTML, a local test site ([Local development and testing](https://eclair.academy/en/paths/expert/local-development)), the [Éclair's architecture](https://eclair.academy/en/paths/expert/architecture) lesson for the rendering cycle, and [Custom CSS](https://eclair.academy/en/paths/intermediate/custom-css) to know what it already allows.

## What you'll be able to do

### 🎨 Choose the right level

Custom CSS, the theme's stylesheet or a PHP template.

### 🛠 Edit a template

With the variables in $v and Éclair's functions.

### 🛡 Stay compatible

Strict CSP, updates and the integrity check.

## Custom CSS or theme templates?

Always start with the lightest solution. Custom CSS covers the vast majority of tweaks, and `<body>` carries classes that let you target a context without touching PHP: `theme-<name>`, `hdr-center`, `cards-list`, then `is-post`, `is-page`, `is-wide`, `is-list`, `is-shop`, `is-product`, `is-search` or `is-404` depending on the page.

| Criterion | Custom CSS | Theme templates |
| --- | --- | --- |
| Where | **Settings › Appearance › Custom CSS** | files in `app/theme/` |
| Stored in | `data/custom-css.php` | `app/`, replaced on every update |
| After an update | kept | must be reapplied |
| Integrity check | nothing to report | “modified” or “unknown” files |
| Limits | 64 KB, no `<` or `@import` | free HTML, within the CSP |
| Best for | colors, spacing, a block's style | HTML structure, new elements |

## The files in app/theme

| File | Role | Main `$v` variables |
| --- | --- | --- |
| `layout.php` | shared skeleton: `<head>`, header, menus, footer | `lang`, `head`, `css`, `content`, `current`, `id`, `class` |
| `single.php` | page or post | `item`, `html`, `toc`, `author`, `category`, `crumbs`, `related`, `translations`, `featured`, `words`, `h1_in_content` |
| `list.php` | blog, category, author, search, catalog | `title`, `description`, `entries`, `crumbs`, `page`, `pages`, `prev`, `next`, `query`, `empty`, `chips` |
| `cards.php`, `products.php` | grids of post and product cards | `entries`, `lang`, `level` |
| `product.php` | product page | `item`, `entry`, `html`, `images`, `offers`, `crumbs`, `related` |
| `404.php` | page not found | `lang` |
| `theme.css` | base styles, inlined in every page | `/*dark*/` and `/*shop*/` markers |
| `themes/*.css` | the 5 optional themes | added after `theme.css` |
| `directions.css` | art direction snippets | only those of the chosen direction |

The CSS variables in `theme.css` (`--bg`, `--bg-soft`, `--card`, `--text`, `--muted`, `--line`, `--radius`, `--shadow`, `--text-w`, `--wide-w`) and those computed from the settings (`--accent`, `--on-accent`, `--link`, `--font`, `--font-head`) remain your best handle: use them in your templates to follow the theme, dark mode and the chosen color.

## How does a template receive its data?

`Theme::render($view, $v)` first runs the view's template (`single`, `list`…), puts its HTML in `$v['content']`, then runs `layout.php` with the same array. The skeleton therefore sees all the page's variables: `$v['item']` or `$v['translations']` are available in a post's header. For a page in French, `Theme::typography()` finally adds the non-breaking spaces.

`Theme::partial($name, $v)` includes `app/theme/<name>.php` in an isolated function, where only `$v` is visible. This lets you split your theme into small files (`Theme::partial('lang-switch', $v)`), knowing that each new file will show up among the integrity check's “Unknown files”.

| Tool | Use in a template |
| --- | --- |
| `e($value)` | escape any text or attribute, without exception |
| `path_to('/path')` | internal URL, compatible with a subfolder |
| `asset('site/site.js')` | URL of a static file with its version fingerprint |
| `Theme::t('key', $lang)` | theme strings in six languages (`read_in`, `toc`, `menu`…) |
| `Menus::items('main')` | menu links: `label`, `href`, `path` and `external` |
| `Media::get()`, `Media::picture()` | media library image, as responsive WebP |
| `Content::path($entry)` | public URL of a piece of content |

Example: show a post's translations in the header, based on the links created in the editor.

```php
<?php // layout.php, inside <header> (add "use Eclair\Content;" at the top of the file) ?>
<?php if (!empty($v['translations'])): ?>
  <nav class="lang-switch" aria-label="<?= e(Theme::t('read_in', $lang)) ?>">
    <?php foreach ($v['translations'] as $code => $t): ?>
      <a href="<?= e(path_to(Content::path($t))) ?>" hreflang="<?= e($code) ?>" lang="<?= e($code) ?>"<?= $t['id'] === ($v['id'] ?? '') ? ' aria-current="true"' : '' ?>><?= e(strtoupper($code)) ?></a>
    <?php endforeach ?>
  </nav>
<?php endif ?>
```

The style for `.lang-switch` goes in custom CSS or in `theme.css`, never in a `style` attribute.

## CSP constraints

Public pages are served with a strict Content-Security-Policy, computed by `Security::headers()`. It applies to your templates like everything else:

- **Scripts**: `script-src 'self'`. No inline `<script>`, no `onclick` attribute, no `javascript:` link. A `.js` file served by your domain is still allowed.
- **Styles**: `style-src 'self'` plus the `sha256` hash of Éclair's CSS. The only accepted `<style>` tag is the one in `layout.php`, filled by `Theme::css()`. A second `<style>` block or a `style=""` attribute is blocked.
- **Fonts and resources**: `default-src 'self'`. A font loaded from a third-party service is refused: host it on your domain, as art directions do in `media/fonts/`.
- **Frames**: only the `youtube-nocookie.com` and `player.vimeo.com` players are allowed.

> **Don't change the CSP** — The policy lives in `app/lib/Security.php`, at the core of Éclair. Loosening it forces you to modify the core, which you lose on every update, and weakens the protection of the whole site. Adapt your HTML instead.

## Template performance

For a cached page, a template only runs on the first display; after that, the static cache serves the page. That's no reason to make it heavier. Read lists from the index (`Content::index()`, `Content::published()`) rather than opening each piece of content, keep `Media::picture()` for images (dimensions, WebP `srcset`, lazy loading) and prefer native HTML elements, `<details>` or the `popover` attribute, to JavaScript. Every KB added to `theme.css` is sent with every page, since the CSS is inlined in the HTML.

Also keep the `Blocks::ea()` calls in `single.php` (`data-ef` attributes): they only appear in the admin preview and let Visual mode edit the title, the featured image and “Key takeaways” in place.

## Updates, integrity and manifest.php

The update procedure deletes `app/`, then replaces it: your theme templates disappear with it. Get organized from day one.

1. Keep your modified templates in a Git repository, outside the live site.
2. After each update, compare the original templates of the old and new versions, to spot a new variable or a fix.
3. Carry those changes over to your files, test locally, then upload them again.
4. Run **Security › File integrity › Scan now**: only your files should appear among the modified or unknown ones.

```bash
# What changed in the original templates between two versions
diff -ru eclair-1.7.0/eclair/app/theme eclair-1.8.0/eclair/app/theme
```

The integrity check compares each file with its fingerprint in `app/manifest.php`. A list that matches your changes is normal; an unexpected file deserves an investigation. For a fleet of sites, a sturdier option is to maintain your own copy of the repository and produce your archive with `php tools/build.php`: its manifest carries your templates' fingerprints, and the check stays clean. The public rendering tests in `tests/run.php` will then flag your changes. That's expected, since they compare against the original rendering.

## This academy's example

Éclair Academy is built with Éclair 1.8 and customized theme templates. The standard theme shares the same menus across all languages; our templates add four behaviors while respecting the constraints described above (no inline script, no `style` attribute, rendering cached as usual).

| File | Role |
| --- | --- |
| `app/theme/academy.php` | `Academy` class: language of a URL, menu filtering, icons, paths and a lesson's position |
| `app/theme/layout.php` | Header with a language switcher, full-screen mobile menu, footer with the four paths |
| `app/theme/single.php` | Lesson header (badge, “Lesson 3 of 9”, line map), side table of contents, previous and next navigation |
| `app/theme/list.php` and `404.php` | Search and page not found in the requested language |
| `assets/site/academy.js` | “Copy” button on code blocks and reading marker in the table of contents (deferred loading) |

- **Menus filtered by language**: a single main menu holds the links for all three languages; `Academy::menu()` only keeps those whose URL belongs to the page's language (French at the root, `/en/…` and `/es/…`). An emoji at the start of a label, as in “🧭 Paths”, becomes a line-drawn icon in the mobile menu.
- **Language switcher**: it relies on the translations linked in the editor (`$v['translations']`) and leads to the same page in the other language, or to that language's home page if the page isn't translated.
- **Full-screen mobile menu without JavaScript**: a `popovertarget="site-menu"` button opens `<nav id="site-menu" popover>`, which the browser handles on its own (Escape key, click outside). On large screens, the CSS puts the menu back in the header.
- **Automatic lesson navigation**: `Academy::stations()` reads the first numbered list on the path's page. The order of its links gives each lesson's number, the line map and the “Previous lesson” and “Next lesson” links. To reorder a path, you simply edit that list in the editor.

The core of the filtering takes just a few lines:

```php
<?php
// app/theme/academy.php (excerpt)
public static function langOf(string $path): string
{
    foreach (['en', 'es'] as $code) {
        if ($path === '/' . $code || str_starts_with($path, '/' . $code . '/')) {
            return $code;
        }
    }
    return 'fr';
}

/** Menu items in the page's language; external links show everywhere. */
public static function menu(array $items, string $lang): array
{
    $out = [];
    foreach ($items as $item) {
        $path = (string) ($item['path'] ?? '');
        if ($path !== '' && self::langOf($path) !== $lang) {
            continue;
        }
        [$icon, $label] = self::splitIcon((string) $item['label']);
        $out[] = $item + ['icon' => $icon, 'text' => $label];
    }
    return $out;
}
```

To keep the Security page green, the academy's build script recalculates the fingerprint of these six files in `app/manifest.php` (the first 32 characters of their SHA-256, like the original manifest). After an Éclair update, you just copy these files again from the academy's archive, then clear the cache.

## Key points

- Try custom CSS first: it's stored in `data/` and survives updates.
- The theme templates receive everything in `$v`, and `layout.php` sees the same variables as the view.
- Escape every value with `e()` and build URLs with `path_to()`.
- The CSP forbids inline scripts and styles: CSS goes in the theme's stylesheets, behavior in native HTML.
- A modified template is replaced on every update: keep it under version control and compare it with each new version.

## Going further

- [Custom CSS](https://eclair.academy/en/paths/intermediate/custom-css): everything you can do without touching PHP.
- [Build a multilingual site](https://eclair.academy/en/paths/intermediate/multilingual-site): the linked translations the language switcher relies on.
- [Performance: staying at 100 out of 100](https://eclair.academy/en/paths/expert/performance): measure the effect of your templates.
- [Advanced security and maintenance](https://eclair.academy/en/paths/expert/advanced-security): the update procedure and the integrity check.
- MDN: [the Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API), for a menu without JavaScript.
- OWASP: [Content Security Policy Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html).

## Frequently asked questions

### Do my templates survive an Éclair update?

No: the update replaces the whole `app/` folder, templates included. Keep your files in a Git repository and reapply them after each release, checking what changed in the originals.

### Can I add my own JavaScript file?

The CSP accepts a file served by your domain, never an inline script. Choose its location carefully: `assets/` is replaced on every update, and the integrity check flags anything in `media/` that is neither an image nor a font. Try a native HTML solution first.

### How do I add a translated string that's missing from Theme::t?

`Theme::t()` returns the key itself when it doesn't exist. Declare a small array of strings per language in your template, indexed by `$lang`.

### Does Visual mode work with my templates?

Yes, if you keep the blocks' output (`$v['html']`) and the `Blocks::ea()` attributes in `single.php`. They stay invisible on the public site.
