├── .editorconfig ├── LICENSE ├── README.md ├── composer.json ├── config └── dto.php └── src ├── Attributes ├── Cast.php ├── DefaultValue.php ├── Map.php └── Rules.php ├── Casting ├── ArrayCast.php ├── BooleanCast.php ├── CarbonCast.php ├── CarbonImmutableCast.php ├── Castable.php ├── CollectionCast.php ├── DTOCast.php ├── EnumCast.php ├── FloatCast.php ├── IntegerCast.php ├── ModelCast.php ├── ObjectCast.php └── StringCast.php ├── Concerns ├── DataResolver.php ├── DataTransformer.php ├── EmptyCasts.php ├── EmptyDefaults.php ├── EmptyRules.php └── Wireable.php ├── Console ├── Commands │ ├── MakeDTOCommand.php │ └── PublishStubsCommand.php └── stubs │ ├── dto.stub │ ├── resource_dto.stub │ └── simple_dto.stub ├── Contracts └── BaseDTO.php ├── Exceptions ├── CastException.php ├── CastTargetException.php ├── InvalidJsonException.php └── MissingCastTypeException.php ├── Providers └── ValidatedDTOServiceProvider.php ├── ResourceDTO.php ├── SimpleDTO.php ├── Support ├── ResourceCollection.php ├── TypeScriptCollector.php └── TypeScriptTransformer.php └── ValidatedDTO.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Wendell Adriel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Validated DTO for Laravel 3 |

4 |

Validated DTO for Laravel

5 | Data Transfer Objects with validation for Laravel applications 6 |

7 |
8 | 9 |

10 | Packagist 11 | PHP from Packagist 12 | Laravel Version 13 | GitHub Workflow Status (main) 14 |

15 | 16 | **Data Transfer Objects (DTOs)** are objects that are used to transfer data between systems. **DTOs** are typically used in applications to provide a simple, consistent format for transferring data between different parts of the application, such as **between the user interface and the business logic**. 17 | 18 | This package provides a base **DTO Class** that can **validate** the data when creating a **DTO**. But why should we do this instead of using the standard **Request** validation? 19 | 20 | Imagine that now you want to do the same action that you do in an endpoint on a **CLI** command per example. If your validation is linked to the Request you'll have to implement the same validation again. 21 | 22 | With this package you **define the validation once** and can **reuse it where you need**, making your application more **maintainable** and **decoupled**. 23 | 24 | ## Documentation 25 | [![Docs Button]][Docs Link] [![DocsRepo Button]][DocsRepo Link] 26 | 27 | ## Installation 28 | 29 | ```bash 30 | composer require wendelladriel/laravel-validated-dto 31 | ``` 32 | 33 | ## Credits 34 | 35 | - [Wendell Adriel](https://github.com/WendellAdriel) 36 | - [All Contributors](../../contributors) 37 | 38 | ## Contributing 39 | 40 | Check the **[Contributing Guide](CONTRIBUTING.md)**. 41 | 42 | 43 | [Docs Button]: https://img.shields.io/badge/Website-0dB816?style=for-the-badge&logoColor=white&logo=GitBook 44 | [Docs Link]: https://wendell-adriel.gitbook.io/laravel-validated-dto/ 45 | [DocsRepo Button]: https://img.shields.io/badge/Repository-3884FF?style=for-the-badge&logoColor=white&logo=GitBook 46 | [DocsRepo Link]: https://github.com/WendellAdriel/laravel-validated-dto-docs 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wendelladriel/laravel-validated-dto", 3 | "description": "Data Transfer Objects with validation for Laravel applications", 4 | "type": "library", 5 | "keywords": [ 6 | "laravel", 7 | "dto", 8 | "data transfer object", 9 | "validation" 10 | ], 11 | "license": "MIT", 12 | "autoload": { 13 | "psr-4": { 14 | "WendellAdriel\\ValidatedDTO\\": "src/" 15 | } 16 | }, 17 | "autoload-dev": { 18 | "psr-4": { 19 | "WendellAdriel\\ValidatedDTO\\Tests\\": "tests/" 20 | } 21 | }, 22 | "support": { 23 | "issues": "https://github.com/WendellAdriel/laravel-validated-dto/issues", 24 | "source": "https://github.com/WendellAdriel/laravel-validated-dto" 25 | }, 26 | "authors": [ 27 | { 28 | "name": "Wendell Adriel", 29 | "email": "wendelladriel.ti@gmail.com" 30 | } 31 | ], 32 | "require": { 33 | "php": "^8.2", 34 | "illuminate/console": "^11.0|^12.0", 35 | "illuminate/database": "^11.0|^12.0", 36 | "illuminate/http": "^11.0|^12.0", 37 | "illuminate/support": "^11.0|^12.0", 38 | "illuminate/validation": "^11.0|^12.0" 39 | }, 40 | "require-dev": { 41 | "laravel/pint": "^1.21", 42 | "orchestra/testbench": "^9.0|^10.0", 43 | "pestphp/pest": "^2.0|^3.0", 44 | "pestphp/pest-plugin-faker": "^2.0|^3.0", 45 | "spatie/typescript-transformer": "^2.4" 46 | }, 47 | "scripts": { 48 | "lint": "pint", 49 | "test:lint": "pint --test", 50 | "test:unit": "./vendor/bin/pest --order-by random", 51 | "test": [ 52 | "@test:lint", 53 | "@test:unit" 54 | ] 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "WendellAdriel\\ValidatedDTO\\Providers\\ValidatedDTOServiceProvider" 60 | ] 61 | } 62 | }, 63 | "config": { 64 | "allow-plugins": { 65 | "pestphp/pest-plugin": true 66 | } 67 | }, 68 | "minimum-stability": "dev", 69 | "prefer-stable": true 70 | } 71 | -------------------------------------------------------------------------------- /config/dto.php: -------------------------------------------------------------------------------- 1 | 'App\\DTOs', 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | REQUIRE CASTING 21 | |-------------------------------------------------------------------------- 22 | | 23 | | If this is set to true, you must configure a cast type for all properties of your DTOs. 24 | | If a property doesn't have a cast type configured it will throw a 25 | | \WendellAdriel\ValidatedDTO\Exceptions\MissingCastTypeException exception 26 | | 27 | */ 28 | 29 | 'require_casting' => false, 30 | ]; 31 | -------------------------------------------------------------------------------- /src/Attributes/Cast.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public array $rules, 17 | /** 18 | * @var array 19 | */ 20 | public array $messages = [], 21 | ) {} 22 | } 23 | -------------------------------------------------------------------------------- /src/Casting/ArrayCast.php: -------------------------------------------------------------------------------- 1 | type) 22 | ? $result 23 | : array_map(fn ($item) => $this->type->cast($property, $item), $result); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Casting/BooleanCast.php: -------------------------------------------------------------------------------- 1 | 0; 13 | } 14 | 15 | if (is_string($value)) { 16 | return filter_var($value, FILTER_VALIDATE_BOOLEAN); 17 | } 18 | 19 | return (bool) $value; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Casting/CarbonCast.php: -------------------------------------------------------------------------------- 1 | format) 25 | ? Carbon::parse($value, $this->timezone) 26 | : Carbon::createFromFormat($this->format, $value, $this->timezone); 27 | } catch (Throwable) { 28 | throw new CastException($property); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Casting/CarbonImmutableCast.php: -------------------------------------------------------------------------------- 1 | format) 25 | ? CarbonImmutable::parse($value, $this->timezone) 26 | : CarbonImmutable::createFromFormat($this->format, $value, $this->timezone); 27 | } catch (Throwable) { 28 | throw new CastException($property); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Casting/Castable.php: -------------------------------------------------------------------------------- 1 | cast($property, $value); 17 | 18 | return Collection::make($value) 19 | ->when($this->type, fn ($collection, $castable) => $collection->map(fn ($item) => $castable->cast($property, $item))); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Casting/DTOCast.php: -------------------------------------------------------------------------------- 1 | dtoClass($value); 32 | } catch (ValidationException $exception) { 33 | throw $exception; 34 | } catch (Throwable) { 35 | throw new CastException($property); 36 | } 37 | 38 | if (! ($dto instanceof SimpleDTO)) { 39 | throw new CastTargetException($property); 40 | } 41 | 42 | return $dto; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Casting/EnumCast.php: -------------------------------------------------------------------------------- 1 | $enum 16 | */ 17 | public function __construct(protected string $enum) {} 18 | 19 | /** 20 | * @throws CastException|CastTargetException 21 | */ 22 | public function cast(string $property, mixed $value): UnitEnum|BackedEnum 23 | { 24 | if (! (is_subclass_of($this->enum, UnitEnum::class))) { 25 | throw new CastTargetException($property); 26 | } 27 | 28 | if ($value instanceof $this->enum) { 29 | return $value; 30 | } 31 | 32 | if (is_subclass_of($this->enum, BackedEnum::class)) { 33 | if (! is_string($value) && ! is_int($value)) { 34 | throw new CastException($property); 35 | } 36 | 37 | $enumCases = array_map( 38 | fn ($case) => $case->value, 39 | $this->enum::cases() 40 | ); 41 | 42 | if (! in_array($value, $enumCases)) { 43 | throw new CastException($property); 44 | } 45 | 46 | return $this->enum::from($value); 47 | } 48 | 49 | $enumCases = array_map( 50 | fn ($case) => $case->name, 51 | $this->enum::cases() 52 | ); 53 | 54 | if (! in_array($value, $enumCases)) { 55 | throw new CastException($property); 56 | } 57 | 58 | $value = constant("{$this->enum}::{$value}"); 59 | if (! $value instanceof $this->enum) { 60 | throw new CastException($property); 61 | } 62 | 63 | return $value; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Casting/FloatCast.php: -------------------------------------------------------------------------------- 1 | modelClass($value); 31 | } catch (Throwable) { 32 | throw new CastTargetException($property); 33 | } 34 | 35 | if (! ($model instanceof Model)) { 36 | throw new CastTargetException($property); 37 | } 38 | 39 | return $model; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Casting/ObjectCast.php: -------------------------------------------------------------------------------- 1 | all()); 44 | } 45 | 46 | /** 47 | * @throws ValidationException|MissingCastTypeException|CastTargetException 48 | */ 49 | public static function fromModel(Model $model): static 50 | { 51 | return new static($model->toArray()); 52 | } 53 | 54 | /** 55 | * @throws ValidationException|MissingCastTypeException|CastTargetException 56 | */ 57 | public static function fromCommandArguments(Command $command): static 58 | { 59 | return new static(self::filterArguments($command->arguments())); 60 | } 61 | 62 | /** 63 | * @throws ValidationException|MissingCastTypeException|CastTargetException 64 | */ 65 | public static function fromCommandOptions(Command $command): static 66 | { 67 | return new static($command->options()); 68 | } 69 | 70 | /** 71 | * @throws ValidationException|MissingCastTypeException|CastTargetException 72 | */ 73 | public static function fromCommand(Command $command): static 74 | { 75 | return new static(array_merge(self::filterArguments($command->arguments()), $command->options())); 76 | } 77 | 78 | private static function filterArguments(array $arguments): array 79 | { 80 | $result = []; 81 | foreach ($arguments as $key => $value) { 82 | if (! is_numeric($key)) { 83 | $result[$key] = $value; 84 | } 85 | } 86 | 87 | return $result; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Concerns/DataTransformer.php: -------------------------------------------------------------------------------- 1 | buildDataForExport(); 14 | } 15 | 16 | public function toJson($options = 0): string 17 | { 18 | return json_encode($this->buildDataForExport(), $options); 19 | } 20 | 21 | public function toPrettyJson(): string 22 | { 23 | return json_encode($this->buildDataForExport(), JSON_PRETTY_PRINT); 24 | } 25 | 26 | public function toModel(string $model): Model 27 | { 28 | return new $model($this->buildDataForExport()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Concerns/EmptyCasts.php: -------------------------------------------------------------------------------- 1 | toArray()); 21 | } 22 | 23 | if ($value instanceof stdClass) { 24 | return new static((array) $value); 25 | } 26 | 27 | return new static([]); 28 | } 29 | 30 | public function toLivewire(): array 31 | { 32 | $fullArray = json_decode(json_encode($this->toArray()), true); 33 | 34 | $filteredArray = array_filter($fullArray, fn ($value) => ! is_null($value)); 35 | 36 | return $filteredArray; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeDTOCommand.php: -------------------------------------------------------------------------------- 1 | rootNamespace(), '', $name); 52 | $fullName = str_replace('\\', '/', "{$this->rootNamespace()}{$name}") . '.php'; 53 | 54 | return base_path(lcfirst($fullName)); 55 | } 56 | 57 | protected function getStub(): string 58 | { 59 | return $this->resolveStubPath(match (true) { 60 | $this->option('resource') => 'resource_dto.stub', 61 | $this->option('simple') => 'simple_dto.stub', 62 | default => 'dto.stub', 63 | }); 64 | } 65 | 66 | /** 67 | * Resolve the fully-qualified path to the stub. 68 | */ 69 | protected function resolveStubPath(string $stub): string 70 | { 71 | return file_exists($customPath = $this->laravel->basePath(trim("stubs/{$stub}", '/'))) 72 | ? $customPath 73 | : __DIR__ . '/../stubs/' . $stub; 74 | } 75 | 76 | protected function getOptions(): array 77 | { 78 | return [ 79 | ['force', null, InputOption::VALUE_NONE, 'Create the class even if the DTO already exists'], 80 | ['simple', null, InputOption::VALUE_NONE, 'If the DTO should be a SimpleDTO'], 81 | ['resource', null, InputOption::VALUE_NONE, 'If the DTO should be a ResourceDTO'], 82 | ]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Console/Commands/PublishStubsCommand.php: -------------------------------------------------------------------------------- 1 | call('vendor:publish', [ 45 | '--tag' => 'validatedDTO-stubs', 46 | '--force' => $this->option('force'), 47 | ]); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Console/stubs/dto.stub: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 20 | $this->commands([ 21 | MakeDTOCommand::class, 22 | PublishStubsCommand::class, 23 | ]); 24 | } 25 | 26 | $this->publishes( 27 | [ 28 | __DIR__ . '/../../config/dto.php' => base_path('config/dto.php'), 29 | ], 30 | 'config' 31 | ); 32 | 33 | $this->publishes([ 34 | __DIR__ . '/../../src/Console/stubs/resource_dto.stub' => base_path('stubs/resource_dto.stub'), 35 | __DIR__ . '/../../src/Console/stubs/simple_dto.stub' => base_path('stubs/simple_dto.stub'), 36 | __DIR__ . '/../../src/Console/stubs/dto.stub' => base_path('stubs/dto.stub'), 37 | ], 'validatedDTO-stubs'); 38 | } 39 | 40 | /** 41 | * @return void 42 | */ 43 | public function register() 44 | { 45 | $this->mergeConfigFrom(__DIR__ . '/../../config/dto.php', 'dto'); 46 | 47 | $this->app->beforeResolving(BaseDTO::class, function ($class, $parameters, $app) { 48 | if ($app->has($class)) { 49 | return; 50 | } 51 | 52 | $app->bind( 53 | $class, 54 | fn ($container) => $class::fromRequest($container['request']) 55 | ); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ResourceDTO.php: -------------------------------------------------------------------------------- 1 | status = $status; 22 | $this->headers = $headers; 23 | } 24 | 25 | public static function collection(array $data, int $status = 200, array $headers = []): ResourceCollection 26 | { 27 | return new ResourceCollection($data, static::class, $status, $headers); 28 | } 29 | 30 | /** 31 | * @param Request $request 32 | */ 33 | public function toResponse($request): JsonResponse 34 | { 35 | return new JsonResponse($this->toArray(), $this->status, $this->headers); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/SimpleDTO.php: -------------------------------------------------------------------------------- 1 | buildAttributesData(); 80 | $this->dtoData = $this->buildDataForValidation($data); 81 | 82 | $this->initConfig(); 83 | 84 | $this->isValidData() 85 | ? $this->passedValidation() 86 | : $this->failedValidation(); 87 | } 88 | 89 | public function __set(string $name, mixed $value): void 90 | { 91 | $this->{$name} = $value; 92 | } 93 | 94 | public function __get(string $name): mixed 95 | { 96 | return $this->{$name} ?? null; 97 | } 98 | 99 | public function __serialize(): array 100 | { 101 | return $this->jsonSerialize(); 102 | } 103 | 104 | public function __unserialize(array $data): void 105 | { 106 | $this->__construct($data); 107 | } 108 | 109 | /** 110 | * Defines the default values for the properties of the DTO. 111 | */ 112 | abstract protected function defaults(): array; 113 | 114 | /** 115 | * Defines the type casting for the properties of the DTO. 116 | */ 117 | abstract protected function casts(): array; 118 | 119 | /** 120 | * Cast the given value to a DTO instance. 121 | * 122 | * @param Model $model 123 | * @param string $key 124 | * @param mixed $value 125 | * @param array $attributes 126 | * @return $this 127 | * 128 | * @throws ValidationException|MissingCastTypeException|CastTargetException 129 | */ 130 | public function get($model, $key, $value, $attributes) 131 | { 132 | $arrayCast = new ArrayCast(); 133 | 134 | return new static($arrayCast->cast($key, $value)); 135 | } 136 | 137 | /** 138 | * Prepare the value for storage. 139 | * 140 | * @param Model $model 141 | * @param string $key 142 | * @param mixed $value 143 | * @param array $attributes 144 | * @return string 145 | */ 146 | public function set($model, $key, $value, $attributes) 147 | { 148 | if (is_string($value)) { 149 | return $value; 150 | } 151 | if (is_array($value)) { 152 | return json_encode($value); 153 | } 154 | if ($value instanceof ValidatedDTO) { 155 | return $value->toJson(); 156 | } 157 | 158 | return ''; 159 | } 160 | 161 | /* 162 | * JsonSerializable 163 | */ 164 | public function jsonSerialize(): mixed 165 | { 166 | return $this->toArray(); 167 | } 168 | 169 | /** 170 | * Maps the DTO properties before the DTO instantiation. 171 | */ 172 | protected function mapData(): array 173 | { 174 | return []; 175 | } 176 | 177 | /** 178 | * Maps the DTO properties before the DTO transformation. 179 | */ 180 | protected function mapToTransform(): array 181 | { 182 | return []; 183 | } 184 | 185 | /** 186 | * @throws MissingCastTypeException|CastTargetException 187 | */ 188 | protected function passedValidation(bool $forceCast = false): void 189 | { 190 | $this->validatedData = $this->validatedData($forceCast); 191 | /** @var array $casts */ 192 | $casts = $this->buildCasts(); 193 | 194 | foreach ($this->validatedData as $key => $value) { 195 | $this->{$key} = $value; 196 | } 197 | 198 | $defaults = [ 199 | ...$this->defaults(), 200 | ...$this->dtoDefaults, 201 | ]; 202 | 203 | foreach ($defaults as $key => $value) { 204 | if ( 205 | ! property_exists($this, $key) || 206 | ! isset($this->{$key}) 207 | ) { 208 | if (! array_key_exists($key, $casts)) { 209 | if ($this->requireCasting) { 210 | throw new MissingCastTypeException($key); 211 | } 212 | 213 | $this->{$key} = $value; 214 | $this->validatedData[$key] = $value; 215 | 216 | continue; 217 | } 218 | 219 | $formatted = $this->shouldReturnNull($key, $value) 220 | ? null 221 | : $this->castValue($casts[$key], $key, $value, $forceCast); 222 | 223 | $this->{$key} = $formatted; 224 | $this->validatedData[$key] = $formatted; 225 | } 226 | } 227 | 228 | $this->dtoData = []; 229 | } 230 | 231 | protected function failedValidation(): void 232 | { 233 | // Do nothing 234 | } 235 | 236 | protected function isValidData(): bool 237 | { 238 | return true; 239 | } 240 | 241 | /** 242 | * Builds the validated data from the given data and the rules. 243 | * 244 | * @throws MissingCastTypeException|CastTargetException 245 | */ 246 | protected function validatedData(bool $forceCast = false): array 247 | { 248 | $acceptedKeys = $this->getAcceptedProperties(); 249 | $result = []; 250 | 251 | /** @var array $casts */ 252 | $casts = $this->buildCasts(); 253 | 254 | foreach ($this->dtoData as $key => $value) { 255 | if (in_array($key, $acceptedKeys)) { 256 | if (! array_key_exists($key, $casts)) { 257 | if ($this->requireCasting) { 258 | throw new MissingCastTypeException($key); 259 | } 260 | $result[$key] = $value; 261 | 262 | continue; 263 | } 264 | 265 | $result[$key] = $this->shouldReturnNull($key, $value) 266 | ? null 267 | : $this->castValue($casts[$key], $key, $value, $forceCast); 268 | } 269 | } 270 | 271 | foreach ($acceptedKeys as $property) { 272 | if (! array_key_exists($property, $result)) { 273 | $this->{$property} = null; 274 | } 275 | } 276 | 277 | return $result; 278 | } 279 | 280 | /** 281 | * @throws CastTargetException 282 | */ 283 | protected function castValue(mixed $cast, string $key, mixed $value, bool $forceCast = false): mixed 284 | { 285 | if ($this->lazyValidation && ! $forceCast) { 286 | return $value; 287 | } 288 | 289 | if ($cast instanceof Castable) { 290 | return $cast->cast($key, $value); 291 | } 292 | 293 | if (! is_callable($cast)) { 294 | throw new CastTargetException($key); 295 | } 296 | 297 | return $cast($key, $value); 298 | } 299 | 300 | protected function shouldReturnNull(string $key, mixed $value): bool 301 | { 302 | return is_null($value); 303 | } 304 | 305 | protected function buildCasts(): array 306 | { 307 | $casts = []; 308 | foreach ($this->dtoCasts as $property => $cast) { 309 | if (is_null($cast->param)) { 310 | $casts[$property] = new $cast->type(); 311 | 312 | continue; 313 | } 314 | 315 | $param = match (true) { 316 | in_array($cast->type, [EnumCast::class, DTOCast::class]) => $cast->param, 317 | default => new $cast->param(), 318 | }; 319 | 320 | $casts[$property] = new $cast->type($param); 321 | } 322 | 323 | return [ 324 | ...$this->casts(), 325 | ...$casts, 326 | ]; 327 | } 328 | 329 | protected function buildDataForExport(): array 330 | { 331 | $mapping = [ 332 | ...$this->mapToTransform(), 333 | ...$this->dtoMapTransform, 334 | ]; 335 | 336 | $data = $this->validatedData; 337 | foreach ($this->getAcceptedProperties() as $property) { 338 | if (! array_key_exists($property, $data) && isset($this->{$property})) { 339 | $data[$property] = $this->{$property}; 340 | } 341 | } 342 | 343 | return $this->mapDTOData($mapping, $data); 344 | } 345 | 346 | protected function buildDataForValidation(array $data): array 347 | { 348 | $mapping = [ 349 | ...$this->mapData(), 350 | ...$this->dtoMapData, 351 | ]; 352 | 353 | return $this->mapDTOData($mapping, $data); 354 | } 355 | 356 | private function buildAttributesData(): void 357 | { 358 | $publicProperties = $this->getPublicProperties(); 359 | 360 | $validatedProperties = $this->getPropertiesForAttribute($publicProperties, Rules::class); 361 | foreach ($validatedProperties as $property => $attribute) { 362 | $attributeInstance = $attribute->newInstance(); 363 | $this->dtoRules[$property] = $attributeInstance->rules; 364 | $this->dtoMessages[$property] = $attributeInstance->messages ?? []; 365 | } 366 | 367 | $this->dtoMessages = array_filter( 368 | $this->dtoMessages, 369 | fn ($value) => $value !== [] 370 | ); 371 | 372 | $defaultProperties = $this->getPropertiesForAttribute($publicProperties, DefaultValue::class); 373 | foreach ($defaultProperties as $property => $attribute) { 374 | $attributeInstance = $attribute->newInstance(); 375 | $this->dtoDefaults[$property] = $attributeInstance->value; 376 | } 377 | 378 | $castProperties = $this->getPropertiesForAttribute($publicProperties, Cast::class); 379 | foreach ($castProperties as $property => $attribute) { 380 | $attributeInstance = $attribute->newInstance(); 381 | $this->dtoCasts[$property] = $attributeInstance; 382 | } 383 | 384 | $mapDataProperties = $this->getPropertiesForAttribute($publicProperties, Map::class); 385 | foreach ($mapDataProperties as $property => $attribute) { 386 | $attributeInstance = $attribute->newInstance(); 387 | 388 | if (! blank($attributeInstance->data)) { 389 | $this->dtoMapData[$attributeInstance->data] = $property; 390 | } 391 | 392 | if (! blank($attributeInstance->transform)) { 393 | $this->dtoMapTransform[$property] = $attributeInstance->transform; 394 | } 395 | } 396 | } 397 | 398 | private function getPublicProperties(): array 399 | { 400 | $reflectionClass = new ReflectionClass($this); 401 | $dtoProperties = []; 402 | 403 | foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { 404 | if ($this->isforbiddenProperty($property->getName())) { 405 | continue; 406 | } 407 | 408 | $reflectionProperty = new ReflectionProperty($this, $property->getName()); 409 | $attributes = $reflectionProperty->getAttributes(); 410 | $dtoProperties[$property->getName()] = $attributes; 411 | } 412 | 413 | return $dtoProperties; 414 | } 415 | 416 | private function getPropertiesForAttribute(array $properties, string $attribute): array 417 | { 418 | $result = []; 419 | foreach ($properties as $property => $attributes) { 420 | foreach ($attributes as $attr) { 421 | if ($attr->getName() === $attribute) { 422 | $result[$property] = $attr; 423 | } 424 | } 425 | } 426 | 427 | return $result; 428 | } 429 | 430 | private function mapDTOData(array $mapping, array $data): array 431 | { 432 | $mappedData = []; 433 | foreach ($data as $key => $value) { 434 | $properties = $this->getMappedProperties($mapping, $key); 435 | if ($properties !== [] && $this->isArrayable($value)) { 436 | $formatted = $this->formatArrayableValue($value); 437 | 438 | foreach ($properties as $property => $mappedValue) { 439 | $mappedData[$mappedValue] = $formatted[$property] ?? null; 440 | } 441 | 442 | continue; 443 | } 444 | 445 | $property = array_key_exists($key, $mapping) 446 | ? $mapping[$key] 447 | : $key; 448 | 449 | if (isset($this->{$key}) && $value !== $this->{$key}) { 450 | $value = $this->{$key}; 451 | } 452 | 453 | $mappedData[$property] = $this->isArrayable($value) 454 | ? $this->formatArrayableValue($value) 455 | : $value; 456 | } 457 | 458 | return $mappedData; 459 | } 460 | 461 | private function getMappedProperties(array $mapping, string $key): array 462 | { 463 | $properties = []; 464 | foreach ($mapping as $mappedKey => $mappedValue) { 465 | if (str_starts_with($mappedKey, "{$key}.")) { 466 | $arrayKey = str_replace("{$key}.", '', $mappedKey); 467 | $properties[$arrayKey] = $mappedValue; 468 | } 469 | 470 | if (str_starts_with($mappedValue, "{$key}.")) { 471 | $arrayKey = str_replace("{$key}.", '', $mappedValue); 472 | $properties[$arrayKey] = $mappedKey; 473 | } 474 | } 475 | 476 | return $properties; 477 | } 478 | 479 | private function isArrayable(mixed $value): bool 480 | { 481 | return is_array($value) || 482 | $value instanceof Arrayable || 483 | $value instanceof Collection || 484 | $value instanceof ValidatedDTO || 485 | $value instanceof Model || 486 | (is_object($value) && ! ($value instanceof UploadedFile)); 487 | } 488 | 489 | private function formatArrayableValue(mixed $value): array|int|string 490 | { 491 | return match (true) { 492 | is_array($value) => $value, 493 | $value instanceof BackedEnum => $value->value, 494 | $value instanceof UnitEnum => $value->name, 495 | $value instanceof Carbon || $value instanceof CarbonImmutable => $value->toISOString(true), 496 | $value instanceof Collection => $this->transformCollectionToArray($value), 497 | $value instanceof SimpleDTO => $this->transformDTOToArray($value), 498 | $value instanceof Arrayable => $value->toArray(), 499 | is_object($value) => (array) $value, 500 | default => [], 501 | }; 502 | } 503 | 504 | private function transformCollectionToArray(Collection $collection): array 505 | { 506 | return $collection->map(fn ($item) => $this->isArrayable($item) 507 | ? $this->formatArrayableValue($item) 508 | : $item)->toArray(); 509 | } 510 | 511 | private function transformDTOToArray(SimpleDTO $dto): array 512 | { 513 | $result = []; 514 | foreach ($dto->buildDataForExport() as $key => $value) { 515 | $result[$key] = $this->isArrayable($value) 516 | ? $this->formatArrayableValue($value) 517 | : $value; 518 | } 519 | 520 | return $result; 521 | } 522 | 523 | private function initConfig(): void 524 | { 525 | $config = config('dto'); 526 | if (! empty($config) && array_key_exists('require_casting', $config)) { 527 | $this->requireCasting = $config['require_casting']; 528 | } 529 | } 530 | 531 | private function getAcceptedProperties(): array 532 | { 533 | $acceptedKeys = []; 534 | foreach (get_class_vars($this::class) as $key => $value) { 535 | if (! $this->isforbiddenProperty($key)) { 536 | $acceptedKeys[] = $key; 537 | } 538 | } 539 | 540 | return $acceptedKeys; 541 | } 542 | 543 | private function isforbiddenProperty(string $property): bool 544 | { 545 | return in_array($property, [ 546 | 'dtoData', 547 | 'validatedData', 548 | 'requireCasting', 549 | 'validator', 550 | 'dtoRules', 551 | 'dtoMessages', 552 | 'dtoDefaults', 553 | 'dtoCasts', 554 | 'dtoMapData', 555 | 'dtoMapTransform', 556 | 'lazyValidation', 557 | ]); 558 | } 559 | } 560 | -------------------------------------------------------------------------------- /src/Support/ResourceCollection.php: -------------------------------------------------------------------------------- 1 | dtoClass); 34 | foreach ($this->data as $item) { 35 | $result[] = $dtoCast->cast('', $item)->toArray(); 36 | } 37 | 38 | return new JsonResponse($result, $this->status, $this->headers); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Support/TypeScriptCollector.php: -------------------------------------------------------------------------------- 1 | shouldCollect($class)) { 18 | return null; 19 | } 20 | 21 | $reflector = ClassTypeReflector::create($class); 22 | 23 | // Always use our ValidatedDtoTransformer 24 | $transformer = $this->config->buildTransformer(TypeScriptTransformer::class); 25 | 26 | return $transformer->transform( 27 | $reflector->getReflectionClass(), 28 | $reflector->getName() 29 | ); 30 | } 31 | 32 | protected function shouldCollect(ReflectionClass $class): bool 33 | { 34 | // Only collect classes that extend ValidatedDTO 35 | if (! $class->isSubclassOf(SimpleDTO::class)) { 36 | return false; 37 | } 38 | 39 | return true; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Support/TypeScriptTransformer.php: -------------------------------------------------------------------------------- 1 | config = $config; 33 | } 34 | 35 | public function transform(ReflectionClass $class, string $name): ?TransformedType 36 | { 37 | if (! $this->canTransform($class)) { 38 | return null; 39 | } 40 | 41 | $missingSymbols = new MissingSymbolsCollection(); 42 | $properties = $this->transformProperties($class, $missingSymbols); 43 | 44 | return TransformedType::create( 45 | $class, 46 | $name, 47 | '{' . PHP_EOL . $properties . '}', 48 | $missingSymbols 49 | ); 50 | } 51 | 52 | protected function canTransform(ReflectionClass $class): bool 53 | { 54 | return $class->isSubclassOf(SimpleDTO::class); 55 | } 56 | 57 | protected function transformProperties( 58 | ReflectionClass $class, 59 | MissingSymbolsCollection $missingSymbols 60 | ): string { 61 | $properties = array_filter( 62 | $class->getProperties(ReflectionProperty::IS_PUBLIC), 63 | function (ReflectionProperty $property) { 64 | // Exclude static properties 65 | if ($property->isStatic()) { 66 | return false; 67 | } 68 | 69 | // Exclude specific properties by name 70 | if (in_array($property->getName(), $this->excludedProperties)) { 71 | return false; 72 | } 73 | 74 | return true; 75 | } 76 | ); 77 | 78 | return array_reduce( 79 | $properties, 80 | function (string $carry, ReflectionProperty $property) use ($missingSymbols) { 81 | $transformed = $this->reflectionToTypeScript( 82 | $property, 83 | $missingSymbols, 84 | false, 85 | new ReplaceDefaultsTypeProcessor($this->config->getDefaultTypeReplacements()) 86 | ); 87 | 88 | if ($transformed === null) { 89 | return $carry; 90 | } 91 | 92 | $propertyName = $property->getName(); 93 | 94 | return "{$carry}{$propertyName}: {$transformed};" . PHP_EOL; 95 | }, 96 | '' 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/ValidatedDTO.php: -------------------------------------------------------------------------------- 1 | dtoData = $this->buildDataForValidation($this->toArray()); 42 | 43 | $this->validationPasses() 44 | ? $this->passedValidation(true) 45 | : $this->failedValidation(); 46 | } 47 | 48 | protected function after(\Illuminate\Validation\Validator $validator): void 49 | { 50 | // Do nothing 51 | } 52 | 53 | /** 54 | * Builds the validated data from the given data and the rules. 55 | * 56 | * @throws MissingCastTypeException|CastTargetException 57 | */ 58 | protected function validatedData(bool $forceCast = false): array 59 | { 60 | $acceptedKeys = array_keys($this->rulesList()); 61 | $result = []; 62 | 63 | /** @var array $casts */ 64 | $casts = $this->buildCasts(); 65 | 66 | foreach ($this->dtoData as $key => $value) { 67 | if (in_array($key, $acceptedKeys)) { 68 | if (! array_key_exists($key, $casts)) { 69 | if ($this->requireCasting) { 70 | throw new MissingCastTypeException($key); 71 | } 72 | $result[$key] = $value; 73 | 74 | continue; 75 | } 76 | 77 | $result[$key] = $this->shouldReturnNull($key, $value) 78 | ? null 79 | : $this->castValue($casts[$key], $key, $value, $forceCast); 80 | } 81 | } 82 | 83 | foreach ($acceptedKeys as $property) { 84 | if ( 85 | ! array_key_exists($property, $result) && 86 | $this->isOptionalProperty($property) 87 | ) { 88 | $result[$property] = null; 89 | } 90 | } 91 | 92 | return $result; 93 | } 94 | 95 | protected function isValidData(): bool 96 | { 97 | return $this->lazyValidation || $this->validationPasses(); 98 | } 99 | 100 | /** 101 | * @throws ValidationException 102 | */ 103 | protected function failedValidation(): void 104 | { 105 | throw new ValidationException($this->validator); 106 | } 107 | 108 | protected function shouldReturnNull(string $key, mixed $value): bool 109 | { 110 | return is_null($value) && $this->isOptionalProperty($key); 111 | } 112 | 113 | private function validationPasses(): bool 114 | { 115 | $this->validator = Validator::make( 116 | $this->dtoData, 117 | $this->rulesList(), 118 | $this->messagesList(), 119 | $this->attributes() 120 | ); 121 | 122 | $this->validator->after(fn (\Illuminate\Validation\Validator $validator) => $this->after($validator)); 123 | 124 | return $this->validator->passes(); 125 | } 126 | 127 | private function isOptionalProperty(string $property): bool 128 | { 129 | $rules = $this->rulesList(); 130 | $propertyRules = is_array($rules[$property]) 131 | ? $rules[$property] 132 | : explode('|', $rules[$property]); 133 | 134 | return in_array('optional', $propertyRules) || in_array('nullable', $propertyRules); 135 | } 136 | 137 | private function rulesList(): array 138 | { 139 | return [ 140 | ...$this->rules(), 141 | ...$this->dtoRules, 142 | ]; 143 | } 144 | 145 | private function messagesList(): array 146 | { 147 | return [ 148 | ...$this->messages(), 149 | ...$this->dtoMessages, 150 | ]; 151 | } 152 | } 153 | --------------------------------------------------------------------------------