winter/wn-blocks-plugin
Winter CMS plugin for creating and managing reusable content blocks in the backend, then embedding them in pages, partials, or layouts via Twig. Ideal for editable callouts, CTAs, and shared content regions without duplicating markup.
Install into a Winter CMS 1.1+ project (Composer only — the plugin lives at plugins/winter/blocks, PHP namespace Winter\Blocks):
composer require winter/wn-blocks-plugin
php artisan winter:up # runs updates/ migrations, clears caches
Then confirm it registered by opening Settings → Updates & Plugins and looking for a Blocks entry in the backend navigation (top-level or under CMS, depending on version).
Where to look first, in this order:
plugins/winter/blocks/Plugin.php — this single file tells you everything the plugin contributes: registerNavigation(), registerComponents(), registerSettings(), registerMarkupTags(), and any Boot-time model extensions.models/Block.php + models/block/fields.yaml — the actual data shape. Typical columns are title, slug, type (which partial/block kind to render), content/content_html, is_enabled, sort_order, plus media relations. fields.yaml is the source of truth for what an editor can set.controllers/Blocks.php with config_form.yaml / config_list.yaml — the backend UI contract, list columns, and whether a ReorderController is wired up.updates/ — the migrations. Read these before writing any query; they tell you exact table and column names rather than guessing from the model.components/ — the CMS-facing surface you'll actually call from Twig.First use case: create two or three blocks in the backend (e.g. a "hero" and a "text" type), then render them in a layout partial:
{# themes/mytheme/partials/blocks.htm #}
{% for block in blocks %}
{% partial 'blocks/' ~ block.type ~ '.htm' block=block %}
{% endfor %}
A working render pipeline — one loop and one partial per block type — is the whole mental model of the plugin. Everything else is scoping, ordering, and caching.
Block-as-partial (the core pattern). Store a type slug on each block and use it to resolve a theme partial. Keep a _fallback.htm and render it when the partial is missing so an editor adding a new type never blanks a live page:
{% set partial = 'blocks/' ~ block.type %}
{% partial exists(partial) ? partial : 'blocks/_fallback' block=block %}
Scoping blocks to a page. Rather than a global list, most real sites attach blocks to a page/entry. Two shapes work:
// In a plugin's boot() — attach the relation without forking the Blocks plugin
Page::extend(function ($model) {
$model->attachMany = array_merge($model->attachMany ?? [], [
'blocks' => [\Winter\Blocks\Models\Block::class, 'order' => 'sort_order'],
]);
});
If the plugin already ships a relation, prefer it and just add eager loading at the retrieval point — Page::with('blocks')->find($id) — otherwise every block with a media relation triggers its own query inside the loop.
Component with properties instead of a raw loop. If the plugin ships a component, drive it from the page rather than hardcoding queries in templates: filter by type, limit, group/category, and orderBy via component properties so the same partial serves the homepage hero and the sidebar.
Programmatic creation / seeding. Content blocks are a good fit for migrations and seeders — build a small seeder that asserts by slug so it's idempotent, and use updateOrCreate on a natural key. This keeps block content in version control while letting editors tweak copy afterwards.
Extend the backend form, don't edit the plugin. Add fields to fields.yaml at runtime:
\Winter\Blocks\Models\Block::extend(function ($model) {
$model->bindEvent('model.form.filterFields', function ($form) { /* ... */ });
});
Or override field definitions from your own plugin's Plugin.php with Backend\Widgets\Form::extend(). Never patch plugins/winter/blocks/ — it's Composer-managed and your changes vanish on composer update.
Custom markup tags. If the plugin registers tags (e.g. a {% block %}/{% blocks %} family), wrap them in your own thin Twig partials so themes depend on your API, not on the plugin's. That makes swapping the plugin out later a one-file change.
Layout composition. Because blocks are ordered rows, treat them as a section stack: render each into its own {% put %} placeholder if your theme needs blocks to land in different regions (hero above the fold, CTA in the footer), and keep the default order from sort_order.
Caching. Wrap the block loop in {% cache %} keyed on the page + block collection's updated_at max, and invalidate from a model event:
Block::extend(function ($model) {
$model->bindEvent('model.afterSave', fn () => Cache::forget('blocks:home'));
});
type has no corresponding theme partial renders nothing (or throws a Twig "unable to find partial"), and editors have no idea why their content disappeared. Always ship the fallback branch shown above and log unknown types.ReorderController. If the backend list doesn't expose drag-and-drop, sort_order may still exist in the schema but never be editable. Check config_list.yaml before telling a client they can reorder; otherwise add your own ReorderController in a small companion plugin.is_enabled is a UI affordance, not an automatic scope. If the component doesn't filter it, you must add ->where('is_enabled', true) yourself — and remember front-end previews will then behave differently from the backend list.|raw in Twig. That's fine for trusted admins and a stored-XSS vector the moment you let non-admins (front-end forms, imported content) write blocks. Sanitise on save with an afterSave/mutator hook rather than stripping in the template.block.images, block.author, block.category — any relation touched in the block partial is a query per row. Eager load at the fetch site; the plugin can't do it for you because it doesn't know what your partials touch.{% cache %} don't know blocks changed on save. Wire model.afterSave / model.afterDelete events to a cache key, or editors will report "I saved it and nothing happened."Block declares $propagatable. Relations that aren't propagated will show the same blocks across sites, which looks like a caching bug but isn't.winter.blocks.* permission set at best; if editors should manage content blocks but not delete them, add role permissions in Settings → Administrators rather than relying on the plugin's defaults.updates/ is the API for upgrades. Because adoption is low (30 stars, no dependents), there's no Stack Overflow corpus. When behaviour surprises you, diff the updates/ folder between versions — schema changes arrive as versioned migration scripts, and the changelog in updates/version.yaml is often the only documentation of a breaking change.composer.json and test winter:up in staging. A niche plugin with a single maintainer is exactly the kind of dependency where an unpinned ^1.0 and a silent migration can take a production site down on a Friday deploy.Model::extend() in your own plugin, (3) Form::extend() field overrides, (4) forking into a private acme/blocks-plugin if you need real changes. Reserve (4) for when you've actually hit a wall — the fork cost is a permanent merge burden.How can I help you explore Laravel packages today?