├── .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 |

3 |
4 |
Validated DTO for Laravel
5 | Data Transfer Objects with validation for Laravel applications
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------