DEV Community

Goce
Goce

Posted on • Edited on

Handing file uploads via commands

To give a bit of context to the problem at hand, we will imagine that we are building a simple app where users can upload an avatar for their profile. The code shown will not be tied down to any framework.

To avoid any thinking in the wrong direction we will ditch the "uploaded" part from the discussion right away. Should the app really care where the file comes from? In this particular case, I don't think so.

The command

We will name the command SetAvatar. Just a simple immutable class with some getters and no behavior / no business logic, a DTO. It needs the user Id and the uploaded file in order to set the avatar of the user in question.

When it comes to the file part, we have couple of options. We can pass the contents of the file as string to the command and this will be perfectly OK. The string is a primitive type, the command is immutable and serializable, but since it is likely that it will be persisted, I do not like the idea of dumping the full file in the database.

We can go for a resource, but resources cannot be serialized. Same goes for the UploadedFileInterface instance (which contains a resource + can read the file contents via a stream) but we will be messing up the immutability and will always be constrained to objects of this interface. Not a good idea at all.

So, we are left with the file path as the best candidate. Each uploaded file has a tmp name which is actually the file path where the uploaded image is temporarily stored and the app can read it without problems. The PSR-7 UploadedFileInterface exposes methods for getting the stream and the meta data including the path of the file. Since the command accepts a file path, we can use it, not only for uploaded images, but setting avatars using any image on the system.

final class SetAvatar {
    private $userId;
    private $filePath;

    public function __construct(UserId $userId, string $filePath){
        $this->userId = $userId;
        $this->filePath = $filePath;
    }

    public function getUserId(): UserId{
        return $this->userId;
    }

    public function getFilePath(): string{
        return $this->filePath;
    }
}
Enter fullscreen mode Exit fullscreen mode

The implementation for the command handler will not be shown but you have the file path so you can go from there.

final class SetAvatarHandler {
    public function handle(SetAvatar $command): void {
        /**
         * TODO Process the file here, make thumbnails, save to storage, etc...
         */
    }
}
Enter fullscreen mode Exit fullscreen mode

It is important that the handler validates the file, we cannot rely on the validations that are performed in the Presentation layer and there is a chance that the command will be triggered from somewhere else in our app by specifying an arbitrary file.

Functions like mime_content_type and finfo_open can be used to get the mime type and determine the extension of the file.

The presentation layer

The implementation of the controller is pretty basic but it does the job. The form factory creates the form from the request, validates it and if successful, the command is handled via the command bus.

final class AvatarController {
    public function upload(ServerRequestInterface $request): ResponseInterface {
        $response = new RedirectResponse('/');

        $form = $this->avatarFormFactory->createFromRequest($request);
        if(!$form->isValid()){
            $this->session->getFlashBag()->add('errors', implode(' ', $form->getValidationErrors()));
            return $response;
        }

        $this->commandBus->handle($form->toCommand());
        $this->session->getFlashBag()->add('success', 'Avatar uploaded');

        return $response;
    }
}
Enter fullscreen mode Exit fullscreen mode

The UploadAvatarFormFactory creates the form with the uploaded file (UploadedFileInterface) and Id of the logged in user.

final class UploadAvatarFormFactory {
        private $session;

    public function __construct(Session $session){
        $this->session = $session;
    }

    public function createFromRequest(ServerRequestInterface $request){
        /**
         * @var UploadedFileInterface[] $uploadedFiles
         */
        $uploadedFiles = $request->getUploadedFiles();
        $uploadedFile = null;

        if(array_key_exists('avatar', $uploadedFiles) && $uploadedFiles['avatar']->getError() != UPLOAD_ERR_NO_FILE){
            $uploadedFile = $uploadedFiles['avatar'];
        }

        return new UploadAvatarForm(
            $this->session->get('userId', null),
            $uploadedFile
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The UploadAvatarForm is the place that is best suited for validating the input and available parameters like the file size, media type, extension, etc. and returning a message to the user. Doing the validations here, ensures that we send a most likely valid command, but we should not rely solely on these (for obvious reasons) and implement the real in-depth validation in the command handler.

In this example the client file name is irrelevant, but if you need it, it can be passed to the command constructor as an optional argument.

final class UploadAvatarForm {
    use UploadedFileValidatorTrait;
    private $userId;
    private $avatar;

    public function __construct(?int $userId, ?UploadedFileInterface $avatar){
        $this->userId = $userId;
        $this->avatar = $avatar;
    }

    public function toCommand(){
        return new SetAvatar(
            new UserId($this->userId),
            $this->avatar->getStream()->getMetadata('uri')
        );
    }

    public function isValid(): bool {
        return empty($this->getValidationErrors());
    }

    public function getValidationErrors(): array{
        $errors = [];
        if($this->userId == null){
            $errors[] = 'Invalid user id!';
        }
        if(!$this->isFileUploaded($this->avatar)){
            $errors[] = $this->getFileUploadError();
        }
        return $errors;
    }
}
Enter fullscreen mode Exit fullscreen mode

Replaying commands

The above code assumes that once the command is handled, successfully or unsuccessfully it cannot be replayed, due to the tmp file not being available after the request has been served.

If you want to reply the commands, one approach you can take is, to first save the file to some intermediary storage and pass that path to the command. This way the file will be available as long as you need it.

Top comments (0)