Winter core sits on top of Winter Storm (installed at vendor/winter/storm/), which sits on top of Laravel, which sits on top of Symfony components. The helper you want almost certainly already exists at one of those layers. Search Storm → Laravel → Symfony before writing any "small utility", often with safer edge-case handling than a fresh implementation would have.
-
Storm itself — each
vendor/winter/storm/src/<Module>/README.mdcatalogues that module's public API:Filesystem/—PathResolver(resolve,within,join,standardize),Filesystem(extends Illuminate's; addsisAbsolutePath,symbolizePath,existsInsensitive,chmodRecursive)Support/— strings, arrays, class loadingNetwork/,Html/,Parse/,Database/,Halcyon/,Auth/, etc.- Path helpers (always loaded):
themes_path(),plugins_path(),media_path(),uploads_path(),temp_path()— use these instead ofbase_path('themes')etc.
-
Laravel (Illuminate) — everything Laravel ships is available:
Illuminate\Support\Str—Str::startsWith/endsWith/contains/before/after/between/slug/camel/snake/kebab/studly/random/uuid/limit/mask/finish/start/of/headline/title. Use instead of regex one-liners.Illuminate\Support\Arr—Arr::get/set/has/forget/only/except/dot/undot/flatten/pluck/wrap/first/last/where. Use instead of nested foreach.Illuminate\Support\Collection(viacollect()) — chainable map/filter/reduce.Illuminate\Filesystem\Filesystem—deleteDirectory(),cleanDirectory(),copyDirectory(),moveDirectory(),allFiles(),glob(),isDirectory(),prepend(),append(),replace(),hash().- Global helpers:
data_get/set/fill,value,tap,optional,transform,head,last,class_basename,now,today,e,__/trans,cache,config,env,app,resolve,route,url,report,rescue,retry,throw_if/unless,abort/abort_if/unless. - Facades:
Cache,Config,DB,Event,File,Hash,Http,Lang,Log,Mail,Queue,Redis,Route,Schema,Session,Storage,URL,Validator,View.
-
Symfony components at
vendor/symfony/:console,css-selector,error-handler,event-dispatcher,finder,http-foundation,http-kernel,mailer,mime,process,routing,string,translation,uid,var-dumper,yaml. Most commonly reached for:Symfony\Component\Finder\Finder—Finder::create()->files()->name('*.less')->in($dir)replaces RecursiveIteratorIterator chains.Symfony\Component\Filesystem\Filesystem—dumpFile()(atomic write),mirror(),mkdir()(idempotent),remove(),symlink().Symfony\Component\Process\Process— safe external command execution instead ofexec()/shell_exec().Symfony\Component\Yaml\Yaml— strict YAML.Symfony\Component\String\— Unicode-aware strings.Symfony\Component\Uid\Uuid/Ulid— UUID/ULID generation.
grep -rl 'function <thing>' vendor/winter/storm/src/ vendor/laravel/framework/src/ vendor/symfony/ is a 10-second check.
Paths and filesystem:
| If you reach for… | Use this instead |
|---|---|
realpath() + null-check + slash-trim |
\Winter\Storm\Filesystem\PathResolver::resolve() |
str_starts_with($path, $root) to gate file access |
PathResolver::within($path, $root) — separator-boundary safe |
Manual base_path('themes') / base_path('plugins') |
themes_path() / plugins_path() (Storm's autoloaded helpers) |
Recursive rmrf in tests |
\File::deleteDirectory($path) (Laravel facade) |
| Detect absolute path | (new \Winter\Storm\Filesystem\Filesystem())->isAbsolutePath($path) |
Custom path-symbol resolution (~/...) |
(new \Winter\Storm\Filesystem\Filesystem())->symbolizePath($path) |
str_replace('\\', '/', $path) (cross-platform comparison) |
(new \Winter\Storm\Filesystem\Filesystem())->normalizePath($path) |
str_replace('/', DIRECTORY_SEPARATOR, $path) (handing to OS API) |
\Winter\Storm\Filesystem\PathResolver::standardize($path) |
| Atomic file write (avoid partial-write races) | (new \Symfony\Component\Filesystem\Filesystem())->dumpFile($path, $contents) |
| Find files matching a pattern | \Symfony\Component\Finder\Finder::create()->files()->name('*.ext')->in($dir) |
Strings and arrays:
| If you reach for… | Use this instead |
|---|---|
preg_match('/^prefix/', $s) |
Str::startsWith($s, 'prefix') (accepts array of prefixes) |
Manual strpos !== false |
Str::contains($s, $needle) |
strtolower-then-replace slug generation |
Str::slug($s) |
| Random hex/string for tmp paths, tokens | Str::random() / Str::uuid() |
| Deep array key access with null safety | data_get($array, 'a.b.c', $default) |
| Pulling subset of array keys | Arr::only($array, [...]) / Arr::except($array, [...]) |
| Chained map / filter / reduce on array | collect($array)->filter(...)->map(...)->values()->all() |
Other:
| If you reach for… | Use this instead |
|---|---|
exec() / shell_exec() / backticks |
(new \Symfony\Component\Process\Process([$cmd, ...$args]))->mustRun() |
| Manual YAML parsing | \Symfony\Component\Yaml\Yaml::parse() |
| JSON parsing without strict error handling | json_decode($s, true, 512, JSON_THROW_ON_ERROR) |
UUID generation by random_bytes + hex shuffle |
Str::uuid() or \Symfony\Component\Uid\Uuid::v7() |
| HTTP request to external service | Laravel's \Http::get(...) facade |
Storm depends on Laravel + Symfony pieces. It must not depend on Winter modules. Conversely, Winter core modules are free to use Storm. So:
- CMS / theme / plugin / system concerns live in
modules/(backend, cms, system). - Generic filesystem, path, parser, network primitives live in
vendor/winter/storm/. - When a Storm class needs a policy that's CMS-specific (e.g. "which directories count as theme asset roots"), expose a public setter on the Storm class and have the module-level caller supply the policy. Don't reach into module-level constants from Storm.
Modules and plugins are autoloaded by Winter\Storm\Support\ClassLoader (see ClassLoader::load()), not plain PSR-4. Its convention: the namespace's directory segments are lower-cased to form the path, while the class file keeps its proper PascalCase name. So System\Twig\SecurityPolicy\SafeCollection resolves to modules/system/twig/securitypolicy/SafeCollection.php — note the lowercase securitypolicy/ directory.
- New sub-namespace directories must be lowercase on disk, even though the namespace segment stays PascalCase:
System\Twig\Node→modules/system/twig/node/, notNode/. The file name keeps its PascalCase and must match the class name exactly. - This only bites on case-sensitive filesystems: a capitalized directory works on macOS/Windows (case-insensitive) and passes local tests, then fails Linux CI with
Class "…" not found. If Windows CI is green but every Ubuntu job fails to find a class, suspect a directory-case mismatch.
- Backend/CMS/system tests usually extend
System\Tests\Bootstrap\PluginTestCase(boots Laravel, plugins, auth) orSystem\Tests\Bootstrap\TestCase(boots the framework but not plugins). - Fixtures that flow through
Assetic\Asset\FileAsset(e.g.CombineAssets::combineToFile()) must live underbase_path().sys_get_temp_dir()will fail with "source is not in the root directory". Usebase_path('storage/framework/cache/<unique>')for temp dirs and clean up with\File::deleteDirectory()intearDown(). - A failing test that doesn't reproduce on a fresh clone is almost always a stale local
vendor/. Runcomposer updatein Storm (~/Repositories/WinterCMS/Core/stormor wherever you check it out) before claiming "environment issue".
CI runs a PHP code-quality check (the fast PHP job, ~15s) that fails the whole PR on style violations, separate from the test matrix. Run the exact same check locally on your changed files before pushing — it's instant and saves a CI round-trip:
vendor/bin/phpcs -nq --report=full --extensions=php <changed .php files>
(CI's .github/workflows/utilities/phpcs-pr runs phpcs against just the files changed vs. the base branch, using the repo-root phpcs.xml ruleset.) The sniff that bites templates: alternative-syntax control blocks must span multiple lines — <?php if ($x): ?>attr="…"<?php endif ?> on one line fails ("Newline required after opening brace" / "Closing brace must be on a line by itself"); put the if:, body, and endif on their own lines (see modules/backend/formwidgets/fileupload/partials/_file_single.php).
When a change touches both, work in the actual local checkouts (e.g. ~/Repositories/WinterCMS/Core/storm and ~/Repositories/WinterCMS/Core/winter) on parallel branches, and symlink vendor/winter/storm to the local Storm checkout so changes are visible in the live install. Avoid /tmp worktrees — the user can't test what they can't see.
Open both PRs concurrently; the maintainer handles merge order and Storm release tagging. The Winter core PR's composer.json constraint bump waits for the Storm tag.
Plugins are commonly symlinked into plugins/<author>/<name> to develop them from their own checkout. Four things bite, in order of how much time they waste:
- Assets 404 until mirrored. When the docroot is a
public/mirror, module/plugin assets are served from symlinks underpublic/. After adding or symlinking a plugin, runphp artisan winter:mirror public --relative(thenphp artisan winter:upto migrate). Skip it and the plugin's assets 404 — and because a browser ORB-blocks a cross-origin script that comes back astext/html(the 404 page), JS-driven UIs render blank with no console error. If a plugin "loads but its editor/widget/builder area is empty", check the network tab for 404'd assets before suspecting the code. - Vite builds can't resolve the workspace's
node_modules.vite:compile <Plugin>loads the plugin'svite.config.mjsfrom the symlink's real path, so Node resolvesnode_modulesupward from there and misses the project install (@vitejs/plugin-vue,laravel-vite-plugin, …). Bridge it withln -s <project>/node_modules <plugin-real-path>/node_modules(gitignored), thenvite:install <Plugin>/vite:compile <Plugin>. Commit the regeneratedassets/dist/, not the generatedpackage-lock.json. Vite also caches builds in<plugin>/node_modules/.vite, so a source edit can silently produce identical output — ifvite:compiledoesn't seem to reflect your change,rm -rf <plugin-real-path>/node_modules/.viteand recompile. - PackageManager once dropped symlinked packages entirely. The asset
PackageManagerresolved each package to its realpath (outsidebase_path()), so Vite/Mix packages in symlinked plugins failed to register and never served. Fixed inmodules/system/classes/asset/PackageManager.php(registerPackage()keeps the in-project path when a symlink resolves outside the project). PreferPathResolver::within()/PathResolver::resolve()over hand-rolledstr_starts_with/realpathwhen touching that path logic. vite:watch(HMR) writes an unreachable hot-file URL on IPv6 hosts.php artisan vite:watch <Plugin>runs a dev server with HMR — edits toassets/src/**reload live in the browser with no manualvite:compile/winter:mirror, which is by far the fastest loop for styling work. But it binds--hostto all interfaces, solaravel-vite-pluginwriteshttp://[::]:5173into<plugin>/assets/dist/hot, which a browser can't reach — the backend then loads unstyled. Fix after starting the watch:echo -n 'http://localhost:5173' > <plugin>/assets/dist/hot, then reload the page once so the backend serves from the dev server. Stopping the watch removes the hot file and recompilesdist/; verify it's gone afterwards or the backend keeps pointing at the dead dev server. Tracked upstream in #1525. (This is the Vite pipeline's fast loop — see "Fast styling-iteration loop" below for the browser-driven half, which is the same across all three asset pipelines.)
Iterating on backend styling via edit → recompile → winter:mirror → screenshot is slow, and headless screenshots also mislead (a headless synthetic click fires :focus-visible that a real mouse click doesn't). Two MCP servers make this dramatically faster — add them once with claude mcp add <name> -s user -- npx <pkg>:
chrome-devtoolsMCP (chrome-devtools-mcp) — recommended default. Drives a persistent, logged-in real Chrome.evaluate_scriptinspects computed styles and drives the UI in a single call;take_screenshot/take_snapshotfor visual/a11y checks. No throwaway*.jsscripts, no browser relaunch between turns, no headless render/focus artifacts.playwrightMCP (@playwright/mcp) — general browser automation (structured a11y snapshots + actions) for scripted multi-step flows rather than DevTools-style inspection.
The high-leverage habit is live-inject-then-commit: find CSS values by injecting a <style> via evaluate_script, eyeball it in the real page with zero compiles, and only write to source once it's right. If an injected rule works but disappears after a build, the build ate it — a much faster diagnosis than chasing specificity.
That browser half is compiler-agnostic. Only the step that turns a source edit into a page change differs by pipeline:
- Vite (
addVite, e.g. Winter.TailwindUI):php artisan vite:watch <Plugin>→ HMR, the edit appears live with no compile/mirror/reload. Fix its IPv6 hot-file bug first (see the symlinked-plugins section above / #1525). - Mix / laravel-mix (a
mix:compilepackage, e.g. LukeTowers.EasyForms's formbuilder widget):php artisan mix:watch <Plugin>→ recompiles to disk on save (no HMR), so reload the page to see it. On a mirror docroot the plugin dir is symlinked intopublic/, so no re-mirror is needed.- Always scope with
-p:mix:compile -p <package>. A bare positional arg (mix:compile luketowers.easyforms) is treated as a webpack passthrough, not a package filter, so it compiles every registered Mix package — includingmodule-backend.formwidgets.codeeditor. That build can fail (ENOENT: js/build/codeeditor.bundle.js) and delete the committedcodeeditor.bundle.js+ chunks, leaving the Monaco codeeditor broken everywhere (it silently fails to register with Snowboard). After anymix:compile,git statusthe build dirs andgit checkout -- <path>to restore anything a failed sub-build wiped. Add-ffor a production (minified) build matching committed output.
- Always scope with
- Asset combiner / LESS: there is no watch. Precompiled module CSS (
modules/*/assets/css/*.css, e.g.winter.css) needs an explicitphp artisan winter:util compile less(see below), then reload. PluginCombineAssetsbundles (.less/.jsarrays passed toaddCss/addJs) recompile on the fly in dev, so editing source and reloading is enough.
Two traps in this loop cost real round-trips — worth internalising:
getComputedStyleread in the same script call that just changed a style is stale. Injecting a<style>(or settingelement.style) and readinggetComputedStyle(el).<prop>in oneevaluate_scriptreturns the pre-recalc value — it will reportrgba(0, 0, 0, 0)even for an inline!importantthat is actually applied. Don't conclude "my override lost the cascade" from that read. Trusttake_screenshot(ground truth), or read the computed value in a separate call after styles settle. A whole "cross-origin!importantis beating me" rabbit hole this session was just this stale read plus a leftover debug<style>.- Cache-busting an asset only works with a fresh
?vstring. Compiled assets are versioned by a query param (?v<system::core.build>for core/module assets,?v<plugin version>for plugins). Bumping the param busts the browser cache only if the value has never been served before — restoring a previously-used value (e.g. back to canonical1.2.14after testing under1.2.14-t5) re-serves the stale copy the persistent browser context cached at that exact URL. To verify a rebuild, bump to a fresh unique value (1.2.14-v2); to confirm the server has the new file regardless of cache,fetch(url, { cache: 'reload' })and inspect the text. In production the natural version bump on release busts it everywhere; locally a hard-refresh also works.
The *.css under modules/*/assets/css/ (e.g. modules/backend/assets/css/winter.css) are build artifacts compiled from the LESS under modules/*/assets/less/. Edit the .less source and recompile — never patch the .css directly, or the next compile silently reverts you and reviewers can't trace the change to a source.
- Recompile with
php artisan winter:util compile less. - Caveat: that command recompiles every registered LESS package, including symlinked plugins. Some plugins ship a
.cssthat concatenates vendored third-party CSS (e.g. a datepicker) which their.lessdoes not reproduce — recompiling truncates those files. After a compile,git statusthe plugin repos and revert any clobbered vendored CSS (git checkout -- assets/css/<file>.css). - Compiled output tracks the local
browserslist; a stale one (the "caniuse-lite is N months old" warning) drifts vendor prefixes vs. the committed baseline. Runnpx update-browserslist-db@latestif you need the diff to stay minimal.
The backend renders under a skin set by cms.backendSkin (default Backend\Skins\Standard). The Winter.TailwindUI plugin overrides it to its own skin in boot() (Config::set('cms.backendSkin', …)), restyling the whole backend — so a widget can look right under one skin and broken under the other (missing buttons, wrong spacing).
- Toggle it with the plugin:
art plugin:disable winter.tailwindui/art plugin:enable winter.tailwindui(art=php artisan). Disabling drops back to the default Standard skin. - When adjusting a core widget's styling (e.g. the
iconpickermodal), do the first pass with TailwindUI disabled so you're tuning the core/default baseline, then re-enable it and fix whatever the skin layers on top (buttons not visible, etc.). Core style changes live inmodules/*/assets/less/**— recompile withwinter:util compile less(see "Never hand-edit compiled CSS").