Download the PHP package dopamine/flatcms without Composer

On this page you can find all versions of the php package dopamine/flatcms. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package flatcms

Dopamine FlatCMS

A flat-file CMS for small client sites (3–10 pages). Components are defined in files. The client edits text and images — nothing else.

No database. No build step. No admin UI for structure. A built-in contact form with CSRF, honeypot, rate limiting and optional Turnstile — submissions stored to disk before any mail is attempted, with retries and retention.

This repository is the Composer engine package (dopamine/flatcms). The skeleton/ directory is the separate dopamine/flatcms-skeleton project template: publish it as its own VCS package, then create each site with composer create-project. A site's theme/ layers over the engine's — dropping a same-named file there overrides it, without ever editing vendor/.


The design rule

Structure lives in files. Content lives in the panel.

You control which components a page has, in what order, with what fields, by editing content/pages/<locale>/*.yml and theme/components/*/schema.yml. The client opens /admin.php and sees a form. There is no "add section" button, no drag and drop, no component picker — those UIs do not exist.

This is enforced on save, not just hidden in the UI. Admin::save() reads the blocks from disk and, for each one, walks the component's schema pulling only the fields that role may edit out of the request. A forged request that posts blocks[hero][type], an undeclared field, a field you marked editable: false, or — as an editor — one marked editable: admin, changes nothing. See tests/03_lockdown.php: it fires exactly those requests, with a real Cloudflare Access token for each role, and asserts the file on disk is unaffected.


Requirements

Engine development (DDEV)

Copy ddev/config.yaml to .ddev/config.yaml, then:

Everything else you need day to day:

Three things specific to this project:

SITE_BASE_URL must match the DDEV URL: img() builds absolute URLs from it once Cloudflare transformations are switched on.

R2, image transformations and cache purge are all off locally, so uploads land in content/uploads/ and saves are instant. The whole thing runs with zero Cloudflare setup.

For private VCS distribution, run ddev auth ssh so the container can reach the package repositories.

Create a site

--no-install skips the host-side install — DDEV's post-start hook runs composer install inside the container, where PHP 8.4 and every required extension exist. --ignore-platform-req=php lets create-project download the skeleton even when the host PHP is older than 8.4; nothing runs on the host, so the host version never matters.

The engine is installed at vendor/dopamine/flatcms; the site owns only its config, content, public entrypoints, layout, and optional overrides. See skeleton/README.md.

Start from the demo theme

A new site opens on a first-run page: three demo_* components that say where you are and point at the docs. It is a placeholder, not a starter — there is nothing in it to build on, and a site still wearing it has not been started.

So this step is effectively step one. For a real starting point — twelve styled components, layouts and bilingual demo content — clone the demo theme over it:

A theme is a starting point, not a dependency: once cloned it is yours, and there is nothing to update from upstream. The theme's own README covers the bilingual config and the styling ladder.

Nothing to clean up afterwards: the placeholder's webfonts are declared in its own components' assets:, so they load only while it renders and go away with it. A theme written over theme/ never inherits them.

Without DDEV


Adding a component

Two files. That is the entire workflow.

theme/components/testimonial/schema.yml:

theme/components/testimonial/testimonial.twig:

Optionally drop a testimonial.css (or .js) beside it — no declaration needed: the file existing is the declaration, and it loads only on pages where a testimonial actually renders, once, however many testimonials the page has. Global CSS/JS — a font, a framework from a CDN, your site.css — is declared in theme/theme.yml; local entries are inlined, https:// entries become real <link>/<script defer> tags with SRI.

Then put it on a page — content/pages/el/home.yml:

Reload /admin.php and the form is there. No registration step, no cache to clear, no database migration.

Adding a field to a schema later is safe: pages that do not have a value for it fall back to default (or an empty string) rather than erroring.

Field types

Type Renders as Sanitised to
text single-line input plain text, whitespace collapsed, truncated at max
textarea multi-line input plain text, blank lines collapsed
richtext Squire editor: B / I / list / link / undo / redo / HTML source, plus a Style menu when richtext_classes is set only p br strong b em i u a ul ol li, all attributes stripped except href — and class, only as a name listed in config.richtext_classes for that tag. style never
image thumbnail + upload button + alt input; click the thumbnail to set a focal point a map: src (must be under media_bases), alt, optional focal ("24% 50%", rendered as object-position for themes that crop with object-fit: cover), and server-derived width/height
link page picker a page id — the filename. The slug is resolved at render time
url single-line input an absolute http(s) URL, a site-relative path, a fragment, mailto: or tel:. Everything else becomes empty — the same rule richtext hrefs use
select dropdown must match a declared option
boolean checkbox a real YAML true/false
list repeater over a fixed sub-schema array_values(), cut to max, then each row walked against fields

An image field carries its own alt: declare the field and the alt input comes with it. Alt is required whenever src is set, unless the field declares decorative: true — which renders alt="" and shows no input, and is the right answer for a background image that carries no information. That flag is the developer's, not the client's: a decorative arriving in a request is dropped like any other undeclared key.

A list declares fields (its sub-schema), max (the ceiling, applied before anything is sanitised) and item_label (which sub-field titles each row in the panel). One level only.

Per-field options: label, hint, max, required, default, placeholder, options, decorative, fields, item_label, and editable, which takes three values:

editable admin editor
true (default) edits edits
admin edits sees, locked
false sees, locked sees, locked

Anything else is treated as false — a typo in a schema must cost a field, not hand one over. Who is an admin is users.yml; Cloudflare Access decides who gets as far as the panel, that file decides what they may do once there, and an authenticated address it does not list is refused outright.

Paste handling in richtext is forced to plain text, so a paste from Word cannot carry styling into the page.

Named styles are the one way a client reaches a class: richtext_classes in config.php maps a tag to class => label ('span' => ['highlight' => 'Highlight']), the theme styles .highlight once, and the panel shows a Style menu with the labels. On save a class survives only as one of those names for that tag; anything typed into the source view or carried by a paste is cut. Tags are limited to Fields::STYLE_TAGS — a site can admit a <mark>, never a <script> — and bin/doctor fails anything else.

Adding your own

A field whose stored value is a string — a colour, a date, a number, a slider — needs no engine change.

  1. Drop admin-theme/fields/<type>.twig in your site for the input widget. The panel falls back to a text input when there is none, so this step is optional while you are still deciding.
  2. Name the type in config.php so bin/doctor knows it is deliberate:

  3. Use it in a component's schema.yml like any built-in.

The value is stored as sanitised plain text, and max and required work as they do everywhere else. Listing the type is what separates a real field from type: txet — doctor still fails the typo, and a deploy whose doctor fails never switches current.

A type whose value is not a string — a map, a repeater, anything holding media — needs its own Fields::sanitise() arm and belongs in the engine, where it arrives by composer update.


Interface strings

Every string the engine shows — the panel, the 404, the contact form's labels and refusals — lives in lang/<locale>.php, a flat PHP array. ADMIN_LOCALE picks the panel's language; a visitor gets the language of the page they are on, so a Greek page refuses a bad email in Greek while the panel stays English.

An unknown key renders as itself, which is what lets a component write label: Επιλέξτε αυτοκίνητο straight into its schema.yml and skip the catalogue entirely. Use a key only when the string must exist in more than one language.

paths.lang takes a list and merges it, site last. A site adds lang/el.php containing only the keys it adds or rewords; everything else resolves from the engine, including keys a later release adds. That is the difference between a one-entry file and a copy of ninety strings that silently falls behind — and it is what makes a translatable label for a component the site wrote possible without editing vendor/.

A public language is two things: an entry in locales, and a catalogue. With only the first, the pages are translated and the engine's own strings are not. bin/doctor reports a catalogue that has fallen behind the source.

The catalogue is required, so that path is executed — it is developer-owned like every other entry in paths and must never point anywhere a request can write.


Deploying on a server

A site is a PHP project with its docroot at public/ — config.php, content/, src/ and vendor/ sit above it and are never reachable over HTTP. Point a vhost at public/ (start from nginx.conf.example or apache.conf.example — written for the atomic layout below, so on a simple site replace current/public with the project's public/ and the uploads alias with <project>/content/uploads), and a deploy is:

That is the whole procedure for a site you update a few times a year. The panel is a moment mid-pull from serving mixed old/new code; if a client's site cannot afford even that, use atomic releases.

Where content lives

content/ — pages, uploads, revisions — stays inside the project directory and is ignored by the site's code repository (the skeleton ships that ignore). The two possible defaults fail differently: tracked content on a site where the client edits means a git pull overwrites their pages — data loss; ignored content on a site where only you edit means a fresh clone has no pages until you copy them down — an inconvenience. The default takes the harmless failure.

Backing it up is the server's job by default. A site that wants the hourly off-box recovery point instead runs git init inside content/ — the parent repository ignores the directory, so the nested repository is invisible to it — adds a private remote, and schedules bin/backup with CONTENT_PATH=<project>/content (see Cron). One clone of that repository restores pages and images together.

CONTENT_PATH, VAR_PATH and USERS_FILE pointing outside the project belong to atomic releases only, where each deploy is a fresh clone and an in-project content directory would simply be absent from it.

Atomic releases — for zero downtime

For sites deployed often, or where a broken deploy must be reversible in one command: current is a symlink to a release directory holding code and vendor only; everything a client owns lives outside it and survives every deploy and every rollback.

The vhost docroot is current/public/, and /uploads/ is aliased to shared/content/uploads/ — see nginx.conf.example, or apache.conf.example for the same vhost on Apache (needs ssl, remoteip, proxy_fcgi and headers enabled). No symlink out of the docroot, and stored src values stay /uploads/... either way. Start from whichever matches your server and replace the example domain and deploy root throughout.

If your host runs PHP as your own site user — CloudPanel, RunCloud, most shared hosting — skip the command below: everything you create is already writable by PHP, and there is nothing to hand over. It exists only for the classic root-managed layout where php-fpm runs as www-data while you deploy as someone else (the layout the example vhosts above describe):

One deploy

shared/.env must exist before the first deploy. The deploy, doctor, smoke server and PHP-FPM all read that same file; it is not copied into a release.

Fetch the exact revision → composer install --no-dev -o → composer audit → the test suite → bin/doctor against the shared content this release is about to serve → a smoke test of the release serving itself → then flip current → smoke-test the live site → purge the edge.

Everything before the flip is reversible by doing nothing: a failure there leaves the running site untouched, same code, same content, no downtime. If the post-switch smoke test fails, the switch is reversed before the deploy reports success.

Content is never rolled back — no client-owned state lives inside a release.

The private Composer repository credential lives in shared/auth.json, read via COMPOSER_HOME. It is deploy-key scoped to the package repository, never enters a release directory, and is rotated by replacing that one file and re-running bin/deploy.sh — there is no second place it is cached. Rotate whenever an operator leaves, and at least annually.

Health check

Validates YAML shape, unique slugs and block ids, duplicate block ids, component schemas and templates (including whether they compile), the layouts, redirect targets and loops, internal page ids, configured paths and their permissions, required PHP extensions, safe production auth settings, and disk headroom. A deploy runs it before switching, so a schema rename that would break a live page stops the deploy instead of the site.

Cron

The paths below assume the atomic layout; on a simple site the project directory stands in for both current and shared, and only the backup entries require a content repository at all.

Log rotation, /etc/logrotate.d/example-domain:

A new site

composer create-project dopamine/flatcms-skeleton — see Create a site. There is deliberately only one way to start a site: a scaffolder that copied src/ into each client produced a fork per client that could never take a Composer update, which is the problem packaging the engine solved.

Copy .env.example to .env, fill it, and make it readable only by the deploy/PHP-FPM users. On a simple site it lives in the project root and that is the whole step. On the atomic layout it is shared/.env, shared by every release; point the FPM pool at that one file (/etc/php/8.4/fpm/pool.d/example-domain.conf):

The standard atomic layout also discovers shared/.env automatically; the explicit FPM value makes the contract visible in server configuration and keeps working if releases are mounted elsewhere.

AUTH_DEV_BYPASS=0 in production. With it on, authentication is skipped for every panel request, regardless of its source address.


Cloudflare setup

1. Access — this is your entire login system

Zero Trust → Access → Applications → Add a self-hosted application

Copy the Application Audience (AUD) tag into CF_ACCESS_AUD and your team domain into CF_ACCESS_TEAM_DOMAIN.

Free for up to 50 users. That is the whole auth layer — src/Auth.php only verifies the JWT signature so nobody can reach the origin directly and bypass Access. There are no passwords stored anywhere in this codebase.

Step by step, with the current dashboard paths: dopamine-flatcms-cloudflare-access.md.

2. R2 — media storage

Create a bucket, connect a custom domain (media.example-domain.com), then R2 → Manage API tokens → create a token with Object Read & Write scoped to that bucket.

Zero egress fees, so image-heavy client sites cost nothing to serve. With R2_ENABLED=0 everything falls back to content/uploads/ and the CMS behaves identically — handy for local work.

3. Image transformations

Optional, and off by default. Derivatives are generated locally with GD: /img.php?src=…&w=… resizes on first request, writes to var/cache/images/ keyed by source content hash + width + format, and serves it immutable for a year. Every image renders through @flatcms/picture.twig as a <picture> with a WebP source and a JPEG (or PNG, where there is transparency) fallback.

The width allowlist is finite — 320, 640, 960, 1280, 1600, 2048 — and a width outside it 404s before a byte of the source is read, so an anonymous GET cannot fill the disk with attacker-chosen variants. img() returns an empty string for one, so it never reaches the markup either.

Cloudflare's own transformations remain available: Images → Transformations → enable for the zone, then CF_IMAGES_ENABLED=1, and img() builds /cdn-cgi/image/… URLs instead. The width allowlist applies to that path too, so both backends serve the same finite set of variants.

It is off by default because the free plan's 5.000 unique transformations per month are per account, shared across every zone — twenty sites at ~200 uniques each is 4.000 against that cap with no headroom, and no way to attribute or rebill it per client.

4. Cache

The site sends Cache-Control: s-maxage=31536000 and Cache-Tag: page:<id>,site, so Cloudflare holds pages at the edge and PHP is barely touched. On save, the CMS purges that page's tag and site — landing in roughly 150ms.

site is the tag on everything that renders cross-page data: the sitemap, the navigation, hreflang alternates, resolved links. /sitemap.xml and /robots.txt carry site and only site — a sitemap tagged page:<id> would never be purged, because the page that changed is never the sitemap.

Create an API token with Zone → Cache Purge → Purge on the zone, set CF_ZONE_ID and CF_API_TOKEN. Set CF_PURGE_STRATEGY=everything if you would rather not bother with tags on a 5-page site.

A failed purge never fails the save: the content is already on disk, the panel shows a warning, and the page updates when the TTL expires.

Headers are not proof that anything is cached. Cloudflare does not cache HTML by default whatever you send, so the zone needs explicit rules:

  1. Cache Rules → Cache public HTML: http.host eq "example-domain.com" → Eligible for cache, Edge TTL Use cache-control header.
  2. Cache Rules → Bypass panel and forms, above the rule above: http.request.uri.path eq "/admin.php" or http.response.headers["cache-control"][0] contains "no-store" → Bypass cache.

Then verify, twice, rather than trusting the config screen:

A contact page served from a shared cache hands one visitor's CSRF token to the next, which is why form pages are private: true and send no-store. The bypass rule is what makes that survive a cache rule somebody adds later.


SEO

Every page carries a seo: map beside its title and slug. It renders in the panel as a collapsed card at the top of the edit form, and every field in it is optional: an editor who never opens it can still save.

Nothing here has to be filled in. Each empty field falls back, at render time, to something the page already has:

Empty field Falls back to
title the page title
description the page's first textarea or richtext value, tags stripped, cut to 155 on a word boundary
og_image the page's first image, then site.og_default

Both derived answers come off the schema — the first field of that type in block order — not off a "first long text field" heuristic with a length to tune. And both are resolved on the way out, never written back: a derived description sitting in the page file would look filled in from the panel, so nobody would ever replace it with a real one, and it would go stale the moment the copy above it changed. The panel shows it as the description input's placeholder, so an empty box does not read as "this page has no description".

It lives in the per-locale page file, so per-language SEO comes free when Phase 9 resolves a second locale directory — there is no separate SEO store to keep in step. og_image is an ordinary image field, which means its src is restricted to media_bases and its dimensions are server-derived. It is declared decorative: true, so it asks for no alt and emits no og:image:alt: the share card already carries the title and the description as text beside it, and asking a client to describe the same banner on every page buys "εικόνα" typed to clear a field.

canonical is admin-only on purpose. Every other field here costs a client a worse search result if they get it wrong; a canonical pointing at a URL they do not own hands that URL the page's ranking, silently, for weeks.

/sitemap.xml is generated from the content files on request — lastmod from each page's mtime, xhtml:link alternates for the resolved locale, no changefreq or priority, and noindex pages left out. /robots.txt points at it, and honours SITE_NOINDEX with Disallow: / so a pre-launch domain says the same thing in both places a crawler looks.

Both are ordinary routes through index.php. Any server config that intercepts /robots.txt as a static file breaks them — .ddev/nginx_full/nginx-site.conf is taken over from the DDEV default for exactly that reason.

bin/doctor refuses a production box whose site.base_url is still localhost: every absolute URL the site publishes is built from it, and the only symptom of getting it wrong is months of nothing being indexed.


What is deliberately not here

Each of those is a real chunk of work. Leave them out until a client actually pays for one.

Safety net

Every save writes a timestamped copy to content/.revisions/ first and keeps the last 10, and the panel restores one for you (admin only, CSRF-protected, re-sanitised on the way back in — never a cp).

Page writes are atomic (temp file + rename), so an interrupted save cannot leave a half-written page live.

Server backups cover the content directory by default. A site that wants the hourly off-box recovery point makes content/ a git repository of its own — pages, revisions and uploads together, see Where content lives — pushed hourly by bin/backup under the site-wide content lock, so a commit can never catch a half-applied save. bin/restore-drill clones that remote weekly, checks every image a page references is actually in it, and runs bin/doctor against the result. A green backup cron proves a push happened; the drill is what proves the push is a site.

That is one mechanism and one restore: git clone, and the site is whole, pictures included. It stops being right at a gallery site or past a few hundred MB — that site sets R2_ENABLED=1 and the media moves out, which is a config change rather than a rewrite.

Tests

1,099 checks: ddev exec bash tests/run.sh. Run all of them after touching Fields, Admin, Components, Media or anything in bin/.

CI (.github/workflows/ci.yml) runs lint, composer audit, bin/doctor, and the portable suite on PHP 8.4. Real HTTPS probes additionally run in DDEV and at deploy time.


All versions of flatcms with dependencies

PHP Build Version
Package Version
Requires php Version >=8.4
ext-curl Version *
ext-dom Version *
ext-exif Version *
ext-fileinfo Version *
ext-gd Version *
ext-json Version *
ext-openssl Version *
firebase/php-jwt Version ^7.0
symfony/dotenv Version ^8.1
symfony/html-sanitizer Version ^8.1
symfony/http-foundation Version ^8.1
symfony/mailer Version ^8.1
symfony/yaml Version ^8.1
twig/twig Version ^3.21
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package dopamine/flatcms contains the following files

Loading the files please wait ...