Documentation
Everything you need to build, extend, and ship a Flat Cake site.
Prerequisites & installation
Flat Cake requires PHP 8.2+, Composer, and Node 18+ for the asset pipeline. Clone the repository, install both dependency sets, and start the built-in server:
git clone https://github.com/your-org/flat-cake.git my-site
cd my-site
composer install
npm install
npm run build
bin/cake server -p 8765
Point your web server (Apache, Nginx, Laragon, DDEV, Herd — anything)
at the webroot/ directory as the document root. All other
directories stay outside the public root.
npm run watch rebuilds SCSS and JS on every save during development.
Run npm run build once for a minified production build.
DDEV
A .ddev/config.yaml is included for DDEV users. Start with:
ddev start
ddev exec composer install
ddev exec npm install && ddev exec npm run build
The site will be available at https://flat-cake.ddev.site. Copy config/.env.ddev to config/.env for the DDEV-specific environment settings.
Starting a new client project
Two commands turn a fresh clone into a clean baseline for a new project, with complementary roles:
bin/setup-client.sh # one-shot wizard: CLAUDE.local.md + env files + optional demo cleanup
bin/cake fork_init # idempotent strip of upstream demo content + write starter templates
bin/setup-client.sh is an interactive bash wizard
you run once per fork. It verifies the upstream
remote, collects client metadata, generates a CLAUDE.local.md
from the template, copies config/{app_local,panel}.example.php
into place, and offers to remove the demo content chain.
bin/cake fork_init is the idempotent counterpart. It deletes
the demo Pages, the demos directory, demo SCSS/JS, and every gallery
subfolder (with its thumb/ caches, .webp renditions
included) — webroot/img/gallery/README.md is preserved.
It then writes back minimal starter templates for home.php,
about.php, docs.php, plus a stub navigation and
footer. Safe to re-run any time — useful after an upstream sync
brings new demo pages, or when you want a clean baseline mid-development.
It prints the deletion plan first so you can dry-run by answering "n";
pass --force to skip the confirmation prompt.
Pages & routing
All pages are served by a single catch-all route that delegates to
PagesController::display(). The URL path maps directly to
a PHP template inside templates/Pages/. No route
registration is ever needed.
/ → templates/Pages/home.php
/about → templates/Pages/about.php
/about/the-team → templates/Pages/about/the_team.php
/docs → templates/Pages/docs.php
Dashes in the URL become underscores in the filename.
Template resolution is case-insensitive — uppercase
letters in filenames are preserved for nav labels while URLs stay
lowercase (e.g. API_posts.php → label "API Posts",
url /demos/api-posts).
To publish a new page, just create the file — nothing else required.
Nested pages
Create a subdirectory inside templates/Pages/ to group
related pages under a common URL prefix. Nesting depth is unlimited.
templates/Pages/
home.php → /
about.php → /about
about/
the_team.php → /about/the-team
demos/
API_posts.php → /demos/api-posts
color_palette.php → /demos/color-palette
lazy_loading.php → /demos/lazy-loading
Layouts & elements
Every page template is wrapped in the default layout at
templates/layout/default.php. It includes the
head, preloader, header, and
footer elements automatically.
Setting the page title and description
<?php
$this->assign('title', 'My Page — Flat Cake');
$this->set('metaDescription', 'Short description for search engines.');
?>
Reusable elements
<?= $this->element('my-component') ?>
<!-- Pass variables into the element: -->
<?= $this->element('card', ['title' => 'Hello', 'text' => 'World']) ?>
Injecting page-specific scripts
Append to the scriptBottom view block to inject JavaScript
at the bottom of the layout without modifying it:
<?php $this->append('scriptBottom') ?>
<script>
// Page-specific JavaScript here
</script>
<?php $this->end() ?>
Asset pipeline
SCSS lives in resources/scss/, JavaScript in
resources/js/. A custom Node build script (build.js)
compiles both into webroot/css/app.min.css and
webroot/js/app.min.js.
npm run build # one-off minified production build
npm run watch # watch + rebuild on every save
SCSS structure
app.scss is the entry point. It imports partials in this order:
@use 'variables'; // design tokens: spacing, breakpoints, radii, static colors
@use 'base'; // CSS custom properties (:root), reset, typography, @font-face
@use 'layout'; // container, header, footer, preloader
@use 'components'; // sections, cards, buttons, forms, docs layout, demo pages
@use 'utilities'; // single-purpose helper classes
@use 'sebanim8'; // animation system — steps line, hover transitions, micro-interactions
Design tokens & color theming
Static tokens (spacing, radii, breakpoints, font stacks) live as SCSS variables in
_variables.scss. Brand colors are defined as CSS custom properties
in the :root block inside _base.scss, so they can be
changed at runtime from JavaScript:
/* Only --cb-brand needs to be updated — all derived colors follow automatically */
:root {
--cb-brand: #3a8fb7;
--cb-brand-dark: color-mix(in srgb, var(--cb-brand) 72%, #000);
--cb-brand-light: color-mix(in srgb, var(--cb-brand) 60%, #fff);
--cb-brand-wash: color-mix(in srgb, var(--cb-brand) 12%, #fff);
--cb-brand-pastel: color-mix(in srgb, var(--cb-brand) 30%, #fff);
--cb-surface: color-mix(in srgb, var(--cb-brand) 4%, #fff);
--cb-surface-alt: color-mix(in srgb, var(--cb-brand) 8%, #fff);
--cb-border: color-mix(in srgb, var(--cb-brand) 18%, #fff);
}
To change the theme color at runtime, set --cb-brand on the
document root. The value is persisted in localStorage and a cookie
so it survives page reloads and is readable by PHP:
document.documentElement.style.setProperty('--cb-brand', '#e85d4a');
localStorage.setItem('flat-cake-theme', 'e85d4a');
document.cookie = 'flat-cake-theme=e85d4a;path=/;max-age=31536000;SameSite=Lax';
Adding a new JS class
There are no ES modules — everything is global UMD. To add a new class:
- Create
resources/js/classes/MyComponent.js - Add it to
config.js.filesinbuild.js(beforeApp.js) - Register it in
App.registerModules():this.modules.push(new MyComponent());
Animation system (Sebanim8)
Sebanim8.js is the animation module — a single class that wraps
GSAP, ScrollTrigger, and Lenis into six declarative layers. Everything is
gated behind prefers-reduced-motion.
Layer reference
Layer 1 — Smooth scroll Automatic. Lenis replaces native scroll.
Layer 2 — Section reveals Add class="section" to a <div>.
Fades in when it enters the viewport.
Layer 3 — Entry animations Add data-anim="up|down|left|right|scale|pop|blur|clip|wipe"
Optional: data-delay="0.2" (seconds)
Layer 4 — Parallax Add data-parallax="-0.05"
Negative = slower than scroll, positive = faster.
Layer 5 — Ambient CSS Add class seba-float, seba-pulse, or seba-glow.
CSS keyframe animations, no JS required.
Layer 6 — Particles Add class seba-particles to a container.
JS spawns floating dots inside it.
Anti-flash
_sebanim8.scss sets opacity: 0 on all
[data-anim] elements before GSAP runs, preventing a flash of
un-animated content. GSAP sets the initial state immediately on
DOMContentLoaded.
Steps component
The .steps container automatically renders a connecting line
(.steps__line) on desktop, animated via GSAP scaleX
on scroll. The line is injected by Sebanim8.js — no extra markup needed.
Lazy loading images
LazyLoad.js uses IntersectionObserver to
defer image requests until they enter the viewport. Wrap each image
in .lazy-img-wrap for a shimmer placeholder while loading.
Deferred data-src
The image is not requested until it scrolls into view. Use a 1×1
transparent GIF as the placeholder src:
<div class="lazy-img-wrap">
<img class="lazy-img"
data-src="/img/photo.jpg"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt="Description"
width="800" height="600">
</div>
Native lazy + fade
When you just want the fade-in without deferring the request —
omit data-src and add loading="lazy".
The browser fetches the image when near the viewport; JS adds the fade:
<div class="lazy-img-wrap">
<img class="lazy-img"
loading="lazy"
src="/img/photo.jpg"
alt="Description">
</div>
Events
- Each image fires a
lazyloadedevent (bubbles) once revealed. - Dispatch
lazyresetrequestedondocumentto reset all images (replay).
Lightbox / gallery
Lightbox.js provides a full-screen image viewer with
group navigation, keyboard controls, and swipe gestures. It works
standalone or together with lazy-loaded images.
Single image
<img data-lightbox src="photo.jpg" alt="Caption text">
Gallery group (navigable)
Give all images the same data-lightbox value to
group them into a navigable gallery:
<img data-lightbox="my-gallery" src="photo-1.jpg" alt="First"> <img data-lightbox="my-gallery" src="photo-2.jpg" alt="Second">
High-res override
Use data-lightbox-src to show a larger version in the
lightbox while displaying a smaller thumbnail on the page:
<img data-lightbox="my-gallery"
data-lightbox-src="/img/photo-large.jpg"
src="/img/photo-thumb.jpg"
alt="Caption">
Controls
- Keyboard:
Escapeto close,←/→to navigate. - Touch: swipe left/right to navigate.
- Mouse: click outside the image or the × button to close.
With lazy loading
Combine both features by adding data-lightbox alongside
data-src. The lightbox resolves the source automatically
(data-lightbox-src → src →
data-src):
<div class="lazy-img-wrap">
<img class="lazy-img"
data-lightbox="gallery"
data-lightbox-src="/img/photo-1600.jpg"
data-src="/img/photo-800.jpg"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt="Caption">
</div>
Gallery images & on-demand thumbnails
Drop full-resolution images into webroot/img/gallery/ and
reference them in templates through the Gallery helper.
Thumbnails are generated on the first request and cached on disk —
every subsequent request is served by Apache/Nginx directly, never
touching PHP.
Helper
thumb() returns a WebP URL by default for JPEG/PNG
sources (when the engine supports it) — the snippet below
is already PageSpeed-friendly without any extra arguments.
<img src="<?= $this->Gallery->thumb('lazienka/photo.jpg', 800) ?>"
data-lightbox-src="<?= $this->Gallery->image('lazienka/photo.jpg') ?>"
data-lightbox="lazienka"
alt="Bathroom">
$this->Gallery->image($path)→/gallery/<path>$this->Gallery->thumb($path, $size, ?$format)→/thumb/<size>/<path>. Smart-WebP default: a JPEG/PNG source comes back as.webpwhen the engine supports it; pass'raw'to force the original format (e.g. for the<img>fallback inside<picture>), or'webp'to force the suffix unconditionally.$this->Gallery->thumbSrcset($path, $sizes, ?$format)→ width-descriptorsrcset. Same'raw'/'webp'/ smart-default semantics asthumb().$this->Gallery->supportsWebp()→ gate for emitting<source type="image/webp">
$path is relative to Gallery.root; full web paths
(e.g. /img/gallery/lazienka/photo.jpg) work too — the prefix is auto-stripped.
Responsive <picture> with WebP
For pages where PageSpeed matters (most of them), pair
thumbSrcset() with a <picture> wrapper:
a WebP <source> for browsers that take it, a JPEG
<img> as the fallback. Note the explicit
'raw' on the <img> — without it,
the smart default would also return WebP and defeat the fallback.
The Gallery Cell builds this pattern for you on folder
grids.
<picture>
<?php if ($this->Gallery->supportsWebp()) : ?>
<source type="image/webp"
srcset="<?= h($this->Gallery->thumbSrcset('lazienka/01.jpg', [400, 800, 1920], 'webp')) ?>"
sizes="(min-width: 900px) 800px, 100vw">
<?php endif; ?>
<img src="<?= $this->Gallery->thumb('lazienka/01.jpg', 800, 'raw') ?>"
srcset="<?= h($this->Gallery->thumbSrcset('lazienka/01.jpg', [400, 800, 1920], 'raw')) ?>"
sizes="(min-width: 900px) 800px, 100vw"
alt="Bathroom" loading="lazy">
</picture>
For a plain <img> without the <picture>
fallback, the bare thumb() call already returns the
WebP URL when the engine supports it — no extra wiring needed.
Whole-folder grids — Gallery Cell
When you want every photo from a subfolder rendered as a
lightbox-ready grid, use the Gallery Cell. It scans
the folder at render time, so dropping new files in shows them
without any template edits.
<?= $this->cell('Gallery', ['kuchnia']) ?>
<?= $this->cell('Gallery', ['kuchnia', [
'thumbSize' => 800, // grid thumbnail (default 800) — must be in Thumbs.sizes
'lightboxSize' => 1920, // lightbox-src (default 1920) — must be in Thumbs.sizes
'lightboxGroup' => 'kitchen', // data-lightbox value (default = slug)
'alt' => 'Kitchen — photo',
]]) ?>
The slug is the subfolder name under Gallery.root;
slashes and .. are rejected. Unknown folders render
the empty-state placeholder (.gallery-empty) so the
page never errors. Each image becomes an
<a class="gallery-item gallery-item--linked">
wrapping a lazy-loaded thumbnail, with
data-lightbox wired up for prev/next navigation.
Sizes & configuration
The whitelisted sizes live in config/app.php — any size off the list returns 404:
// config/app.php
'Gallery' => ['root' => 'img/gallery'],
'Thumbs' => [
'sizes' => [400, 800, 1920], // longest-edge px
'quality' => 82,
'placeholder' => 'img/placeholder.png',
],
How generation works
- First hit:
ImageControllergenerates the thumb and writes it next to the source —img/gallery/lazienka/thumb/photo_800.jpg. - WebP sibling: append
.webpto any thumb URL —/thumb/800/lazienka/photo.jpg.webp→thumb/photo_800.webp. Same on-demand flow.GalleryHelper::thumb()does this automatically for JPEG/PNG sources when the engine supports WebP — you only think about the suffix when you explicitly need the raw form (e.g. as a<picture>fallback). - Subsequent hits: the web server serves the file directly — the controller never runs.
- Never upscales: if the source is already smaller than the requested size, the image is just re-encoded (EXIF stripped, auto-oriented) — no pixel interpolation.
- Source missing: the configured placeholder (or an inline grey SVG) is returned, never a 404.
Cache busting
To regenerate one folder's thumbs (incl. its .webp renditions), delete its thumb/ subdirectory:
rm -rf webroot/img/gallery/lazienka/thumb
To wipe every demo gallery folder at once — useful when forking the framework into a new client project — run bin/cake fork_init. It deletes every subfolder under webroot/img/gallery/ (and the thumb/ caches inside them, .webp renditions included), leaving only the README.md; it also strips the demo Pages and demo SCSS/JS in lockstep.
Optional warm-up
Pre-generate every size for every image in one pass — useful after a bulk upload or as a deploy step. The on-demand controller handles any miss, so this command is purely a latency optimisation. It shows per-folder progress as it goes (→ folder/ (N images × M renders) on entry, done on exit), with a Done./Done with failures. verdict at the end:
bin/cake thumbs # JPEG/PNG + WebP per size (when supported)
bin/cake thumbs --no-webp # skip the WebP renditions
bin/cake thumbs --sizes 200,400,800 # ad-hoc sizes
bin/cake thumbs --force --show-files # rebuild everything verbosely
bin/cake thumbs --wipe # delete every thumb/ subdir, then exit
Use --wipe when you change Thumbs.sizes or Thumbs.quality and want orphaned renditions gone. Sources are never touched; the next warm-up (or first request) rebuilds the cache from scratch.
Needs the PHP imagick or gd extension — Imagick is preferred (better quality + EXIF handling), GD is the fallback. WebP additionally needs the matching engine feature (Imagick built with WEBP, or GD with imagewebp()). With neither extension installed, the controller falls through to the placeholder and the CLI exits with an error.
Controller methods
Most pages need no controller logic — the catch-all handles everything.
When a page needs data, add a method to
src/Controller/PagesController.php named
after the camelCase slug:
// URL: /pricing → method: pricing
public function pricing(): void
{
$plans = [
['name' => 'Starter', 'price' => 0, 'features' => ['5 pages', 'CDN']],
['name' => 'Pro', 'price' => 49, 'features' => ['Unlimited pages']],
];
$this->set(compact('plans'));
}
For nested pages, combine segments in camelCase:
// URL: /about/the-team → method: aboutTheTeam
public function aboutTheTeam(): void
{
$this->set('members', $this->fetchTeamData());
}
The method may return a Response (e.g. a redirect) to
short-circuit rendering. If it returns null, rendering
continues normally.
Deployment
Before going to production, build the assets and optimise Composer autoloading:
npm run build
composer install --no-dev --optimize-autoloader
app_local.php
CakePHP 5 requires App.fullBaseUrl in production.
Without it the application throws a security exception. Add it to
config/app_local.php:
'App' => [
'fullBaseUrl' => env('APP_FULL_BASE_URL', 'https://your-domain.com'),
],
Environment variables
Sensitive settings live in config/.env (git-ignored).
Copy the provided template and fill in your values:
cp config/.env.example config/.env
Required variables for the contact form to work:
SMTP_HOST,SMTP_PORT,SMTP_ENCRYPTIONSMTP_USERNAME,SMTP_PASSWORDMAIL_FROM,MAIL_FROM_NAMEMAIL_TO,MAIL_TO_NAMEMAIL_BCC— optional blind-copy
For local development use Mailpit:
set SMTP_HOST=localhost and SMTP_PORT=1025.
Document root & debug mode
The server document root must point at webroot/ — all other
directories must remain inaccessible over HTTP.
The DEBUG environment variable controls debug mode
(defaults to false when not set).
# config/.env — local dev, never commit
DEBUG=true
APP_FULL_BASE_URL=https://flat-cake.test
Cookies & analytics consent
Out of the box the public site sets only two cookies:
flat-cake-theme (your chosen accent colour, 1 year)
and CAKEPHP (CSRF protection on the contact form,
session). Both are strictly functional and exempt from GDPR
consent under art. 5(3) of the ePrivacy directive — but you still
need to inform visitors. A subtle consent bar at the bottom of
the screen does exactly that on the first visit, and writes the
user's choice to flat-cake-consent for 180 days.
The bar lives in templates/element/cookie_consent.php
and is included from layout/default.php +
layout/error.php. A full breakdown of every cookie
— with persistent Accept / Reject buttons — is served at
/cookies, linked from the footer.
Adding Google Analytics
Google Analytics is opt-in. Set FlatCake.gaId in
config/app.php (or app_local.php) to a
valid measurement ID and the loader in
templates/element/head.php will render itself
automatically:
'FlatCake' => [
'gaId' => 'G-XXXXXXXXXX',
],
The format is validated against ^G-[A-Z0-9]+$;
anything else is silently ignored so a typo never leaks an
unintended script. IP addresses are anonymised
(anonymize_ip:true), and the GA snippet is only
injected after the visitor explicitly clicks Accept
(or on subsequent visits if the consent cookie is already
granted). Without a configured ID, no GA code is
emitted at all.
Other trackers can hook into the same gate by listening for
the cookie-consent:granted event on
window:
window.addEventListener('cookie-consent:granted', function () {
// load your script here
}, { once: true });
Footer "Built by" credit
The footer can show an agency credit line linking back to whoever
built and hosts the site. Configure it via App.credit
in config/app.php; per-environment overrides go in
config/app_local.php (no .env files —
the convention is hardcoded defaults in app.php with
local overrides in app_local.php). Both fields must
be set — the line is hidden when either is empty, so the upstream
template shows no credit by default.
'App' => [
// …
'credit' => [
'url' => 'https://your-agency.example',
'label' => 'your-agency.example',
],
],
AppController::beforeRender exposes the array as the
$credit view var, which
templates/element/footer.php reads. Localise the
prefix (e.g. Built by, Wykonanie i hosting, …)
by editing the footer element directly — that's per-fork copy.
Admin panel (FlatPanel)
An optional browser-based editor for the same template files — mounted
at /panel, fully isolated from the public site. Edit PHP
templates with Monaco, keep timestamped backups, restore any version,
switch light / dark / accent themes.