Before writing any filesystem, path, string, or HTTP helper, search Storm — then Laravel, then Symfony — for an existing one. Storm 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. Hand-rolling a function that already exists creates duplicate logic with subtly different edge-case behaviour — the security-relevant bugs (path-traversal, prefix collisions, symlink escape, open_basedir respect) almost always live in your version, not the canonical one.
-
Storm itself. Each
src/<Module>/directory has aREADME.mdthat catalogues its public API. Read those before writing anything that "feels generic":src/Filesystem/— paths, file IO, recursive directory ops, drive-letter handling, open_basedir-safe resolutionPathResolver(static class):resolve()(realpath that works on non-existent paths and respects open_basedir),within()(safe directory-containment check, separator-boundary aware after the GHSA-58fp fix),join(),standardize()Filesystem(extends Illuminate's):isAbsolutePath(),isLocalPath(),existsInsensitive(),symbolizePath(),chmodRecursive(), plus everything Illuminate's gives you (deleteDirectory(),cleanDirectory(),copyDirectory(), etc.)
src/Support/— strings, arrays, class loading, helperssrc/Network/— HTTP requests, headerssrc/Html/— HTML/URL building, sanitisingsrc/Parse/— YAML, INI, env, syntax/template parsing, Assetic filter wrapperssrc/Database/,src/Halcyon/,src/Auth/,src/Config/,src/Translation/,src/Mail/,src/Argon/,src/Flash/,src/Validation/
-
Laravel (Illuminate). Storm depends on Laravel, so 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 these instead of writing regex one-liners.Illuminate\Support\Arr—Arr::get/set/has/forget/only/except/dot/undot/flatten/pluck/wrap/divide/random/last/first/sort/sortRecursive/where/whereNotNull/query. Use these instead of nested foreach loops.Illuminate\Support\Collection(viacollect()) — chainable map/filter/reduce/groupBy/pipe/tap. Often clearer than imperative array manipulation.Illuminate\Filesystem\Filesystem(Storm's parent class) —deleteDirectory(),cleanDirectory(),copyDirectory(),moveDirectory(),allFiles(),glob(),isDirectory(),prepend(),append(),replace(),hash().- Global helpers (always loaded):
data_get(),data_set(),data_fill(),value(),with(),tap(),optional(),transform(),head(),last(),class_basename(),class_uses_recursive(),now(),today(),e(),__()/trans(),dispatch(),event(),cache(),config(),env(),app(),resolve(),back(),redirect(),response(),view(),route(),url(),report(),rescue(),retry(),throw_if()/throw_unless(),abort()/abort_if()/abort_unless(). - Facades for everything:
Cache,Config,DB,Event,File,Hash,Http,Lang,Log,Mail,Queue,Redis,Route,Schema,Session,Storage,URL,Validator,View.
-
Symfony components. Many are installed (
vendor/symfony/):console,css-selector,error-handler,event-dispatcher,finder,http-foundation,http-kernel,mailer,mime,process,routing,string,translation,uid,var-dumper,yaml. The ones agents reach for most often:Symfony\Component\Finder\Finder— file finding (Finder::create()->files()->name('*.less')->in($dir)) — much cleaner than RecursiveIteratorIterator + RegexIterator chains.Symfony\Component\Filesystem\Filesystem(different from Illuminate's — atomic operations) —dumpFile()(atomic write),mirror(),mkdir()(idempotent),remove(),rename(),symlink(),appendToFile(),chmod()/chown()/chgrp()with recursive flag.Symfony\Component\Process\Process— run external commands safely (handles escaping, timeouts, streaming output) instead ofexec()/shell_exec().Symfony\Component\String\— Unicode-aware string handling (u()/b()helpers, grapheme-cluster-safe operations).Symfony\Component\Yaml\Yaml— strict YAML parse/dump.Symfony\Component\Uid\Uuid/Ulid— modern UUID/ULID generation.
grep -rl 'function <thing-you-need>' src/ vendor/laravel/framework/src/ vendor/symfony/ is a 10-second check that prevents 100-line mistakes.
Paths and filesystem:
| If you reach for… | Use this instead |
|---|---|
realpath() plus null-check plus trim slash |
PathResolver::resolve() (handles non-existent paths and open_basedir) |
str_starts_with($path, $root) to check containment |
PathResolver::within($path, $root) — separator-boundary safe, defeats prefix-collision attacks |
Manual '/' . ltrim(...) path join |
PathResolver::join() |
Custom recursive rmrf() in tests |
(new Winter\Storm\Filesystem\Filesystem())->deleteDirectory($path) or File::deleteDirectory($path) |
| Detect absolute path | (new Filesystem())->isAbsolutePath($path) (covers Unix, Windows drive letter, URL schemes) |
Path symbol resolution (~/, $/) |
(new Filesystem())->symbolizePath($path) |
str_replace('\\', '/', $path) (backslash → forward slash, often for cross-platform comparison) |
(new Filesystem())->normalizePath($path) |
str_replace('/', DIRECTORY_SEPARATOR, $path) (forward slash → native, when handing a path to an OS API) |
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) |
RecursiveDirectoryIterator + filter loops |
Same — Finder is almost always shorter and clearer |
Strings and arrays:
| If you reach for… | Use this instead |
|---|---|
preg_match('/^prefix/', $s) |
Str::startsWith($s, 'prefix') (accepts array of prefixes too) |
Manual strpos !== false |
Str::contains($s, $needle) |
strtolower-then-replace slug generation |
Str::slug($s) |
| Random hex/string for tmp files, tokens | Str::random() / Str::uuid() |
| Deep array key access with null safety | data_get($array, 'a.b.c', $default) (works on objects and arrays) |
| Pulling subset of array keys | Arr::only($array, ['a', 'b']) / Arr::except($array, ['c']) |
| Flattening nested array structure | Arr::dot($array) / Arr::undot($array) / Arr::flatten($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/JSON parsing | \Symfony\Component\Yaml\Yaml::parse() / json_decode($s, true, 512, JSON_THROW_ON_ERROR) |
Generating UUID via random_bytes() + manual formatting |
Str::uuid() or \Symfony\Component\Uid\Uuid::v7() |
| HTTP request to external service | Laravel's Http::get(...) facade (built on Guzzle) |
- Grep first. If the function is missing, document where you looked in the PR description.
- Put it in Storm, not the consumer module, unless it has hard module-specific dependencies (theme/plugin/CMS concerns belong in
modules/, notvendor/winter/storm/). - Test the security-relevant edge cases. For paths: traversal (
../../etc/passwd), prefix collision (/var/www/secret_othervs allowed/var/www/secret), symlink escape, Windows separators. - Use the canonical helpers internally — don't have your new utility hand-roll its own
realpath()and prefix check; compose fromPathResolver.
- Extend
TestCase(Orchestra Testbench-based) for tests that need the Laravel container. Plain unit tests against pure helper classes don't need it. - For fixtures that need to live inside the application root (Assetic's
FileAssetenforces this), usebase_path('storage/framework/cache/...')plusbin2hex(random_bytes(4))for uniqueness, then clean up withFilesystem::deleteDirectory()intearDown(). - Run the full suite (
vendor/bin/phpunit) before claiming a change is regression-free. Failures unrelated to your change usually mean a stalevendor/—composer updateand try again before blaming the test.
Storm depends on Laravel and a few Symfony pieces; it must not depend on Winter modules (modules/backend, modules/cms, modules/system) or on plugin code. If a feature you're adding needs CMS-specific configuration (e.g., which directories count as "themes"), expose a setter on the Storm class and let the consumer in modules/ supply the policy.