Context
src/Storage/UploadedFile.php currently mixes multiple responsibilities:
- uploaded-file metadata handling
- mime/extension policy validation
- storage move/copy/remote write logic
- uploads config loading/parsing
This makes bootstrap behavior fragile and the class hard to test/maintain.
Goal
Refactor UploadedFile with minimal scope by extracting 3 focused collaborators, while preserving existing public behavior and API.
Scope
In scope
- Keep
UploadedFile public API backward-compatible.
- Extract and use these new classes:
src/Storage/Uploads/UploadPolicy.php
src/Storage/Uploads/UploadStorage.php
src/Storage/Uploads/UploadConfigProvider.php
- Move logic only (no feature changes).
- Add/adjust unit tests for extracted classes and integration through
UploadedFile.
Out of scope
- No namespace-wide reorganization beyond these files.
- No interface/factory abstractions in this ticket.
- No behavior changes to validation rules, upload error semantics, or storage adapter selection.
Proposed Design
UploadPolicy
- Owns allowed mime-types map and validation checks.
- Methods:
setAllowedMimeTypesMap(array $map, bool $merge = true): void
isAllowed(string $extension, string $mimeType): bool
UploadConfigProvider
- Loads
uploads config safely and returns validated mime-types map.
- Handles fallback when config file is missing.
- Method:
getAllowedMimeTypesMap(): array
UploadStorage
- Owns file move/copy/remote-write behavior.
- Uses local adapter + optional remote adapter.
- Method:
store(UploadedFile $file, string $targetPath): bool
UploadedFile
- Keeps orchestration only:
- read metadata
- prepare destination
- call policy/config/storage collaborators
- apply optional image modifications
Examples
1) Policy extraction
Before (inside UploadedFile):
protected function allowed(string $extension, string $mimeType): bool
{
$extension = strtolower($extension);
$mimeType = strtolower($mimeType);
return isset($this->allowedMimeTypes[$mimeType])
&& in_array($extension, (array) $this->allowedMimeTypes[$mimeType], true);
}
After (UploadPolicy):
$policy = new UploadPolicy($defaultMap);
$policy->merge($configMap);
if (!$policy->isAllowed($this->getExtension(), $this->getMimeType())) {
throw FileUploadException::fileTypeNotAllowed($this->getExtension());
}
2) Config loading extraction
Before (inside UploadedFile):
if (!config()->has('uploads')) {
// loader + setup + import logic...
}
$map = config()->get('uploads.allowed_mime_types') ?? [];
After (UploadConfigProvider):
$configMap = $uploadConfigProvider->getAllowedMimeTypesMap();
$policy->merge($configMap);
3) Storage write extraction
Before (inside UploadedFile::moveUploadedFile):
if ($this->remoteFileSystem) {
return (bool) $this->remoteFileSystem->put($filePath, $local->get($this->getPathname()));
}
if ($this->isUploaded()) {
return move_uploaded_file($this->getPathname(), $filePath);
}
return $local->copy($this->getPathname(), $filePath);
After (UploadStorage):
$stored = $uploadStorage->store($this, $filePath, $this->remoteFileSystem);
if (!$stored) {
return false;
}
4) Orchestration-only save()
Target shape:
public function save(string $dest, bool $overwrite = false): bool
{
$this->assertUploadIsValid($dest, $overwrite);
$policy = $this->buildPolicy();
if (!$policy->isAllowed($this->getExtension(), $this->getMimeType())) {
throw FileUploadException::fileTypeNotAllowed($this->getExtension());
}
$filePath = $dest . DS . $this->getNameWithExtension();
if (!$this->uploadStorage->store($this, $filePath, $this->remoteFileSystem)) {
return false;
}
$this->applyPendingImageMods($filePath);
return true;
}
Implementation Plan
- Add
UploadPolicy and move current allowed-mime logic there.
- Add
UploadConfigProvider and move uploads-config loading/validation there.
- Add
UploadStorage and move moveUploadedFile branch logic there.
- Update
UploadedFile to delegate to these classes.
- Keep constructor side-effect-free (no eager fs/config loading).
- Add tests:
- policy unit tests
- config-provider unit tests
- storage unit tests
- updated UploadedFile orchestration tests
Acceptance Criteria
- No circular dependency in request bootstrap paths.
- Existing upload behavior remains unchanged (happy path + known failure paths).
UploadedFile becomes smaller and focused on orchestration.
- New classes are covered by unit tests.
- Existing relevant tests pass.
Risks / Notes
- DI/global helper coupling still exists in framework-wide services; this ticket limits changes to upload domain only.
- Keep changes incremental and avoid broad architecture refactors in this ticket.
Context
src/Storage/UploadedFile.phpcurrently mixes multiple responsibilities:This makes bootstrap behavior fragile and the class hard to test/maintain.
Goal
Refactor
UploadedFilewith minimal scope by extracting 3 focused collaborators, while preserving existing public behavior and API.Scope
In scope
UploadedFilepublic API backward-compatible.src/Storage/Uploads/UploadPolicy.phpsrc/Storage/Uploads/UploadStorage.phpsrc/Storage/Uploads/UploadConfigProvider.phpUploadedFile.Out of scope
Proposed Design
UploadPolicysetAllowedMimeTypesMap(array $map, bool $merge = true): voidisAllowed(string $extension, string $mimeType): boolUploadConfigProvideruploadsconfig safely and returns validated mime-types map.getAllowedMimeTypesMap(): arrayUploadStoragestore(UploadedFile $file, string $targetPath): boolUploadedFileExamples
1) Policy extraction
Before (inside
UploadedFile):After (
UploadPolicy):2) Config loading extraction
Before (inside
UploadedFile):After (
UploadConfigProvider):3) Storage write extraction
Before (inside
UploadedFile::moveUploadedFile):After (
UploadStorage):4) Orchestration-only
save()Target shape:
Implementation Plan
UploadPolicyand move current allowed-mime logic there.UploadConfigProviderand move uploads-config loading/validation there.UploadStorageand movemoveUploadedFilebranch logic there.UploadedFileto delegate to these classes.Acceptance Criteria
UploadedFilebecomes smaller and focused on orchestration.Risks / Notes