winter/wn-cms-module
Winter CMS's core content management module, providing page templates, layouts, partials, and content blocks on top of Laravel. It powers front-end page management with a Twig-based templating engine, letting developers build and edit dynamic sites through an intuitive admin interface.
winter/wn-cms-module is the CMS half of Winter CMS — the thing that turns a Laravel app into a theme-driven site: pages, partials, layouts, content files, Twig tags like {% component %} / {% placeholder %}, the AJAX framework, and the backend CMS editor. You will almost never composer require it directly: it arrives as part of wintercms/winter (or the wintercms/wn-* module set) and shows up in your install as modules/cms. The split repo is a read-only mirror — file issues and PRs against wintercms/winter, not here. Expect to use it, not to develop it, unless you're patching a tag parser or the Controller.
Where to look first, in order:
config/cms.php — active_theme, template_max_size, editable_template_extensions, link_policy, and the security switches (safe_mode, enable_unsafe_markup).modules/cms/classes/ — Controller.php, Router.php, Theme.php, CmsCompoundObject.php (the page/layout/partial base class and its front-matter parsing), ComponentBase.php.modules/cms/twig/ — Extension.php plus the token parsers for {% page %}, {% partial %}, {% content %}, {% component %}, {% placeholder %}.themes/<name>/.First useful thing to build: a layout with {% page %}, {% styles %}, {% scripts %}, one page that uses url, layout and [viewBag] front matter, and one tiny plugin component rendered with {% component %}. That wires up the whole surface area in ~20 lines.
Page front matter is the routing table. Pages are .htm files with INI front matter terminated by a bare == line:
url = "/blog/:slug"
title = "Blog post"
layout = "default"
description = "SEO description"
is_hidden = 0
[blogPost]
slug = "{{ :slug }}"
[viewBag]
sectionClass = "blog"
==
{% component 'blogPost' %}
:slug in url routes the parameter; {{ :slug }} inside a component property block forwards it into the component. Backend-defined [viewBag] values are readable in Twig as {{ viewBag.sectionClass }}. is_hidden = 1 keeps a page out of the backend list without deleting it — handy for partial-like pages driven purely by URL segments.
Components are the unit of PHP-in-template. A component is a class extending Cms\Classes\ComponentBase registered in a plugin's registerComponents(), with markup in components/<name>/default.htm beside the class:
public function defineProperties()
{
return [
'limit' => [
'title' => 'Limit',
'type' => 'string',
'default' => 5,
'validationPattern' => '^[0-9]+$',
'validationMessage' => 'Numbers only',
],
];
}
public function onRun()
{
$this->addJs('assets/js/latest-posts.js');
$this->page['posts'] = Post::take($this->property('limit'))->get();
}
public function onFilter()
{
$this->page['posts'] = Post::where(/* ... */)->get();
return ['#post-list' => $this->renderPartial('@list')];
}
Inside component markup, {{ __SELF__.property('limit') }} reads settings and {% partial '@list' %} resolves a partial inside the component's own directory — the @ prefix is the single most useful bit of component knowledge.
Splitting markup across pages/layouts with placeholders. Layout output goes through {% page %}; pages can inject into named regions:
{# layout: default.htm #}
<head>{% styles %}</head>
<body>{% page %}{% scripts %}</body>
{# a page that needs extra assets #}
{% put styles %}<link href="{{ 'assets/css/blog.css'|theme }}" rel="stylesheet">{% endput %}
Placeholders ({% placeholder hero %}...{% default %}fallback{% enddefault %}{% endplaceholder %}) are the idiomatic way to let a layout expose editable regions without a full page editor. Forget {% styles %}/{% scripts %} in the layout and every addCss()/addJs() call silently vanishes — that's most "assets don't load" reports.
AJAX in the CMS framework. Forms/buttons use data-request, data-request-update, data-request-data, data-request-confirm, data-request-flash, data-request-validate. A component handler returning ['#selector' => $this->renderPartial('@list')] is the reliable pattern; data-request-update="partialName: '#selector'" on the markup side resolves partials through the controller, so keep those partials in the theme (or return HTML from PHP instead of guessing at @ resolution).
URLs and asset URLs are filters, not hardcoded paths. {{ 'blog/post'|page({ slug: 'hello' }) }}, {{ 'assets/css/app.css'|theme }}, {{ 'photos/hero.jpg'|media }}, and {{ 'My theme key'|_ }} for theme lang files in lang/en/lang.php.
Integrating with plugins. Plugins get CMS integration points for free: registerComponents(), registerMarkupTags() (custom Twig functions/filters — the sanctioned way to add helpers, rather than editing the module), and Page::extend() / Theme::extend() in boot() for model-level additions. For hooks at render time, listen on cms.page.beforeDisplay, cms.page.start, cms.page.beforeRenderPage, cms.page.render, cms.page.end, or cms.block.render (partial/placeholder blocks).
winter/wn-system-module breaks in confusing ways. The near-zero dependents count reflects the split-repo packaging, not abandonment.== must be alone on its line. Malformed front matter throws a CmsException that surfaces as a blank page or a generic error — check storage/logs/ before assuming the Twig is wrong.|raw. cms.enable_unsafe_markup (and cms.safe_mode) gate the raw filter and unsafe template abilities, since anyone who can edit a template could otherwise inject JS for every admin. Turn it on deliberately, per environment, not to make one partial render.php artisan cache:clear) and confirm you edited the theme that's actually active — php artisan theme:list / theme:use <dir> use the directory name, not the name: in theme.yaml.config/fields.yaml in the theme defines the editor ({{ this.theme.site_name }} reads it), but saved values persist per environment — a fresh deploy starts from the default: values. Don't treat this.theme as file-config.public function on*() on the component bound to that page, and they must be re-bound after an AJAX partial replaces the DOM. Use delegated listeners or the ajaxUpdateComplete event rather than $(...).on('click') at page load.__SELF__. Two instances of the same component need explicit aliases ([blogPost recentPosts]); a component's partial referring to itself should use __SELF__, and @partial not a theme-relative path.routes.php static routes and Laravel routes win over CMS page URLs. If a page never resolves, check for a conflicting route and whether the page slug is shadowed by a url pattern; pages/404.htm (and error.htm) are your fallbacks, so give them real content early.public/.htaccess or the nginx try_files block), otherwise the index.php/... fallback changes every |page URL you produce.registerMarkupTags() → Event::listen('cms.page.*') → Page::extend(). Editing modules/cms directly is a last resort; it's vendor code and gets overwritten on update.How can I help you explore Laravel packages today?