winter/storm
Winter Storm is the Laravel-based core library behind Winter CMS, providing the module and plugin system, backend scaffolding, and shared helpers that power the platform. Use it to build or extend Winter CMS applications on a familiar, well-documented foundation.
Storm is not a "require it and read the docs" library — it's the substrate under Winter CMS. So the first step is deciding which Storm you're using:
If you're on Winter CMS already: Storm is in your dependency tree. You don't install it; you learn it. Start by listing what's available so you stop reinventing helpers:
ls vendor/winter/storm/src/ # module inventory
cat vendor/winter/storm/src/Filesystem/README.md # per-module API catalogue
Each src/<Module>/ has a README. Read Filesystem first — it's the highest-value module and the one whose absence from your mental model causes the worst bugs.
If you're on plain Laravel and want it anyway: don't start with composer require winter/storm. Start with a throwaway spike that only touches static helpers. Confirm three things before committing:
composer require winter/storm resolves against your PHP/Laravel constraints.PathResolver and Filesystem are usable once autoloaded, without the Winter application class.If those three fail, vendor the classes instead (MIT permits it, with attribution) and skip the dependency graph.
First use case — the one that pays for the read time immediately:
use Winter\Storm\Filesystem\PathResolver;
// Instead of realpath() + null-check + trim
$real = PathResolver::resolve($userSuppliedPath);
// Instead of str_starts_with($path, $root) — which is prefix-collision-broken
if (!PathResolver::within($real, $uploadsRoot)) {
abort(403);
}
within() is separator-boundary aware after the GHSA-58fp fix. That means /var/www/secret_other correctly fails containment against /var/www/secret. The hand-rolled version of that check passes — and that's the class of bug that ends up in a CVE.
Day-one checklist:
src/Filesystem/README.md and skim PathResolver's source.agents/AGENTS.md (bundled with the repo). It's the search-order policy: Storm → Laravel → Symfony, never hand-roll. Adopt it whether or not you adopt the code.The substitution table is the daily workflow. Treat it as a lookup, not documentation. Before writing a helper, grep:
grep -rl 'function <thing-you-need>' \
vendor/winter/storm/src/ \
vendor/laravel/framework/src/ \
vendor/symfony/
Ten seconds against a hundred-line mistake. The patterns that come up most in day-to-day work:
Path containment on any user-supplied path. File managers, theme editors, plugin uploaders, media libraries — anywhere a path crosses a trust boundary:
$full = PathResolver::join($base, $relative);
$real = PathResolver::resolve($full); // open_basedir-safe, non-existent-path-safe
if (!PathResolver::within($real, $base)) {
throw new \RuntimeException('Path escapes base');
}
This is the pattern. resolve() then within() then use the resolved path — never the raw input, and never re-derive after the check (TOCTOU: resolve once, use that resolution).
Cross-platform path handling. If you ship to Windows or your CI is Linux while prod is mixed:
$fs = new Winter\Storm\Filesystem\Filesystem();
$fs->isAbsolutePath($p); // covers Unix, Windows drive letters, and URL schemes — better than str_starts_with('/')
$fs->normalizePath($p); // backslash → forward slash, for comparison
$fs->symbolizePath($p); // ~/ and $/ symbol expansion
PathResolver::standardize($p); // forward slash → DIRECTORY_SEPARATOR, for OS APIs
Separate the two concerns: normalizePath on comparison, standardize on handoff to the filesystem.
Recursive directory ops. Storm's Filesystem extends Illuminate's, so you get both:
$fs = new Winter\Storm\Filesystem\Filesystem();
$fs->deleteDirectory($tmp); // Storm/Illuminate
$fs->existsInsensitive($p); // Storm addition — case-insensitive existence check
Atomic writes are not here — reach for Symfony's Filesystem::dumpFile(), which Storm's own internals rely on for the same reason you should (partial-write races on flat-file stores):
(new \Symfony\Component\Filesystem\Filesystem())->dumpFile($path, $contents);
Finding files. Don't write RecursiveIteratorIterator + RegexIterator chains:
Finder::create()->files()->name('*.less')->in($dir)
Flat-file models (Halcyon). Only if you have a concrete requirement — this is where Storm's CMS container assumptions live, and where a plain Laravel app starts inheriting abstractions it doesn't need. If you do use it, the atomic-write and single-node constraints are yours to manage: flat-file models have no transactional semantics across distributed writers, and cache invalidation is your problem. Do not put Halcyon behind a horizontally scaled write path without a design review.
Contract tests as the integration pattern. Every Storm upgrade should be gated by behaviour you already asserted. This is the day-to-day discipline that makes the dependency safe:
public function test_path_containment()
{
config(['open_basedir' => null]);
$root = '/var/www/secret';
$this->assertFalse(PathResolver::within('/var/www/secret_other', $root)); // prefix collision
$this->assertFalse(PathResolver::within('/var/www/secret/../../etc/passwd', $root)); // traversal
$this->assertTrue(PathResolver::within('/var/www/secret/uploads/a.txt', $root));
// plus: symlink escape, Windows separators
}
Four tests. Ten minutes to write. Catches the exact failure mode you pin the package to avoid.
str_starts_with($path, $root) is the bug, not the fix. This is the single most important line in the assessment. A prefix check passes /var/www/secret_other against /var/www/secret. PathResolver::within() is separator-boundary safe after the GHSA-58fp fix — meaning an outdated Storm pin is a live path-traversal risk. Pin exactly, commit the lockfile, and watch Winter's advisories. The package's strongest argument (canonical, audited helpers) is also its strongest argument for pinning.
Bootstrapping is not a documented path. Storm expects a Winter/October-style application container — provider registration, class loader, facade aliases. Registering it inside a plain Laravel app must be proven in a spike, not assumed. The pure static helpers (PathResolver, the non-container-dependent Filesystem methods) are likely fine once autoloaded; Halcyon/Argon/Database model extensions are where the container assumptions bite. Decide early: sliver, or framework marriage.
Autoload collisions are fatal, not warnings. If Storm's composer.json autoloads global function files and one collides with yours, every environment dies at boot. Ask this before Phase 0, not during.
Dependents: 0 is probably a Packagist artefact — Winter's own manifest should depend on Storm. Don't read it as reassurance, and don't read it as a bug either. The real point stands: standalone consumer base is effectively nil. There's no external bug-finder pool, no semver promise you can lean on in a contract, and security advisories appear only if Winter's maintainers file them. Treat it as first-party Winter internals with a bus factor near one.
Respect the layering rule. Storm explicitly forbids depending on Winter modules or plugin code. Using it without that discipline inverts the architecture and you get a shared layer that quietly imports the app. The AGENTS.md search order (Storm → Laravel → Symfony) is a policy, not just docs — it's the thing that keeps duplicated helper logic out of your codebase.
Don't half-adopt the substrate. If you find yourself wanting themes and plugins, stop bolting Storm onto your SaaS and evaluate Winter CMS proper. The worst outcome is a Laravel app carrying a CMS's domain abstractions for two utility classes.
Vendoring is a legitimate exit. If the need is essentially PathResolver, copy it with MIT attribution and take the same contract tests. You trade an external release cadence for a couple hundred lines you own. The hard rule: it must be a copy of the canonical code, never a reimplementation — the whole point is that hand-rolled path logic is where the bugs live.
Windows and open_basedir diverge from Linux CI. If you ship to Windows hosts, run the contract tests cross-platform. standardize() vs normalizePath() confusion usually shows up exactly here.
Debugging tip. When a containment check fails in production but passes locally, print both the resolved path and the root, then compare byte-for-byte — trailing separators, symlink resolution, and case sensitivity (via existsInsensitive) are the three usual culprits. PathResolver::resolve() output is the only string you should be comparing.
How can I help you explore Laravel packages today?