├── .github └── workflows │ └── php.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── README.md ├── composer.json ├── src ├── Attribute │ ├── Document.php │ ├── Image.php │ ├── TypeInterface.php │ ├── TypePropertyDecorated.php │ └── Video.php ├── Exception │ ├── FileNotReadableException.php │ ├── FileUploadException.php │ ├── InvalidFileException.php │ ├── MissingFileException.php │ ├── TooManyAttributesException.php │ ├── UploadedFileException.php │ └── ValidationException.php ├── Factory │ ├── FileNameFactory.php │ ├── StreamFactory.php │ ├── TypePropertyFactory.php │ ├── UploadedFileFactory.php │ ├── UploadedFilesFactory.php │ └── UploaderFactory.php ├── File.php ├── Reflection │ └── EntityReflector.php ├── Stream.php ├── UploadedFile.php ├── UploadedFileInterface.php ├── Uploader.php ├── UploaderImpl.php ├── UploaderInterface.php └── Validation │ ├── Dimension.php │ ├── FieldValidation.php │ ├── FieldValidatorInterface.php │ ├── FileSize.php │ ├── FileType.php │ ├── ValidationImpl.php │ ├── ValidationInterface.php │ └── ValidatorInterface.php └── test └── Unit ├── Factory ├── TypePropertyFactoryTest.php └── UploadedFileFactoryTest.php ├── UploadedFileTest.php └── UploaderTest.php /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Validate composer.json and composer.lock 21 | run: composer validate --strict 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress 34 | 35 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 36 | # Docs: https://getcomposer.org/doc/articles/scripts.md 37 | 38 | - name: Run test suite 39 | run: composer run test 40 | 41 | - name: Upload coverage reports to Codecov 42 | uses: codecov/codecov-action@v3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | composer.lock 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | diarselimi92@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # File Upload Wrapper 2 | 3 | The File Upload Wrapper is a PHP library that simplifies file uploads by providing a set of easy-to-use classes that handle common validation and processing tasks. 4 | With this library, you can: 5 | 6 | - Validate uploaded files with ease 7 | - Process uploaded files with targeted validations for specific fields 8 | - Simplify the file upload process with a set of easy-to-use classes 9 | 10 | # Getting Started 11 | 12 | To use the library, follow these steps: 13 | 14 | 1. Install the library using Composer: 15 | 16 | ```sh 17 | composer require didslm/file-upload-wrapper 18 | ``` 19 | 20 | 2. Import the classes you need: 21 | 22 | ```php 23 | use Didslm\FileUpload\Uploader; 24 | use Didslm\FileUpload\UploaderInterface; 25 | use Didslm\FileUpload\File; 26 | use Didslm\FileUpload\Validation\FileSize; 27 | use Didslm\FileUpload\Validation\FileType; 28 | use Didslm\FileUpload\Validation\Dimension; 29 | use Didslm\FileUpload\FieldValidation; 30 | use Didslm\FileUpload\Attributes\Image; 31 | use Didslm\FileUpload\Attributes\Document; 32 | use Didslm\FileUpload\Exceptions\FileUploadException; 33 | ``` 34 | 35 | 3. Use the `upload()` method to handle file uploads for your entity: 36 | 37 | ```php 38 | class Product { 39 | #[Image(requestField: "request_field", dir: "/public")] 40 | private string $image; 41 | 42 | #[Image(requestField: "profile_field", dir: "/public")] 43 | private string $profile; 44 | 45 | // ... 46 | } 47 | 48 | $product = new Product(); 49 | 50 | Uploader::upload($product, [ 51 | new FileType([File::JPEG]), 52 | new FileSize(2, File::MB) 53 | ]); 54 | 55 | ``` 56 | 57 | 4. The same exmaple you can do via Dependency Injection: 58 | 59 | ```php 60 | class ProductController { 61 | public function __construct(private UploaderInterface $uploader){} 62 | 63 | public function upload(Request $request) 64 | { 65 | $product = new Product(); 66 | 67 | $this->uploader->upload($product, [ 68 | new FileType([File::IMAGES]), 69 | new FileSize(2, File::MB) 70 | ]); 71 | } 72 | } 73 | ``` 74 | 75 | At the end of this document you can see how to configure in Laravel or Symfony. 76 | 77 | ---- 78 | 79 | # Examples 80 | 81 | ### Handling File Uploads for an Entity 82 | 83 | The following code shows an example of how to use the library to handle file uploads for an entity: 84 | 85 | ```php 86 | class Product { 87 | //... 88 | #[Image(requestField: "request_field", dir: "/public")] 89 | private string $image; 90 | 91 | #[Image(requestField: "profile_field", dir: "/public")] 92 | private string $profile; 93 | 94 | public function getImageFilename(): string 95 | { 96 | return $this->image; 97 | } 98 | 99 | public function getProfileFilename(): string 100 | { 101 | return $this->profile; 102 | } 103 | } 104 | 105 | ``` 106 | 107 | In this example, the `Product` class has two properties `image` and `profile` that are decorated with the `Image` attribute. 108 | 109 | ### Types 110 | 111 | In the following example you will see a list of available Attribute types: 112 | 113 | ```php 114 | #[new Image(requestName: 'file_upload_field', dir: '/upload/dir')] 115 | #[new Document(requestName: 'cv_file', dir: '/upload/dir')] 116 | #[new Video(requestName: 'video_file', dir: '/upload/dir')] 117 | ``` 118 | 119 | The `Image` attribute provides metadata to the library to process the files correctly during the upload. 120 | 121 | The `Document` attribute provides metadata to the library to process the files correctly during the upload. 122 | 123 | The `Video` attribute provides metadata to the library to process the files correctly during the upload. 124 | 125 | The `upload()` method is then called on the `Uploader` class with the `Product` object and an array of validation rules as its parameters. 126 | 127 | ```php 128 | $product = new Product(); 129 | 130 | Uploader::upload($product, [ 131 | new FileType([File::JPEG]), 132 | new FileSize(2, File::MB) 133 | ]); 134 | 135 | echo $product->getImageFilename(); 136 | 137 | ``` 138 | 139 | # Handling Exceptions 140 | 141 | The library provides a `FileUploadException` class that all exceptions thrown by the library extend. This means that you can catch all exceptions using `FileUploadException` in a try-catch block, as shown below: 142 | ```php 143 | try { 144 | Uploader::upload($product, [ 145 | new FileType([File::PNG]), 146 | new FileSize(2, File::MB) 147 | ]); 148 | } catch (FileUploadException $e) { 149 | // handle exception 150 | } 151 | ``` 152 | 153 | # Validation 154 | 155 | The library provides several validation classes that you can use to validate uploaded files. These classes can be passed as parameters to the `upload()` method to specify the validation rules for the files being uploaded. 156 | 157 | 158 | ### Type 159 | 160 | The `FileType` class is used to check the file type. You can specify the types of files allowed by passing an array of file types to the constructor. For example: 161 | 162 | ```php 163 | new FileType([File::PNG, File::JPEG, File::GIF]) 164 | ``` 165 | 166 | ### Size 167 | 168 | The `FileSize` class is used to validate the file size. You can specify the maximum file size allowed by passing the size in bytes to the constructor. Alternatively, you can use the Size class to specify the size in a more readable format. For example: 169 | ```php 170 | new FileSize(2, File::MB) 171 | new FileSize(200, File::KB) 172 | 173 | ``` 174 | 175 | ### Dimension 176 | 177 | The `Dimension` class is used to validate the dimensions of images. You can specify the maximum width and height of the image by passing them as parameters to the constructor. For example: 178 | ```php 179 | new Dimension(200, 200) 180 | ``` 181 | 182 | ### Targeted Validations 183 | 184 | You can also target specific fields in your entity with a set of validations. 185 | To do this, you can use the `FieldValidations` class, which takes the request field name as it's first parameter and an array of validation rules as its second parameter. Here's an example: 186 | 187 | ```php 188 | $profileValidations = new FieldValidations("profile_field", [ 189 | new Dimension(200, 200), 190 | new FileSize(2, File::MB) 191 | ]); 192 | 193 | Uploader::upload($product, [ 194 | new FileType([File::PNG, File::JPEG, File::GIF]), 195 | $profileValidations, 196 | ]); 197 | ``` 198 | 199 | In the example above, we are specifying a set of validation checks that apply to the profile field in the Product entity. These checks will only be applied to the profile image uploaded by the user. 200 | 201 | # Frameworks Implementation 202 | 203 | The library is framework agnostic, which means that you can use it with any framework. 204 | 205 | The following sections show how to configure the library in some of the most popular frameworks. 206 | 207 | ## Symfony 208 | 209 | In your Symfony app you can easily configure the library in your `services.yaml` file: 210 | 211 | ```yaml 212 | services: 213 | Didslm\FileUpload\: 214 | resource: '../vendor/didslm/file-upload-wrapper/src/*' 215 | ``` 216 | 217 | ## Laravel 218 | 219 | In your Laravel app you can easily configure the library in your `AppServiceProvider.php` file: 220 | 221 | ```php 222 | public function register() 223 | { 224 | $this->app->bind(UploaderInterface::class, function (){ 225 | return UploaderFactory::create(); 226 | }); 227 | } 228 | ``` 229 | 230 | ----- 231 | Handling file uploads can be a complicated and error-prone task, but with this library, you can simplify the process and focus on building the features that matter. If you have any questions or feedback, feel free to reach out to the author on [Twitter](https://twitter.com/slmdiar) or [LinkedIn](https://linkedin.com/in/diarselimi). -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "didslm/file-upload", 3 | "description": "This wrapper makes file uploading easier to use", 4 | "type": "library", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "Didslm\\FileUpload\\": "src/" 9 | } 10 | }, 11 | "require": { 12 | "php": ">=8.0", 13 | "psr/http-message": "^1.0" 14 | }, 15 | "autoload-dev": { 16 | "psr-4": { 17 | "Didslm\\FileUpload\\Test\\": "test/" 18 | } 19 | }, 20 | "authors": [ 21 | { 22 | "name": "Diar", 23 | "email": "diarselimi92+github@gmail.com" 24 | } 25 | ], 26 | "scripts": { 27 | "test": "./vendor/bin/phpunit test/Unit --colors=always --testdox" 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "@dev" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Attribute/Document.php: -------------------------------------------------------------------------------- 1 | dir; 18 | } 19 | 20 | 21 | public function getRequestField(): string 22 | { 23 | return $this->requestField; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Attribute/Image.php: -------------------------------------------------------------------------------- 1 | dir; 15 | } 16 | 17 | 18 | public function getRequestField(): string 19 | { 20 | return $this->requestField; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Attribute/TypeInterface.php: -------------------------------------------------------------------------------- 1 | type->getDir(); 12 | } 13 | 14 | public function getRequestField(): string 15 | { 16 | return $this->type->getRequestField(); 17 | } 18 | 19 | public function getProperty(): string 20 | { 21 | return $this->property; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Attribute/Video.php: -------------------------------------------------------------------------------- 1 | dir; 17 | } 18 | 19 | public function getRequestField(): string 20 | { 21 | return $this->requestField; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/FileNotReadableException.php: -------------------------------------------------------------------------------- 1 | message = $message; 12 | $this->previous = $previous; 13 | } 14 | 15 | public function getLastException(): ?FileUploadException 16 | { 17 | return $this->previous; 18 | } 19 | 20 | public function __toString(): string 21 | { 22 | return $this->message; 23 | } 24 | } -------------------------------------------------------------------------------- /src/Exception/InvalidFileException.php: -------------------------------------------------------------------------------- 1 | getProperties() as $property) { 15 | $attributes = $property->getAttributes(TypeInterface::class, \ReflectionAttribute::IS_INSTANCEOF); 16 | 17 | if (count($attributes) > 1) { 18 | throw new TooManyAttributesException($property->getName()); 19 | } 20 | 21 | if (count($attributes) === 1) { 22 | // Set the property value to an empty string ONLY if it has the TypeInterface attribute 23 | $property->setAccessible(true); // Make sure you can access private or protected properties 24 | $type = $property->getType(); 25 | $property->setValue($obj, $type && $type->getName() === 'array' ? [] : ''); 26 | 27 | /** @var TypeInterface $type */ 28 | $type = $attributes[0]->newInstance(); 29 | $types[$type->getRequestField()] = new TypePropertyDecorated($type, $property->getName()); 30 | } 31 | } 32 | 33 | return $types; 34 | } 35 | 36 | public function createFromEntity(object $entity): array 37 | { 38 | return self::create($entity); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Factory/UploadedFileFactory.php: -------------------------------------------------------------------------------- 1 | $file) { 15 | $uploadedFiles = array_merge($uploadedFiles, self::processFileArray($key, $file)); 16 | } 17 | 18 | return $uploadedFiles; 19 | } 20 | 21 | private static function processFileArray(string $key, array $file): array 22 | { 23 | $uploadedFiles = []; 24 | 25 | if (is_array($file['tmp_name'])) { 26 | foreach ($file['tmp_name'] as $nestedKey => $nestedFile) { 27 | $nestedFileData = [ 28 | 'tmp_name' => $file['tmp_name'][$nestedKey], 29 | 'name' => $file['name'][$nestedKey], 30 | 'type' => $file['type'][$nestedKey], 31 | 'size' => $file['size'][$nestedKey], 32 | 'error' => $file['error'][$nestedKey], 33 | ]; 34 | 35 | if (is_array($nestedFile)) { 36 | $uploadedFiles = array_merge($uploadedFiles, self::processFileArray($key, $nestedFileData)); 37 | } else { 38 | $uploadedFiles[] = self::createSingleFile($key, $nestedFileData); 39 | } 40 | } 41 | } else { 42 | $uploadedFiles[] = self::createSingleFile($key, $file); 43 | } 44 | 45 | return $uploadedFiles; 46 | } 47 | 48 | private static function createSingleFile(string $key, array $file): UploadedFileInterface 49 | { 50 | return new UploadedFile( 51 | $key, 52 | $file['tmp_name'], 53 | $file['name'], 54 | $file['type'], 55 | $file['size'], 56 | $file['error'] 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Factory/UploadedFilesFactory.php: -------------------------------------------------------------------------------- 1 | $file) { 18 | 19 | if ($factory->isMultiple($key)) { 20 | $normalizedFiles = $factory->normalizeMultiple($key); 21 | 22 | for ($i=0; $i < count($normalizedFiles['name']); $i++) { 23 | 24 | $uploadedFiles[] = new UploadedFile( 25 | $key, 26 | $normalizedFiles['tmp_name'][$i], 27 | $normalizedFiles['name'][$i], 28 | $normalizedFiles['type'][$i], 29 | $normalizedFiles['size'][$i], 30 | (int) $normalizedFiles['error'][$i], 31 | ); 32 | } 33 | continue; 34 | } 35 | 36 | $file['request_field'] = $key; 37 | $uploadedFiles[] = $factory->createFile($file); 38 | } 39 | 40 | return $uploadedFiles; 41 | } 42 | 43 | private function isMultiple(string $key): bool 44 | { 45 | return is_array($this->files[$key]['name']); 46 | } 47 | 48 | private function normalizeMultiple(string $key): array 49 | { 50 | return [ 51 | 'name' => $this->extractLastArray($this->files[$key]['name']), 52 | 'type' => $this->extractLastArray($this->files[$key]['type']), 53 | 'tmp_name' => $this->extractLastArray($this->files[$key]['tmp_name']), 54 | 'error' => $this->extractLastArray($this->files[$key]['error']), 55 | 'size' => $this->extractLastArray($this->files[$key]['size']), 56 | ]; 57 | } 58 | 59 | private function extractLastArray(array $data): array 60 | { 61 | $firstItem = $data[array_key_first($data)]; 62 | 63 | if (is_array($firstItem)) { 64 | return $this->extractLastArray($firstItem); 65 | } 66 | 67 | return array_values($data); 68 | } 69 | 70 | private function createFile(array $file): UploadedFileInterface 71 | { 72 | return new UploadedFile( 73 | $file['request_field'], 74 | $file['tmp_name'], 75 | $file['name'], 76 | $file['type'], 77 | $file['size'], 78 | $file['error'], 79 | ); 80 | } 81 | } -------------------------------------------------------------------------------- /src/Factory/UploaderFactory.php: -------------------------------------------------------------------------------- 1 | 1000, 12 | self::MB => 1000000, 13 | ]; 14 | 15 | //image types 16 | public const JPEG = 'image/jpeg'; 17 | public const JPG = 'image/jpg'; 18 | public const PNG = 'image/png'; 19 | public const GIF = 'image/gif'; 20 | 21 | public const IMAGES = [ 22 | self::JPEG, 23 | self::JPG, 24 | self::PNG, 25 | self::GIF, 26 | ]; 27 | 28 | //document types 29 | public const PDF = 'application/pdf'; 30 | public const DOC = 'application/msword'; 31 | public const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; 32 | public const XLS = 'application/vnd.ms-excel'; 33 | public const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; 34 | public const PPT = 'application/vnd.ms-powerpoint'; 35 | public const PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; 36 | 37 | public const DOCUMENTS = [ 38 | self::PDF, 39 | self::DOC, 40 | self::DOCX, 41 | self::XLS, 42 | self::XLSX, 43 | self::PPT, 44 | self::PPTX, 45 | ]; 46 | 47 | //all video types 48 | public const MP4 = 'video/mp4'; 49 | public const WEBM = 'video/webm'; 50 | public const OGG = 'video/ogg'; 51 | public const AVI = 'video/x-msvideo'; 52 | public const WMV = 'video/x-ms-wmv'; 53 | public const FLV = 'video/x-flv'; 54 | public const MOV = 'video/quicktime'; 55 | 56 | public const VIDEOS = [ 57 | self::MP4, 58 | self::WEBM, 59 | self::OGG, 60 | self::AVI, 61 | self::WMV, 62 | self::FLV, 63 | self::MOV, 64 | ]; 65 | 66 | public const ALL = [ 67 | self::JPEG => 'jpg', 68 | self::JPG => 'jpg', 69 | self::PNG => 'png', 70 | self::GIF => 'gif', 71 | self::PDF => 'pdf', 72 | self::DOC => 'doc', 73 | self::DOCX => 'docx', 74 | self::XLS => 'xls', 75 | self::XLSX => 'xlsx', 76 | self::PPT => 'ppt', 77 | self::PPTX => 'pptx', 78 | self::MP4 => 'mp4', 79 | self::WEBM => 'webm', 80 | self::OGG => 'ogg', 81 | self::AVI => 'avi', 82 | self::WMV => 'wmv', 83 | self::FLV => 'flv', 84 | self::MOV => 'mov', 85 | ]; 86 | 87 | } -------------------------------------------------------------------------------- /src/Reflection/EntityReflector.php: -------------------------------------------------------------------------------- 1 | targetObject = $entity; 11 | return $this; 12 | } 13 | 14 | public function set(string $property, string $value): object 15 | { 16 | $reflection = new \ReflectionClass($this->targetObject); 17 | $property = $reflection->getProperty($property); 18 | 19 | if ($property->getType()->getName() === 'string') { 20 | $property->setValue($this->targetObject, $value); 21 | } 22 | 23 | if ($property->getType()->getName() === 'array') { 24 | $property->setValue($this->targetObject, array_merge($property->getValue($this->targetObject), [$value])); 25 | } 26 | 27 | return $this->targetObject; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Stream.php: -------------------------------------------------------------------------------- 1 | stream = fopen($path, $mode); 19 | if ($this->stream === false) { 20 | throw new InvalidFileException($path); 21 | } 22 | } 23 | 24 | public function __toString(): string 25 | { 26 | return (string) $this->stream; 27 | } 28 | 29 | public function close(): void 30 | { 31 | $this->stream = null; 32 | } 33 | 34 | public function detach(): void 35 | { 36 | $this->stream = null; 37 | } 38 | 39 | public function getSize(): int 40 | { 41 | return filesize($this->path) ?? 0; 42 | } 43 | 44 | public function tell(): int 45 | { 46 | return ftell($this->stream); 47 | } 48 | 49 | public function eof(): bool 50 | { 51 | return feof($this->stream); 52 | } 53 | 54 | public function isSeekable(): bool 55 | { 56 | return true; 57 | } 58 | 59 | public function seek($offset, $whence = SEEK_SET): int 60 | { 61 | return fseek($this->stream, $offset, $whence); 62 | } 63 | 64 | public function rewind(): bool 65 | { 66 | return rewind($this->stream); 67 | } 68 | 69 | public function isWritable(): bool 70 | { 71 | return in_array($this->mode, ['w', 'w+']); 72 | } 73 | 74 | public function write($string): int 75 | { 76 | return fwrite($this->stream, $string); 77 | } 78 | 79 | //check if the stream is readable 80 | public function isReadable(): bool 81 | { 82 | if ($this->stream) { 83 | return in_array($this->mode, ['r', 'r+', 'w+', 'a', 'a+', 'x', 'x+', 'c', 'c+']); 84 | } 85 | 86 | return false; 87 | } 88 | 89 | public function read($length): string 90 | { 91 | return fread($this->stream, $length) ?? ''; 92 | } 93 | 94 | public function getContents(): string 95 | { 96 | if ($this->isReadable()) { 97 | return stream_get_contents($this->stream); 98 | } 99 | 100 | throw new FileNotReadableException('Stream is not readable'); 101 | } 102 | 103 | public function getMetadata($key = null): array|string|null 104 | { 105 | if ($key) { 106 | return stream_get_meta_data($this->stream)[$key]; 107 | } 108 | return stream_get_meta_data($this->stream); 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/UploadedFile.php: -------------------------------------------------------------------------------- 1 | 'There is no error, the file uploaded with success.', 16 | UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini.', 17 | UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive' 18 | . ' that was specified in the HTML form.', 19 | UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded.', 20 | UPLOAD_ERR_NO_FILE => 'No file was uploaded.', 21 | UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.', 22 | UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.', 23 | UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.', 24 | ]; 25 | 26 | public function __construct( 27 | private string $uploadedUnderFieldName, 28 | private ?string $tmpName, 29 | private ?string $name, 30 | private ?string $type, 31 | private ?int $size, 32 | private int $error 33 | ){} 34 | 35 | public function getStream(): StreamInterface 36 | { 37 | return new Stream($this->tmpName); 38 | } 39 | 40 | public function moveTo($targetPath): void 41 | { 42 | if ($this->error !== UPLOAD_ERR_OK) { 43 | throw new UploadedFileException(self::ERRORS[$this->error]); 44 | } 45 | 46 | if (!is_uploaded_file($this->tmpName)) { 47 | throw new UploadedFileException("The file {$this->tmpName} is not an uploaded file."); 48 | } 49 | 50 | if (!copy($this->tmpName, $targetPath)) { 51 | $error = error_get_last(); 52 | throw new UploadedFileException("Failed to move {$this->tmpName} to {$targetPath}. Error: " . $error['message']); 53 | } 54 | } 55 | 56 | public function getSize(): int 57 | { 58 | return $this->size; 59 | } 60 | 61 | public function getError(): int 62 | { 63 | return $this->error; 64 | } 65 | 66 | public function getClientFilename(): ?string 67 | { 68 | return $this->name; 69 | } 70 | 71 | public function getClientMediaType(): ?string 72 | { 73 | return $this->type; 74 | } 75 | 76 | public function getRequestField(): string 77 | { 78 | return $this->uploadedUnderFieldName; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/UploadedFileInterface.php: -------------------------------------------------------------------------------- 1 | uploader = UploaderFactory::create(); 15 | } 16 | 17 | public static function upload(object &$obj, array|ValidatorInterface|null $validators = []): void 18 | { 19 | self::$instance = new self($obj); 20 | $obj = self::$instance->uploader->upload($obj, $validators); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/UploaderImpl.php: -------------------------------------------------------------------------------- 1 | rootDirectoy = $_SERVER['DOCUMENT_ROOT'] .'/'; 23 | } 24 | 25 | 26 | public function upload(object &$entity, array $validations): object 27 | { 28 | 29 | $uploadedFiles = $this->uploadedFilesFactory->create($_FILES); 30 | 31 | $this->validator->validate($uploadedFiles, $validations); 32 | 33 | $types = $this->typeFactory->createFromEntity($entity); 34 | 35 | 36 | foreach ($uploadedFiles as $key => $uploadedFile) { 37 | $type = $types[$uploadedFile->getRequestField()] ?? null; 38 | 39 | if ($type === null) { 40 | unset($uploadedFiles[$key]); //we don't need it if it's not declared in the object 41 | continue; 42 | } 43 | 44 | $newFileName = $this->generateFileName($uploadedFile); 45 | $this->saveUploadedFile($uploadedFile, $type->getDir() . '/' . $newFileName); 46 | $this->assignValueToEntity($entity, $type->getProperty(), $newFileName); 47 | 48 | } 49 | 50 | return $entity; 51 | } 52 | 53 | private function saveUploadedFile(UploadedFile $uploadedFile, string $uploadTo): void 54 | { 55 | 56 | $uploadedFile->moveTo( 57 | $this->rootDirectoy . 58 | $uploadTo 59 | ); 60 | } 61 | 62 | private function generateFileName(UploadedFile $uploadedFile): string 63 | { 64 | return $this->fileNameFactory->create($uploadedFile->getClientMediaType()); 65 | } 66 | 67 | private function assignValueToEntity(object $entity, string $property, string $value): void 68 | { 69 | $this->entityReflector 70 | ->reflect($entity) 71 | ->set($property, $value); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/UploaderInterface.php: -------------------------------------------------------------------------------- 1 | getStream()->getMetadata('uri')); 17 | if ($image[0] <= $this->width && $image[1] <= $this->height) { 18 | return true; 19 | } 20 | 21 | throw new ValidationException(sprintf('File dimensions are too big. Limit is %s by %s.', $this->width, $this->height)); 22 | } 23 | 24 | public function getName(): string 25 | { 26 | return self::CHECKER_NAME; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Validation/FieldValidation.php: -------------------------------------------------------------------------------- 1 | requestField === '') { 11 | throw new \InvalidArgumentException('Request field name cannot be empty string.'); 12 | } 13 | } 14 | 15 | 16 | public function isPassed(UploadedFileInterface $file): bool 17 | { 18 | /** @var validatorInterface $validation */ 19 | foreach ($this->validations as $validation) { 20 | if ($validation->isPassed($file) === false) { 21 | return false; 22 | } 23 | } 24 | return true; 25 | } 26 | 27 | public function validateOnlyField(): bool|string 28 | { 29 | return $this->requestField ?? false; 30 | } 31 | 32 | public function getName(): string 33 | { 34 | return $this->requestField; 35 | } 36 | } -------------------------------------------------------------------------------- /src/Validation/FieldValidatorInterface.php: -------------------------------------------------------------------------------- 1 | type] * $this->size; 17 | 18 | if ($file->getSize() > $limitSize) { 19 | throw new ValidationException( 20 | sprintf('File size is too big. Limit is %s %s.', $this->size, 'MB'), 21 | new ValidationException('Potentialy check you PHP ini configuration, if your file size limit is bigger than the limit you defined in your code.') 22 | ); 23 | } 24 | 25 | return true; 26 | } 27 | 28 | public function getName(): string 29 | { 30 | return self::CHECKER_NAME; 31 | } 32 | } -------------------------------------------------------------------------------- /src/Validation/FileType.php: -------------------------------------------------------------------------------- 1 | getClientMediaType(), $this->acceptedTypes, true)) { 20 | throw new ValidationException(sprintf('File type (%s) is not allowed. Allowed types are %s.',$file->getClientMediaType(), implode(', ', $this->acceptedTypes))); 21 | } 22 | 23 | return true; 24 | } 25 | 26 | public function getName(): string 27 | { 28 | return self::CHECKER_NAME; 29 | } 30 | } -------------------------------------------------------------------------------- /src/Validation/ValidationImpl.php: -------------------------------------------------------------------------------- 1 | validateFile($uploadedFile, $validations); 18 | } 19 | 20 | 21 | } 22 | 23 | private function validateFile( 24 | UploadedFileInterface $uploadedFile, 25 | array $validations 26 | ): void 27 | { 28 | foreach ($validations as $validation) { 29 | if ($validation instanceof FieldValidatorInterface && $validation->validateOnlyField() !== $uploadedFile->getRequestField()) { 30 | continue; 31 | } 32 | 33 | $validation->isPassed($uploadedFile); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Validation/ValidationInterface.php: -------------------------------------------------------------------------------- 1 | field = "Test"; 20 | 21 | $result = TypePropertyFactory::create($entity); 22 | $this->assertEmpty($result); 23 | $this->assertEquals("Test", $entity->field); 24 | $this->assertEquals("test", $entity->name); 25 | } 26 | 27 | public function testEntityWithSingleAttribute(): void 28 | { 29 | $entity = new class { 30 | #[Image('field')] 31 | public $field; 32 | }; 33 | 34 | $result = TypePropertyFactory::create($entity); 35 | $this->assertCount(1, $result); 36 | $this->assertInstanceOf(TypePropertyDecorated::class, $result['field']); 37 | $this->assertEquals("", $entity->field); 38 | } 39 | } -------------------------------------------------------------------------------- /test/Unit/Factory/UploadedFileFactoryTest.php: -------------------------------------------------------------------------------- 1 | array( 15 | 'tmp_name' => 'phpUxcOty', 16 | 'name' => 'my-avatar.png', 17 | 'size' => 90996, 18 | 'type' => 'image/png', 19 | 'error' => 0, 20 | ), 21 | ); 22 | 23 | $uploadedFiles = $factory->create($files); 24 | 25 | $this->assertInstanceOf(\Didslm\FileUpload\UploadedFileInterface::class, $uploadedFiles[0]); 26 | } 27 | 28 | public function testShouldReturnMultipleUploadedFiles(): void 29 | { 30 | $factory = new UploadedFileFactory(); 31 | 32 | $files = array( 33 | 'avatar' => array( 34 | 'tmp_name' => 'phpUxcOty', 35 | 'name' => 'my-avatar.png', 36 | 'size' => 90996, 37 | 'type' => 'image/png', 38 | 'error' => 0, 39 | ), 40 | 'cover' => array( 41 | 'tmp_name' => 'phpUxcOty', 42 | 'name' => 'my-cover.png', 43 | 'size' => 90996, 44 | 'type' => 'image/png', 45 | 'error' => 0, 46 | ), 47 | ); 48 | 49 | $uploadedFiles = $factory->create($files); 50 | 51 | $this->assertCount(2, $uploadedFiles); 52 | $this->assertInstanceOf(\Didslm\FileUpload\UploadedFileInterface::class, $uploadedFiles[0]); 53 | $this->assertInstanceOf(\Didslm\FileUpload\UploadedFileInterface::class, $uploadedFiles[1]); 54 | } 55 | 56 | public function testShouldReturnMultipleUploadedFilesInTheSameField(): void 57 | { 58 | $factory = new UploadedFileFactory(); 59 | 60 | $files = array( 61 | 'avatar' => array( 62 | 'tmp_name' => array( 63 | 'phpUxcOty', 64 | 'phpUxcOty', 65 | ), 66 | 'name' => array( 67 | 'my-avatar.png', 68 | 'my-avatar.png', 69 | ), 70 | 'size' => array( 71 | 90996, 72 | 90996, 73 | ), 74 | 'type' => array( 75 | 'image/png', 76 | 'image/png', 77 | ), 78 | 'error' => array( 79 | 0, 80 | 0, 81 | ), 82 | ), 83 | ); 84 | 85 | $uploadedFiles = $factory->create($files); 86 | 87 | $this->assertCount(2, $uploadedFiles); 88 | $this->assertInstanceOf(\Didslm\FileUpload\UploadedFileInterface::class, $uploadedFiles[0]); 89 | $this->assertInstanceOf(\Didslm\FileUpload\UploadedFileInterface::class, $uploadedFiles[1]); 90 | } 91 | 92 | public function testShouldReturnMultipleUploadedFilesWhenArrayAsFieldIsGiven(): void 93 | { 94 | $factory = new UploadedFileFactory(); 95 | 96 | $files = array ( 97 | 'image' => [ 98 | 'tmp_name' => 'phpUxcOty', 99 | 'name' => 'my-avatar.png', 100 | 'size' => 90996, 101 | 'type' => 'image/png', 102 | 'error' => 0, 103 | ], 104 | 'my-form' => array ( 105 | 'name' => array ( 106 | 'details' => array ( 107 | 'avatar' => 'my-avatar.png', 108 | ), 109 | ), 110 | 'type' => array ( 111 | 'details' => array ( 112 | 'avatar' => 'image/png', 113 | ), 114 | ), 115 | 'tmp_name' => array ( 116 | 'details' => array ( 117 | 'avatar' => 'phpmFLrzD', 118 | ), 119 | ), 120 | 'error' => array ( 121 | 'details' => array ( 122 | 'avatar' => 0, 123 | ), 124 | ), 125 | 'size' => array ( 126 | 'details' => array ( 127 | 'avatar' => 90996, 128 | ), 129 | ), 130 | )); 131 | 132 | $uploadedFiles = $factory->create($files); 133 | 134 | $this->assertCount(2, $uploadedFiles); 135 | $this->assertInstanceOf(\Didslm\FileUpload\UploadedFileInterface::class, $uploadedFiles[0]); 136 | } 137 | 138 | public function testShouldPassWithMultipleFilesAndSingleFile(): void 139 | { 140 | $factory = new UploadedFileFactory(); 141 | $files = [ 142 | 'image' => [ 143 | 'name' => 'test.png', 144 | 'type' => 'image/png', 145 | 'tmp_name' => tempnam(sys_get_temp_dir(), 'test_'), 146 | 'error' => 0, 147 | 'size' => 12345 148 | ], 149 | 'video' => [ 150 | 'name' => 'test.mp4', 151 | 'type' => 'video/mp4', 152 | 'tmp_name' => tempnam(sys_get_temp_dir(), 'test_'), 153 | 'error' => 0, 154 | 'size' => 54321 155 | ], 156 | 'gallery' => [ 157 | 'name' => [ 158 | 'test1.jpg', 159 | 'test2.jpg', 160 | 'test3.jpg' 161 | ], 162 | 'type' => [ 163 | 'image/jpeg', 164 | 'image/jpeg', 165 | 'image/jpeg' 166 | ], 167 | 'tmp_name' => [ 168 | tempnam(sys_get_temp_dir(), 'test_'), 169 | tempnam(sys_get_temp_dir(), 'test_'), 170 | tempnam(sys_get_temp_dir(), 'test_'), 171 | ], 172 | 'error' => [ 173 | 0, 174 | 0, 175 | 0 176 | ], 177 | 'size' => [ 178 | 11111, 179 | 22222, 180 | 33333 181 | ] 182 | ] 183 | ]; 184 | 185 | $uploadedFiles = $factory->create($files); 186 | $this->assertCount(5, $uploadedFiles); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /test/Unit/UploadedFileTest.php: -------------------------------------------------------------------------------- 1 | getStream(); 25 | 26 | $this->assertInstanceOf(StreamInterface::class, $stream); 27 | } 28 | 29 | public function testMoveToThrowsRuntimeExceptionIfErrorOccurred() 30 | { 31 | $uploadedFile = new UploadedFile( 32 | 'test', 33 | self::TEST_FILE_PATH, 34 | 'test.txt', 35 | 'text/plain', 36 | 123, 37 | UPLOAD_ERR_CANT_WRITE 38 | ); 39 | 40 | $this->expectException(\RuntimeException::class); 41 | 42 | $uploadedFile->moveTo('/path/to/target'); 43 | } 44 | 45 | public function testMoveToThrowsRuntimeExceptionIfTargetPathNotWritable() 46 | { 47 | $uploadedFile = new UploadedFile( 48 | 'test', 49 | self::TEST_FILE_PATH, 50 | 'test.txt', 51 | 'text/plain', 52 | 123, 53 | UPLOAD_ERR_OK 54 | ); 55 | 56 | $this->expectException(\RuntimeException::class); 57 | 58 | $uploadedFile->moveTo('/non/writable/path'); 59 | } 60 | 61 | public function testMoveToThrowsRuntimeExceptionIfTmpFileNotUploaded() 62 | { 63 | $uploadedFile = new UploadedFile( 64 | 'test', 65 | self::TEST_FILE_PATH, 66 | 'test.txt', 67 | 'text/plain', 68 | 123, 69 | UPLOAD_ERR_OK 70 | ); 71 | 72 | $this->expectException(\RuntimeException::class); 73 | 74 | $uploadedFile->moveTo('/path/to/target'); 75 | } 76 | 77 | public function testGetSizeReturnsSize() 78 | { 79 | $uploadedFile = new UploadedFile( 80 | 'test', 81 | self::TEST_FILE_PATH, 82 | 'test.txt', 83 | 'text/plain', 84 | 123, 85 | UPLOAD_ERR_OK 86 | ); 87 | 88 | $size = $uploadedFile->getSize(); 89 | 90 | $this->assertSame(123, $size); 91 | } 92 | 93 | public function testGetErrorReturnsError() 94 | { 95 | $uploadedFile = new UploadedFile( 96 | 'test', 97 | self::TEST_FILE_PATH, 98 | 'test.txt', 99 | 'text/plain', 100 | 123, 101 | UPLOAD_ERR_INI_SIZE 102 | ); 103 | 104 | $error = $uploadedFile->getError(); 105 | 106 | $this->assertSame(UPLOAD_ERR_INI_SIZE, $error); 107 | } 108 | 109 | public function testGetClientFilenameReturnsFilename() 110 | { 111 | $uploadedFile = new UploadedFile( 112 | 'test', 113 | self::TEST_FILE_PATH, 114 | 'test.txt', 115 | 'text/plain', 116 | 123, 117 | UPLOAD_ERR_OK 118 | ); 119 | 120 | $filename = $uploadedFile->getClientFilename(); 121 | 122 | $this->assertSame('test.txt', $filename); 123 | 124 | } 125 | 126 | } -------------------------------------------------------------------------------- /test/Unit/UploaderTest.php: -------------------------------------------------------------------------------- 1 | dir = dirname(__DIR__, 2) . '/uploads/'; 19 | parent::setUp(); 20 | $_SERVER['DOCUMENT_ROOT'] = dirname(__DIR__, 2); 21 | $directory = dirname($this->dir); 22 | if (!is_dir($directory)) { 23 | mkdir($directory, 0755, true); 24 | } 25 | } 26 | 27 | protected function tearDown(): void 28 | { 29 | parent::tearDown(); 30 | $_FILES = []; 31 | exec('rm -rf ' . $this->dir); 32 | } 33 | 34 | public function testShouldThrowExceptionWhenMoreThanOneAttributeIsDefined(): void 35 | { 36 | $obj = new class() { 37 | 38 | #[Image('image', 'uploads')] 39 | #[Video('image', dir: 'uploads')] 40 | private string $image; 41 | 42 | public string $name = 'test'; 43 | 44 | }; 45 | 46 | $_FILES = [ 47 | 'image' => [ 48 | 'name' => 'test.png', 49 | 'type' => 'image/png', 50 | 'tmp_name' => tempnam(sys_get_temp_dir(), 'test_'), 51 | 'error' => 0, 52 | 'size' => 12345 53 | ], 54 | ]; 55 | 56 | $this->expectException(\Didslm\FileUpload\Exception\TooManyAttributesException::class); 57 | $this->expectExceptionMessage('Too many attributes defined for field: image'); 58 | 59 | File::upload($obj, [ 60 | new FileSize(10) //this will test all the uploaded files and fail if one of them is bigger than 5MB 61 | ]); 62 | } 63 | 64 | public function testFileUploadShouldNotCleanUpClass(): void 65 | { 66 | $obj = new class() { 67 | 68 | #[Image('image', 'uploads')] 69 | private string $image; 70 | 71 | public string $name = 'test'; 72 | 73 | public function image(): string 74 | { 75 | return $this->image; 76 | } 77 | }; 78 | 79 | File::upload($obj, [ 80 | new FileSize(10) 81 | ]); 82 | 83 | self::assertEquals('test', $obj->name); 84 | } 85 | 86 | 87 | } 88 | --------------------------------------------------------------------------------