---
title: "Éclair's architecture"
description: "The Éclair CMS architecture explained for developers: the index.php fast path, static cache, flat-file storage in data/, classes and security headers."
url: "https://eclair.academy/en/paths/expert/architecture"
lang: "en"
author: "Thierry Gustin"
published: "2026-11-02"
updated: "2026-11-02"
---

# Éclair's architecture

> **Key takeaways** — The Éclair CMS architecture relies on a single public script, index.php, which serves pages already generated straight from cache/pages. Other requests go through app/bootstrap.php, then App::run, which routes them to the installer, the admin, the contact form, the MCP connector or the site. Data is stored as protected JSON files in data/, written atomically under a lock.

The Éclair CMS architecture comes down to one rule: only make PHP work when it's needed. This lesson follows a request from end to end, from the web server to the static cache, shows how data is stored without a database and which class does what. You'll then be able to debug, customize or host Éclair knowing how it works inside.

> **Before you start** — Time: 30 minutes. Prerequisites: you can read PHP 8 and know the basics of HTTP (headers, status codes 200, 301, 304, 404). An installed site helps you follow along: locally with [Local development and testing](https://eclair.academy/en/paths/expert/local-development), or online after [Install Éclair step by step](https://eclair.academy/en/paths/beginner/install-eclair).

## What you'll be able to do

### ⚡ Follow a request

From the web server to index.php, then to the cache or the application.

### 🔒 Read the data/ folder

What each file contains and how it's protected.

### 🛠 Find the right class

Know where to act in app/lib/ in a few seconds.

## How does a request travel through the Éclair CMS architecture?

![Diagram of a request's life cycle: a response from the static cache or a pass through the Éclair application](https://eclair.academy/media/2026/11/eclair-academy-architecture-en.png)
*The fast path serves pages already generated; everything else goes through App::run.*

It all starts from one observation: most visits ask for a page that hasn't changed. So the Éclair CMS architecture sends it back as is, without reading a single piece of content. Here is the path a request takes.

1. **The web server.** On Apache or LiteSpeed, `.htaccess` sends any URL that isn't an existing file to `index.php`. It returns a 404 for `app/`, `data/`, `cache/`, hidden files (except `.well-known`) and any `.php` file other than `index.php`, and passes the `Authorization` header on to the MCP connector.
2. **The index.php fast path.** For a GET or HEAD request, `index.php` reads `cache/runtime.php`, then looks for the page in `cache/pages/` under the `md5` of the path. If it finds it, it returns the page's saved headers, its ETag and `X-Cache: HIT`, or a `304 Not Modified` if the browser already has this version.
3. **Bootstrap.** Otherwise, `app/bootstrap.php` checks the PHP version (8.1 at least), loads `eclair-config.php` if it exists, defines `ECLAIR_DATA` and `ECLAIR_DEBUG`, registers autoloading for the `Eclair\…` classes, then calls `Eclair\App::run()`.
4. **Routing.** `App::run()` sets up error handling and hands the request to the right module (table below).
5. **Rendering.** `Site::handle()` only accepts GET and HEAD, enforces a single URL per piece of content (trailing slash, uppercase, `index.php`: 301 redirect), serves the generated files (`robots.txt`, `sitemap.xml`, `llms.txt`, feeds, `.md` versions), then looks up the content. A page rendered with a 200 status is saved by `Cache::put()`.

| Requested path | Module that responds |
| --- | --- |
| `/__eclair/ping` | rewrite test, proof of identity (`Loopback`) |
| any path, before installation | `Installer::handle()` |
| the admin URL and its subpages | `Admin::handle()` |
| `/_eclair/contact` | `Contact::handle()`, the contact form |
| `/_eclair/edit/<id>` | redirect to the editor, if you're logged in |
| `/_eclair/…` and `/.well-known/…` | `Mcp::handle()`, silent while the connector is disabled |
| everything else | `Site::handle()` |

Search, the 404 page, the appearance preview, Visual mode and the admin are never cached. To see the fast path at work, request the same page twice:

```bash
# 1st call: the application generates the page, then saves it
curl -sI https://exemple.fr/contact | grep -i -E 'etag|x-cache'
# ETag: "0745734655e7d523"

# 2nd call: served by index.php from cache/pages/, without loading the application
curl -sI https://exemple.fr/contact | grep -i -E 'etag|x-cache'
# ETag: "0745734655e7d523"
# X-Cache: HIT

# The cache file is named after the md5 of the requested path
php -r 'echo md5("/contact"), PHP_EOL;'   # 90609cfd31a32bdbd3d81843c57d356b
```

## Flat-file storage: the Store class

The Éclair CMS architecture does without a database: each piece of data is a `data/<name>.php` file that starts with the line `<?php exit; ?>`, followed by readable JSON. Requested from the web, it returns nothing, even on a server that ignores `.htaccess` files. Here is the beginning of a real menus file:

```php
<?php exit; ?>
{
    "main": [
        {
            "type": "content",
            "id": "af59e5ba0a",
            "label": "",
            "url": ""
        }
    ]
}
```

Four mechanisms keep this storage safe, even on a busy shared server:

- **Atomic writes**: `Store::atomic()` writes a temporary file, then renames it, so a reader never sees a half-written file. Files get 0600 permissions, and the folders in `data/` and `cache/` get 0700.
- **No injectable code**: `Store::write()` encodes the JSON with `JSON_HEX_TAG` and `JSON_HEX_AMP`. A `<` becomes `<`, so the `<?php` sequence can't appear in the data.
- **Lock**: `Store::locked()` takes an exclusive lock (`flock`) on `data/.lock` during every read-modify-write operation.
- **Controlled names**: `Store::file()` only accepts lowercase letters, numbers, hyphens and slashes. There's no way out of `data/`.

Every class that writes data follows this pattern, `Redirects::add()` for example:

```php
use Eclair\Store;

$menus = Store::read('menus', []);           // read, with a default value
Store::locked(function (): void {            // read, modify, write with no race
    $list = Store::read('redirects', []);
    // … modify $list …
    Store::write('redirects', $list);        // temporary file, 0600, rename
});
```

## The files in the data/ folder

The last column shows what the ZIP backup on the **Security** page keeps: secrets and short-lived data are left out.

| File or folder | Contents | ZIP backup |
| --- | --- | --- |
| `site.php` | settings, merged with `Settings::DEFAULTS` | yes |
| `users.php` | accounts, Argon2id hashes, two-factor authentication | yes |
| `keys.php` | site secret and IndexNow key, created at installation | yes |
| `index.php` | a summary of each piece of content, to list and route quickly | yes |
| `content/<id>.php` | a page, a post or a product, with its blocks | yes |
| `revisions/<id>/` | the last 20 versions of that content | yes |
| `search.php`, `media.php` | search text, media library register | yes |
| `menus.php`, `categories.php`, `redirects.php`, `custom-css.php` | menus, categories, redirects, custom CSS | yes |
| `messages/`, `inbox.php` | form messages, 500 at most | yes |
| `logs/` | error and security logs, capped at 1 MB | yes |
| `vault.php` | encrypted API keys | no |
| `ai/` | assistant conversations, suggestions, MCP connections | no |
| `sessions/`, `limits/` | admin sessions, brute-force counters | no |

Updates never touch `data/`, and the `ECLAIR_DATA` constant can even move it outside the public folder.

## The main classes in app/lib/

All classes live in the `Eclair\` namespace, one file per class, with no framework and no Composer:

| Class | Role |
| --- | --- |
| `App` | routing for requests the cache doesn't serve |
| `Req`, `Http` | reading the request, responses and redirects |
| `Store`, `Settings` | flat-file storage, settings and default values |
| `Content`, `Blocks` | content, index, revisions; rendering of the 18 blocks |
| `Site`, `Theme` | public router; templates and inline CSS |
| `Cache` | static pages and `cache/runtime.php` |
| `Seo`, `Analyzer` | structured data, sitemap, llms.txt; scores |
| `Security`, `Session`, `Auth` | headers, CSRF tokens, sessions, login |
| `Media` | media library, re-encoding and WebP |
| `Admin` and `Admin…` | admin screens and actions |
| `Ai`, `AiChat`, `AiTools`, `Mcp` | AI assistant, tools, MCP connector and OAuth |
| `Integrity`, `Log`, `Keys`, `Vault`, `Loopback` | integrity, logs, secrets, vault, address verification |
| `I18n` | admin translations |

## Why does the cache clear on every change?

A changed title also appears in a menu, a post card and the breadcrumbs. Rather than guess these dependencies, the Éclair CMS architecture clears everything: `Cache::purge()` runs after every save of content, settings, menus, categories, media, a profile or the custom CSS. It deletes the files in `cache/pages/` and rewrites `cache/runtime.php`; each page is regenerated on its first visit.

```php
<?php exit; ?>
{"on":true,"expires":0,"host":"exemple.fr","https":true}
```

`index.php` reads this small file on every request:

- `on`: cache active (**Static page cache** checked, debug mode off);
- `expires`: the time of the next scheduled post, after which `index.php` ignores the cache and `Cache::checkExpiry()` clears it;
- `host` and `https`: the official host and protocol, once the address is verified, so that redirects are never bypassed.

> **Clearing the cache by hand** — **Settings › Advanced › Clear cache** does the same thing as `Cache::purge()`. It's the last step of every Éclair update.

## Security headers and CSP

`Security::headers()` produces three sets of headers: `site`, `admin` and `text` (sitemap, `llms.txt`, feeds). They depend on the settings, never on the request, so nobody can poison the cache with a forged header. Here are the headers of a public page served over HTTP, recorded on Éclair 1.8:

```text
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'sha256-…'; img-src 'self' data: https:; media-src 'self' https:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com; connect-src 'self'; form-action 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()
Cross-Origin-Opener-Policy: same-origin
```

`Theme::hash()` computes the `sha256` hash of the CSS inlined in the page: only that `<style>` tag is allowed, no inline script is permitted, and the only public script is `assets/site/site.js`. Over HTTPS, `upgrade-insecure-requests` is added, along with `Strict-Transport-Security` if the HSTS option is checked. The admin is stricter still: no inline styles, `frame-ancestors 'none'`, `noindex` and `Cache-Control: no-store, private`.

## Translations

- **Admin**: every string is written in French in the code, with `t()`. `I18n::t()` looks up its translation in `app/lang/<lang>.json.gz`, compiled by `tools/i18n.php` and indexed by the CRC32 hash of the French text. Without the zlib extension, the admin stays in French.
- **Public site**: `Theme::t('key', $lang)` reads the `Theme::STRINGS` array (“Contents”, “Key takeaways”, “Read in”…) in six languages, and `Theme::words()` gives the words used in URLs (page, author, search, shop) in the site's language.

> **Don't modify app/ lightly** — Every file in `app/` is replaced at the next update and flagged by the integrity check. For styling, use custom CSS; for the HTML structure, follow [Customize the theme templates](https://eclair.academy/en/paths/expert/theme-customization).

## Key points

- In the Éclair CMS architecture, a page already generated is served by `index.php` from `cache/pages/` without loading the application: `X-Cache: HIT` proves it.
- Everything else goes through `app/bootstrap.php`, then `App::run()`, which routes to the installer, the admin, the contact form, MCP or the site.
- Data is stored as JSON files behind `<?php exit; ?>`, written atomically, with 0600 permissions and under a lock.
- The cache clears completely on every change and rebuilds itself on the next visit.
- Security headers depend only on the settings: the cache can't be poisoned.

## Going further

- [Local development and testing](https://eclair.academy/en/paths/expert/local-development): run Éclair on your machine and replay its 550 checks.
- [Customize the theme templates](https://eclair.academy/en/paths/expert/theme-customization): change the rendering without breaking the CSP.
- [Nginx, VPS, proxy and CDN](https://eclair.academy/en/paths/expert/nginx-vps): reproduce the `.htaccess` rules outside Apache.
- [Advanced security and maintenance](https://eclair.academy/en/paths/expert/advanced-security): integrity, backups and updates.
- PHP documentation: [flock()](https://www.php.net/manual/en/function.flock.php) and [rename()](https://www.php.net/manual/en/function.rename.php), at the heart of how `Store` writes.
- OWASP: [Content Security Policy Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html).

## Frequently asked questions

### Can Éclair run without its static cache?

Yes: uncheck **Static page cache** in **Settings › Advanced**, or turn on debug mode. Every visit then generates the page, which costs more server time.

### Why store JSON in .php files?

So that the `<?php exit; ?>` line protects each file even if the server ignores `.htaccess`. Requested from the web, the file displays nothing, while Éclair reads the JSON that follows this first line.

### What happens if two people save at the same time?

Writes go through `Store::locked()`, which takes an exclusive lock on `data/.lock`. The two saves therefore run one after the other, and the previous version joins the revision history.

### Can I put the data/ folder outside the public folder?

Yes. First move the folder, then set its new location with `define('ECLAIR_DATA', '/path/to/eclair-data');` in `eclair-config.php`, next to `index.php`. The fast path doesn't depend on it: it only reads `cache/`.
