Skip to content

Refactor UploadedFile responsibilities into focused upload collaborators #494

Description

@armanist

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

  1. UploadPolicy
  • Owns allowed mime-types map and validation checks.
  • Methods:
    • setAllowedMimeTypesMap(array $map, bool $merge = true): void
    • isAllowed(string $extension, string $mimeType): bool
  1. UploadConfigProvider
  • Loads uploads config safely and returns validated mime-types map.
  • Handles fallback when config file is missing.
  • Method:
    • getAllowedMimeTypesMap(): array
  1. UploadStorage
  • Owns file move/copy/remote-write behavior.
  • Uses local adapter + optional remote adapter.
  • Method:
    • store(UploadedFile $file, string $targetPath): bool
  1. 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

  1. Add UploadPolicy and move current allowed-mime logic there.
  2. Add UploadConfigProvider and move uploads-config loading/validation there.
  3. Add UploadStorage and move moveUploadedFile branch logic there.
  4. Update UploadedFile to delegate to these classes.
  5. Keep constructor side-effect-free (no eager fs/config loading).
  6. 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.

Metadata

Metadata

Assignees

Type

Fields

No fields configured for Task.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions