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:

  1. Create resources/js/classes/MyComponent.js
  2. Add it to config.js.files in build.js (before App.js)
  3. 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 lazyloaded event (bubbles) once revealed.
  • Dispatch lazyresetrequested on document to reset all images (replay).

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 .webp when 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-descriptor srcset. Same 'raw' / 'webp' / smart-default semantics as thumb().
  • $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: ImageController generates the thumb and writes it next to the source — img/gallery/lazienka/thumb/photo_800.jpg.
  • WebP sibling: append .webp to any thumb URL — /thumb/800/lazienka/photo.jpg.webpthumb/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_ENCRYPTION
  • SMTP_USERNAME, SMTP_PASSWORD
  • MAIL_FROM, MAIL_FROM_NAME
  • MAIL_TO, MAIL_TO_NAME
  • MAIL_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.

Read the FlatPanel docs →