Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Winter Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. plugins/ — an example plugin is usually shipped; read its Plugin.php top to bottom.
  2. themes/<theme>/layouts|pages|partials — the front-end contract.
  3. config/cms.php — activeTheme, backendUri, editable-plugin flags, asset/caching behavior.
  4. 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

Implementation Patterns

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.

Gotchas and Tips

  • v1.2.14 is a security release — treat it as urgent, not routine. It fixes a reflected XSS in the backend 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.
  • Page action renaming is the breaking change in v1.2.14. 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.
  • Don't edit modules/ or vendor/. Everything you write belongs in plugins/ or themes/. Core updates are destructive; there is no merge story.
  • Storm vs Eloquent model is a one-way door. Extending 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.
  • YAML indentation failures are the #1 time sink. A malformed 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.
  • Field names must resolve to a real column, relation, or accessor. type: relation requires an actual relationship method with that exact name; type: fileupload requires attachOne/attachMany.
  • Permissions are required, not optional. A backend controller without $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.
  • Twig ≠ Blade. No @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.
  • AJAX handlers must be 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.
  • Compiled Twig templates and parsed YAML are cached. Stale front-end output after an edit usually means php artisan cache:clear plus clearing storage/framework/views. In development, disable the template cache in config/cms.php.
  • Plugin migrations don't run from 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.
  • Route shadowing. The CMS page router is effectively a catch-all; a page slug matching your plugin route will win or lose depending on registration order. Prefix API/webhook routes (/api/v1/...) and register them in your plugin's register().
  • N+1 in Twig is invisible. {{ 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.
  • Recovery from a bad plugin. Winter has a safe mode that boots the backend with plugins/themes disabled — reach for it (and php artisan winter:util for cache/thumbnail purges) before you start commenting out Plugin.php files by hand.
  • Halcyon force-delete is now recoverable. v1.2.14 fixed a datastore getting stuck in force-deletion mode after an exception. If you have an old datastore in that state, retry the delete after upgrading rather than hand-editing files.
  • Testing: 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.
  • Extension points worth remembering: 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib