Architecture
How notACMS works internally — the content pipeline, routing system, and build process.
Content Pipeline
Every build follows a four-step pipeline:
1. sass:build → compile SCSS to CSS
2. asset-map:compile → hash and copy assets
3. app:build → render all HTML pages from content + templates
4. pagefind → build the search index
Step 3 is driven by BuildStaticSiteCommand in src/Command/, which scans content, renders templates, generates responsive images, and writes static HTML to public/static/. Step 4 runs npx pagefind as a separate process.
URLs come from the slug frontmatter field, not directory paths. A post at blog/releases/release-1-0-0/en.md with slug: "blog/release-1-0-0" generates /blog/release-1-0-0/.
Routing System
Routes are generated from the content tree. Each ContentItem has a URL defined by its slug frontmatter field.
The LocalizedRouteLoader reads local/content/_routes.yaml and registers Symfony routes for each locale. Default locale (EN) routes are prefix-free. Other locales get a /{locale}/ prefix unless overridden.
Example URL generation:
| Content file | Slug frontmatter | Locale | Generated URL |
|---|---|---|---|
pages/about/en.md |
about |
EN | /about/ |
pages/about/de.md |
about |
DE | /de/about/ |
blog/releases/release-1-0-0/en.md |
blog/release-1-0-0 |
EN | /blog/release-1-0-0/ |
blog/releases/release-1-0-0/pl.md |
blog/release-1-0-0 |
PL | /wpisy/release-1-0-0/ |
Core Services
ContentService
The main facade for loading and querying content. Delegates tree building to ContentTreeBuilder and implements ContentCacheInterface so invalidateCache($locale) can drop cached trees when content changes.
$posts = $contentService->getPosts($locale, 1, 5);
$recent = $contentService->getRecentPosts($locale, 5);
$item = $contentService->findByUrl('/about/', $locale);
ContentItem
Represents a single piece of content. Exposes methods for frontmatter fields:
$item->title() // string
$item->slug() // string — last segment of the slug frontmatter (e.g. "my-post" from "blog/my-post")
$item->url() // string (full URL path, e.g. /blog/my-post/)
$item->date() // ?DateTimeImmutable
$item->updatedDate() // ?DateTimeImmutable
$item->description() // string
$item->tags() // string[]
$item->category() // ?string
$item->image() // ?string
$item->imageAlt() // string (defaults to title)
$item->template() // string
$item->isDraft() // bool
$item->isScheduled() // bool
$item->isPinned() // bool
$item->isDynamic() // bool
$item->isFeatured() // bool
$item->hasToc() // bool
$item->series() // ?string
$item->seriesOrder() // ?int
$item->directoryKey() // ?string
$item->relatedSlugs() // string[]
$item->menuWeight() // int
$item->isIndex() // bool
$item->readingTime() // int
$item->wordCount() // int
$item->excerpt() // string
$item->htmlContent // rendered HTML string (public property)
SiteConfigService
Reads local/content/_site.yaml and exposes site-wide settings as a service. Injected into controllers and Twig globals via SiteConfigExtension.
ContentTree
Internal representation of the content hierarchy for a single locale. Used for navigation, prev/next calculation, and series detection. ContentService wraps it as the facade, so the same lookup names (findByUrl, findPostBySlug, findScheduledPostBySlug) exist on both: the service version takes a $locale and delegates to the per-locale tree.
Key methods:
getAdjacentPosts()— returnsAdjacentPostsvalue object with->prev/->next(?ContentItem)getSeriesPosts()— posts in the same series, sorted byseries_ordergetAllPosts(),getAllPages(),getStaticPages(),getAllItems()getPostsByCategory(),getPostsByTag(),getPostsByYearMonth(),getPostsByYear()getAllTags(),getAllCategories(),getArchiveMonths(),getArchiveYears()findByDirectoryKey(),findByUrl(),findPostBySlug(),findScheduledPostBySlug()
Other Services
| Service | Purpose |
|---|---|
ImageResizer |
ImageMagick wrapper: resize() generates responsive variants; optimize() recompresses in-place |
ResponsiveImageService |
Computes responsive variant widths and builds srcset attribute values |
SrcsetExtension |
Twig filter srcset_media — post-processes HTML to inject srcset/sizes attributes into <img> tags |
MediaFileResolver |
Resolves media file paths across locales |
RelatedPostsService |
Finds related posts by shared tags and manual related frontmatter |
SessionToggleService |
Generic session-based boolean toggle; shared dependency of both preview services below |
DraftPreviewService |
Handles draft content preview with session-based toggles |
ScheduledPreviewService |
Manages scheduled content preview for future-dated posts |
TranslationMapBuilder |
Builds cross-locale URL translation maps for the language switcher |
ContactFormConfig |
Provides contact form configuration (recipient, Turnstile, etc.) |
MarkdownParser |
Converts Markdown to HTML via CommonMark |
SidebarDataProvider |
Assembles sidebar data (categories, tags, recent posts) |
TagTranslationService |
Translates tag slugs between locales |
Twig Extensions
Five Twig extensions expose data to templates:
| Extension | Type | Provides |
|---|---|---|
SiteConfigExtension |
Globals | site_name, site_base_url, site_description, site_social, site_author, site_locales, site_locales_list, site_default_locale, image_variant_widths, new_post_days, coming_soon_reveal_days, meta_description_length |
TranslationMapTwigExtension |
Global | translation_map — {directoryKey: {locale: url}} mapping for language switcher and hreflang tags |
ContentTwigExtension |
Function | content_url(directoryKey, locale) — resolves URL for a content item by directory key |
LangSwitcherExtension |
Function | lang_switch_urls(otherLocales) — resolves language switcher URLs per locale using translation map, controller overrides, and route-based fallbacks (archive → paginated → blog list → home) |
SrcsetExtension |
Filter | srcset_media — post-processes HTML to inject srcset/sizes attributes into <img> tags for responsive images |
Additionally, cf_analytics_token is registered as a Twig global in config/packages/twig.yaml, bound to the CF_ANALYTICS_TOKEN environment variable.
Request Flow (Live Mode)
Browser → nginx → PHP-FPM → Symfony Kernel → LocaleListener → Router
→ Controller → ContentService → Twig → HTML response
Controllers are thin. All business logic lives in services.
Test Strategy
ddev test # Run the full test suite
ddev code-check # PHPStan level 6 + PHP CS Fixer
- Hundreds of test methods across integration and unit suites.
- PHPStan: Static analysis at level 6. All services are fully typed.
- PHP CS Fixer: Enforces PSR-12 + Symfony coding standards.
- Integration tests: Cover routing, content loading, controllers, build output, image processing, preview services, and Twig extensions.
- Unit tests: Cover
ContentItem,ContentTree, value objects,RelatedPostsService,TagTranslationService, andResponsiveImageService.
See docs/TESTING.md for details.
Adding Features
notACMS follows the Symfony service container pattern. To add a new content type or build step:
- For core contributions: Create an interface in
src/Service/, then the implementation. Symfony auto-discovers and autowires it — no manualservices.yamlregistration needed. - For site-specific customizations: Place PHP classes in
local/src/using theNotACms\Local\namespace. These are also auto-discovered. See Customization for details. - Inject the service into the relevant controller or command.
- Override templates in
local/templates/if needed.
Tip: All core services use interfaces. This makes it easy to swap implementations — for example, replacing the local filesystem loader with an S3 source.
PHP Runtime (Optional)
By default, the built site is purely static — Nginx serves public/static/ directly and PHP is not involved.
The production Docker setup in local/docker/ includes an optional PHP container. Set RUNTIME_PHP_ENABLED=true in your production environment to start it. When enabled, Nginx routes /api/ (and /{locale}/api/) to the PHP container — everything else continues to be served as static files.
The default use case is the contact form (ContactController via SMTP), which lives at /api/contact.
Note: Local development (
ddev start) runs the full Symfony application dynamically. This is a dev-only convenience — it is not part of the production PHP runtime.
Deployment Architecture
Static-First Serving
Nginx serves pre-built files from public/static/ directly. PHP is only involved for:
/api/contact— ifRUNTIME_PHP_ENABLED=true- Development mode (
ddev start)
Production traffic flow:
Browser → Nginx → public/static/ (HTML, CSS, JS)
→ Proxy to PHP container (only for /api/*)
Build vs Runtime
| Phase | Command | Output | Duration |
|---|---|---|---|
| 1. Compile | sass:build |
public/assets/app-<hash>.css |
~2s |
| 2. Assets | asset-map:compile |
Hashed asset copies in public/assets/ |
~1s |
| 3. Build | app:build (BuildStaticSiteCommand) |
public/static/**/*.html |
~5s |
| 4. Index | pagefind |
public/pagefind/ search index |
~3s |
| 5. Serve | nginx | HTTP responses | ongoing |
Total first deploy: ~2-3 minutes (Docker build) Content update: ~10 seconds
Container Profiles
| Profile | Containers | Use case |
|---|---|---|
| (none) | nginx only | Static sites, no contact form |
--profile runtime |
nginx + php | Contact form enabled |
Switch profiles by setting RUNTIME_PHP_ENABLED in .env.local.