wintercms/winter
Winter CMS is a free, open-source content management system built on Laravel. Forked from October CMS and maintained by the community, it offers a powerful plugin marketplace, Twig-based templating, and a flexible admin interface for building and managing websites quickly.
Know what you're installing. Winter CMS is not a library you require into an existing app — it's a Laravel application skeleton (Winter 1.x tracks a pinned Laravel major). That's why "dependents: 0" is meaningless here: nobody consumes it as a dependency. Your day-to-day code lives in plugins/ and themes/; modules/ (System, Backend, Cms) and vendor/ are core and are overwritten on upgrade.
If you only want the good parts inside an existing Laravel app, install wintercms/storm instead. Storm is the extracted foundation (Storm Model, behaviors, Halcyon flat-file models, YAML/Bracket parsers, Twig integration, Argon asset compiler). That is the single most useful escape hatch for a Laravel dev evaluating this project.
composer create-project wintercms/winter myapp
cd myapp
php artisan winter:env # .env + APP_KEY
php artisan winter:install # migrations + backend admin user
php artisan serve # backend at /admin
If you're upgrading to v1.2.14 or later, do one grep before you ship. Page action methods in CMS/controller classes must now be lowercase snake_case (coming_soon()), and dashed URLs are normalised to snake_case, so /coming-soon resolves to coming_soon() rather than comingSoon(). Rename the method and its matching view file; the published URL stays the same.
grep -rnE 'function on?[a-z]+[A-Z]' themes plugins --include=*.php # find camelCase page actions
php artisan winter:update && php artisan winter:version
Where to look first, in order:
plugins/ — an example plugin is usually shipped; read its Plugin.php top to bottom.themes/<theme>/layouts|pages|partials — the front-end contract.config/cms.php — activeTheme, backendUri, editable-plugin flags, asset/caching behavior.vendor/winter/storm/src/Database/Model.php — everything you'll be surprised by lives here.First real use case (the canonical loop): scaffold a plugin, give it a model + backend CRUD form, register a component, and drop that component onto a CMS page in the theme. When that loop works, you understand 80% of Winter.
php artisan create:plugin Acme.Blog
php artisan create:model Acme.Blog Post
php artisan create:component Acme.Blog PostList
1. Plugins are the unit of work, not "the app". Each plugin has a Plugin.php where you register components, permissions, backend navigation, Twig filters, console commands, and schedules (registerSchedule). Keep business logic in plugins so themes stay disposable and upgrades don't touch your code.
class Plugin extends \System\Classes\PluginBase
{
public function registerComponents(): array
{
return ['Acme\Blog\Components\PostList' => 'postList'];
}
public function registerPermissions(): array
{
return ['acme.blog.manage_posts' => [
'tab' => 'Blog', 'label' => 'Manage blog posts',
]];
}
public function boot(): void
{
Post::extend(function ($model) {
$model->addDynamicMethod('isPublished', fn () => $model->published_at !== null);
});
}
}
2. Use Storm's model, not Eloquent's. Winter\Storm\Database\Model is a superset: $rules + Validation trait, $jsonable, $encrypted, attachOne/attachMany (via System\Models\File), attribute modifiers, and opt-in traits you'll reach for constantly — Sortable, NestedTree, Sluggable, Revisionable, Multisite, Purgeable. Query builder and relationships are unchanged, so Eloquent knowledge transfers directly.
class Post extends \Winter\Storm\Database\Model
{
use \Winter\Storm\Database\Traits\Validation;
use \Winter\Storm\Database\Traits\Sluggable;
public $rules = ['title' => 'required|max:255'];
protected $slugs = ['slug' => 'title'];
public $attachOne = ['cover' => \System\Models\File::class];
public $belongsTo = ['author' => \Backend\Models\User::class];
}
3. Write YAML, not forms. The backend CRUD behaviors are the biggest productivity win in the whole package. A controller with three behavior declarations plus fields.yaml/columns.yaml gives you a searchable, sortable, permission-aware admin UI with zero HTML.
class Posts extends \Backend\Classes\Controller
{
public $implement = [
\Backend\Behaviors\FormController::class,
\Backend\Behaviors\ListController::class,
\Backend\Behaviors\RelationController::class,
];
public $formConfig = 'config_form.yaml';
public $listConfig = 'config_list.yaml';
public $relationConfig = 'config_relation.yaml';
}
# models/post/fields.yaml
fields:
title:
label: Title
span: left
required: true
published_at:
label: Published
type: datepicker
mode: date
span: right
cover:
label: Cover image
type: fileupload
mode: image
4. Use fieldset to group dense forms (new in v1.2.14). Instead of long flat field lists, wrap related fields in a fieldset FormWidget so the backend reads as sections. It's a visual grouping only — fields keep their own names, so validation, permissions, and formExtendFields listeners are unaffected.
fields:
_publishing:
type: fieldset
label: Publishing
fields:
- published_at
- status
5. Prev/next record navigation (new in v1.2.14). Forms now expose previous/next record navigation out of the box, so editors can walk through a list without bouncing back to it. It follows the list's current ordering, so if your columns.yaml order looks wrong in the admin, check the list config, not the form. It's toggled through the Form behavior's config — leave it on for editorial/content models, and turn it off for singleton-ish settings records where it makes no sense.
6. Front-end is Twig + components, and it is not Blade. Pages/partials/layouts live in the theme; components are PHP classes that inject variables into $this->page and expose AJAX handlers.
class PostList extends \Cms\Classes\ComponentBase
{
public function componentDetails(): array
{
return ['name' => 'Post List', 'description' => 'Lists published posts'];
}
public function defineProperties(): array
{
return ['limit' => ['title' => 'Limit', 'default' => 10, 'validation' => 'numeric']];
}
public function onRun(): void
{
$this->page['posts'] = Post::whereNotNull('published_at')
->with('author') // always eager load what the template touches
->latest()->limit($this->property('limit'))->get();
}
public function onLoadMore(): array
{
return ['#post-list' => $this->renderPartial('post-list.htm')];
}
}
{% component 'postList' %}
{% for post in posts %}
<a href="{{ 'blog/post'|page({ slug: post.slug }) }}">{{ post.title }}</a>
<img src="{{ post.cover.path|media|resize(600, 400) }}" alt="">
{% endfor %}
<button data-request="onLoadMore" data-request-update="'post-list': '#post-list'">More</button>
The |media, |theme, |page, |resize, |md, |trans filters are Winter-specific and are what you'll use daily instead of asset()/route().
7. Page actions are snake_case methods. Handlers reached by URL use the same onSomething() convention but must be lowercase-snake as of v1.2.14. Keep them thin, delegate to a service/component method, and name the view file to match the method (coming_soon() → coming_soon.htm).
class ComingSoon extends \Cms\Classes\Controller
{
public function coming_soon(): string // /coming-soon, /comingSoon both resolve here
{
return $this->makePartial('coming_soon');
}
}
8. Extend, never fork. For any third-party plugin, prefer: Event::listen('backend.form.extendFields', ...), Event::listen('backend.menu.extendItems', ...), class extend() closures with addDynamicMethod, and the behavior hooks (listExtendColumns, listExtendQuery, formExtendFields, relationExtendManageWidget) defined on your own controller. Forking a marketplace plugin means you own every future security patch — and v1.2.14 shipped five of them.
9. Settings. Prefer a plugin models/Settings.php (database-backed, editable from a backend Settings page via registerSettings()) over hardcoding config. Reserve config/ for environment-shaped values.
10. Composer-installed plugins/themes. Winter supports winter/wn-* Composer packages alongside git-cloned plugins in plugins/. Composer-managed ones are treated as read-only in the backend editor — which is correct: do your own overrides in a first-party plugin, not by editing vendor/.
11. Deployment: composer install --no-dev, then php artisan winter:update (runs core/plugin version scripts from updates/version.yaml), then php artisan winter:util compile assets for any Less/SCSS, then php artisan cache:clear. Verify with php artisan winter:version. Ensure storage/ and bootstrap/cache/ are writable. v1.2.14 bumps assetic/framework to v3.2.2 — rebuild compiled assets rather than assuming the existing ones still apply.
Table widget, hardening of the image column type against untrusted input, tighter permission checks in the import/export controllers, hardening of the MyAccount controller from v1.2.13, and backend routing changes that close off CSRF attempts against AJAX handlers. If you expose the backend to non-superusers, you are affected. The routing/CSRF change is the one most likely to surface as "my custom AJAX POST now 403s" — check your custom data-request handlers send the expected token/headers before assuming it's a bug.comingSoon() no longer resolves; /coming-soon maps to coming_soon(). Rename method and view file, keep the URL. Anything calling those actions via AJAX (data-request="onComingSoon") or $this->action references needs the same sweep.modules/ or vendor/. Everything you write belongs in plugins/ or themes/. Core updates are destructive; there is no merge story.Illuminate\Database\Eloquent\Model silently loses $rules validation, file attachments, and every trait. If a backend form "just doesn't validate", check the parent class first.fields.yaml can surface as an empty form or a missing field rather than a parse error. When a field vanishes, Log::debug() the parsed config or check the backend error page's "trace" tab. The new fieldset widget nests fields: one level deeper — that indent is the easiest mistake to make in the whole file.type: relation requires an actual relationship method with that exact name; type: fileupload requires attachOne/attachMany.$requiredPermissions (and a matching registerPermissions() entry) works for superusers and 403s for everyone else. This bites in staging every single time — and v1.2.14 tightened this class of check, so any controller that was "accidentally working" may start correctly refusing.@if, no {{ $var }} (it's {{ var }}), and not every Laravel helper is registered. Component aliases can collide across plugins — reference the fully qualified alias ({% component 'acmeBlogPostList' %}) when you hit "component not found" or duplicated output.public function onSomething(). Returning $this->page re-renders the whole page; returning ['#selector' => $html] updates a partial. For user-facing validation errors, throw Winter\Storm\Exception\ValidationException — the AJAX framework renders it in the form automatically. Laravel's exception won't.php artisan cache:clear plus clearing storage/framework/views. In development, disable the template cache in config/cms.php.composer update. Version scripts in updates/version.yaml only execute when php artisan winter:update runs, and only if you bumped the version. Plain Laravel migrations in database/migrations still go through php artisan migrate — pick one convention per plugin and stick to it./api/v1/...) and register them in your plugin's register().{{ post.author.name }} inside a loop lazy-loads per row. Use with() in the component query; for backend lists, set select: and with: in columns.yaml / listExtendQuery. v1.2.14 cut a chunk of front-end queries from CMS templates, theme data, request logs, and Halcyon mtime checks — measure before adding your own caching layer on top, since the old baselines are now stale.php artisan winter:util for cache/thumbnail purges) before you start commenting out Plugin.php files by hand.php artisan winter:test runs plugin tests when present; standard Laravel feature tests against plugin routes work normally, but backend behavior widgets are easier to trust via a seeded DB than to unit test. Add a test asserting your renamed page action resolves from its dashed URL, since that's a silent-404 class of regression.Event::listen for backend lifecycle, registerMarkupTags() for custom Twig filters/functions, custom Response/middleware for headers and caching, and Winter\Storm\Halcyon\Model for config-like flat-file records that shouldn't hit the database.How can I help you explore Laravel packages today?