├── .php_cs.dist.php ├── LICENSE.md ├── README.md ├── composer.json ├── config └── typescript.php ├── phpstan.neon.dist └── src ├── Commands └── TypeScriptGenerateCommand.php ├── Contracts └── Generator.php ├── Definitions ├── TypeScriptProperty.php └── TypeScriptType.php ├── Generators ├── AbstractGenerator.php ├── ModelGenerator.php └── RequestGenerator.php ├── TypeScriptGenerator.php └── TypeScriptServiceProvider.php /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'class_attributes_separation' => [ 30 | 'elements' => [ 31 | 'method' => 'one', 32 | ], 33 | ], 34 | 'method_argument_space' => [ 35 | 'on_multiline' => 'ensure_fully_multiline', 36 | 'keep_multiple_spaces_after_comma' => true, 37 | ], 38 | 'single_trait_insert_per_statement' => true, 39 | ]) 40 | ->setFinder($finder); 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Boris Lepikhin 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel TypeScript 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/based/laravel-typescript.svg?style=flat-square)](https://packagist.org/packages/based/laravel-typescript) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/lepikhinb/laravel-typescript/run-tests?label=tests)](https://github.com/lepikhinb/laravel-typescript/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/based/laravel-typescript.svg?style=flat-square)](https://packagist.org/packages/based/laravel-typescript) 6 | 7 | The package lets you generate TypeScript interfaces from your Laravel models. 8 | 9 | ## Introduction 10 | Say you have a model which has several properties (database columns) and multiple relations. 11 | ```php 12 | class Product extends Model 13 | { 14 | public function category(): BelongsTo 15 | { 16 | return $this->belongsTo(Category::class); 17 | } 18 | 19 | public function features(): HasMany 20 | { 21 | return $this->hasMany(Feature::class); 22 | } 23 | } 24 | ``` 25 | 26 | Laravel TypeScript will generate the following TypeScript interface: 27 | 28 | ```typescript 29 | declare namespace App.Models { 30 | export interface Product { 31 | id: number; 32 | category_id: number; 33 | name: string; 34 | price: number; 35 | created_at: string | null; 36 | updated_at: string | null; 37 | category?: App.Models.Category | null; 38 | features?: Array | null; 39 | } 40 | ... 41 | } 42 | ``` 43 | 44 | **Laravel TypeScript** supports: 45 | - [x] Database columns 46 | - [x] Model relations 47 | - [x] Model accessors 48 | - [ ] Casted attributes 49 | 50 | ## Installation 51 | 52 | **Laravel 8 and PHP 8 are required.** 53 | You can install the package via composer: 54 | 55 | ```bash 56 | composer require based/laravel-typescript 57 | ``` 58 | 59 | You can publish the config file with: 60 | ```bash 61 | php artisan vendor:publish --provider="Based\TypeScript\TypeScriptServiceProvider" --tag="typescript-config" 62 | ``` 63 | 64 | This is the contents of the published config file: 65 | 66 | ```php 67 | return [ 68 | 'generators' => [ 69 | Model::class => ModelGenerator::class, 70 | ], 71 | 72 | 'output' => resource_path('js/models.d.ts'), 73 | 74 | // load namespaces from composer's `dev-autoload` 75 | 'autoloadDev' => false, 76 | ]; 77 | 78 | ``` 79 | 80 | ## Usage 81 | 82 | Generate TypeScript interfaces. 83 | ```bash 84 | php artisan typescript:generate 85 | ``` 86 | 87 | Example usage with Vue 3: 88 | ```typescript 89 | import { defineComponent, PropType } from "vue"; 90 | 91 | export default defineComponent({ 92 | props: { 93 | product: { 94 | type: Object as PropType, 95 | required: true, 96 | }, 97 | }, 98 | } 99 | ``` 100 | 101 | ## Testing 102 | 103 | ```bash 104 | composer test 105 | ``` 106 | 107 | ## Credits 108 | 109 | - [Boris Lepikhin](https://github.com/lepikhinb) 110 | - [All Contributors](../../contributors) 111 | 112 | ## License 113 | 114 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 115 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "based/laravel-typescript", 3 | "description": "Transform Laravel models into TypeScript interfaces", 4 | "keywords": [ 5 | "laravel", 6 | "typescript" 7 | ], 8 | "homepage": "https://github.com/lepikhinb/laravel-typescript", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Boris Lepikhin", 13 | "email": "boris@lepikhin.com", 14 | "role": "Developer" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.0", 19 | "doctrine/dbal": "^3.1", 20 | "illuminate/contracts": "^8.37|^9.0|^10.0", 21 | "spatie/laravel-package-tools": "^1.11.0" 22 | }, 23 | "require-dev": { 24 | "brianium/paratest": "^6.2", 25 | "nunomaduro/collision": "^5.3|^6.1.0", 26 | "nunomaduro/larastan": "^0.7.11|^2.0.1", 27 | "orchestra/testbench": "^6.15|^7.0.1|^8.0", 28 | "phpunit/phpunit": "^9.3" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "Based\\TypeScript\\": "src" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "Based\\TypeScript\\Tests\\": "tests" 38 | } 39 | }, 40 | "scripts": { 41 | "stan": "vendor/bin/phpstan analyse", 42 | "test": "./vendor/bin/testbench package:test --parallel --no-coverage", 43 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 44 | }, 45 | "config": { 46 | "sort-packages": true 47 | }, 48 | "extra": { 49 | "laravel": { 50 | "providers": [ 51 | "Based\\TypeScript\\TypeScriptServiceProvider" 52 | ] 53 | } 54 | }, 55 | "minimum-stability": "dev", 56 | "prefer-stable": true 57 | } 58 | -------------------------------------------------------------------------------- /config/typescript.php: -------------------------------------------------------------------------------- 1 | [ 8 | Model::class => ModelGenerator::class, 9 | ], 10 | 11 | 'paths' => [ 12 | // 13 | ], 14 | 15 | 'customRules' => [ 16 | // \App\Rules\MyCustomRule::class => 'string', 17 | // \App\Rules\MyOtherCustomRule::class => ['string', 'number'], 18 | ], 19 | 20 | 'output' => resource_path('js/models.d.ts'), 21 | 22 | 'autoloadDev' => false, 23 | ]; 24 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src 8 | 9 | # The level 8 is the highest level 10 | level: 5 11 | 12 | ignoreErrors: 13 | 14 | excludePaths: 15 | 16 | checkMissingIterableValueType: false -------------------------------------------------------------------------------- /src/Commands/TypeScriptGenerateCommand.php: -------------------------------------------------------------------------------- 1 | execute(); 24 | 25 | $this->comment('TypeScript definitions generated successfully'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Contracts/Generator.php: -------------------------------------------------------------------------------- 1 | types) 22 | ->when($this->nullable, fn(Collection $types) => $types->push(TypeScriptType::NULL)) 23 | ->join(' | ', ''); 24 | } 25 | 26 | public function __toString(): string 27 | { 28 | return collect($this->name) 29 | ->when($this->readonly, fn(Collection $definition) => $definition->prepend('readonly ')) 30 | ->when($this->optional, fn(Collection $definition) => $definition->push('?')) 31 | ->push(': ') 32 | ->push($this->getTypes()) 33 | ->push(';') 34 | ->join(''); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Definitions/TypeScriptType.php: -------------------------------------------------------------------------------- 1 | "; 19 | } 20 | 21 | public static function fromMethod(ReflectionMethod $method): array 22 | { 23 | $types = $method->getReturnType() instanceof ReflectionUnionType 24 | ? $method->getReturnType()->getTypes() 25 | : (string) $method->getReturnType(); 26 | 27 | if (is_string($types) && strpos($types, '?') !== false) { 28 | $types = [ 29 | str_replace('?', '', $types), 30 | self::NULL 31 | ]; 32 | } 33 | 34 | return collect($types) 35 | ->map(function (string $type) { 36 | return match ($type) { 37 | 'int' => self::NUMBER, 38 | 'float' => self::NUMBER, 39 | 'string' => self::STRING, 40 | 'array' => self::array(), 41 | 'object' => self::ANY, 42 | 'null' => self::NULL, 43 | 'bool' => self::BOOLEAN, 44 | default => self::ANY, 45 | }; 46 | }) 47 | ->toArray(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Generators/AbstractGenerator.php: -------------------------------------------------------------------------------- 1 | reflection = $reflection; 15 | $this->boot(); 16 | 17 | if (empty(trim($definition = $this->getDefinition()))) { 18 | return " export interface {$this->tsClassName()} {}" . PHP_EOL; 19 | } 20 | 21 | return <<tsClassName()} { 23 | $definition 24 | } 25 | 26 | TS; 27 | } 28 | 29 | protected function boot(): void 30 | { 31 | // 32 | } 33 | 34 | protected function tsClassName(): string 35 | { 36 | return str_replace('\\', '.', $this->reflection->getShortName()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Generators/ModelGenerator.php: -------------------------------------------------------------------------------- 1 | */ 31 | protected Collection $columns; 32 | 33 | public function __construct() 34 | { 35 | // Enums aren't supported by DBAL, so map enum columns to string. 36 | DB::getDoctrineSchemaManager()->getDatabasePlatform()->registerDoctrineTypeMapping('enum', 'string'); 37 | } 38 | 39 | public function getDefinition(): ?string 40 | { 41 | return collect([ 42 | $this->getProperties(), 43 | $this->getRelations(), 44 | $this->getManyRelations(), 45 | $this->getAccessors(), 46 | ]) 47 | ->filter(fn (string $part) => !empty($part)) 48 | ->join(PHP_EOL . ' '); 49 | } 50 | 51 | /** 52 | * @throws \Doctrine\DBAL\Exception 53 | * @throws \ReflectionException 54 | */ 55 | protected function boot(): void 56 | { 57 | $this->model = $this->reflection->newInstance(); 58 | 59 | $this->columns = collect( 60 | $this->model->getConnection() 61 | ->getDoctrineSchemaManager() 62 | ->listTableColumns($this->model->getConnection()->getTablePrefix() . $this->model->getTable()) 63 | ); 64 | } 65 | 66 | protected function getProperties(): string 67 | { 68 | return $this->columns->map(function (Column $column) { 69 | return (string) new TypeScriptProperty( 70 | name: $column->getName(), 71 | types: $this->getPropertyType($column->getType()->getName()), 72 | nullable: !$column->getNotnull() 73 | ); 74 | }) 75 | ->join(PHP_EOL . ' '); 76 | } 77 | 78 | protected function getAccessors(): string 79 | { 80 | $relationsToSkip = $this->getRelationMethods() 81 | ->map(function (ReflectionMethod $method) { 82 | return Str::snake($method->getName()); 83 | }); 84 | 85 | return $this->getMethods() 86 | ->filter(fn (ReflectionMethod $method) => Str::startsWith($method->getName(), 'get')) 87 | ->filter(fn (ReflectionMethod $method) => Str::endsWith($method->getName(), 'Attribute')) 88 | ->mapWithKeys(function (ReflectionMethod $method) { 89 | $property = (string) Str::of($method->getName()) 90 | ->between('get', 'Attribute') 91 | ->snake(); 92 | 93 | return [$property => $method]; 94 | }) 95 | ->reject(function (ReflectionMethod $method, string $property) { 96 | return $this->columns->contains(fn (Column $column) => $column->getName() == $property); 97 | }) 98 | ->reject(function (ReflectionMethod $method, string $property) use ($relationsToSkip) { 99 | return $relationsToSkip->contains($property); 100 | }) 101 | ->map(function (ReflectionMethod $method, string $property) { 102 | return (string) new TypeScriptProperty( 103 | name: $property, 104 | types: TypeScriptType::fromMethod($method), 105 | optional: true, 106 | readonly: true 107 | ); 108 | }) 109 | ->join(PHP_EOL . ' '); 110 | } 111 | 112 | protected function getRelations(): string 113 | { 114 | return $this->getRelationMethods() 115 | ->map(function (ReflectionMethod $method) { 116 | return (string) new TypeScriptProperty( 117 | name: Str::snake($method->getName()), 118 | types: $this->getRelationType($method), 119 | optional: true, 120 | nullable: true 121 | ); 122 | }) 123 | ->join(PHP_EOL . ' '); 124 | } 125 | 126 | protected function getManyRelations(): string 127 | { 128 | return $this->getRelationMethods() 129 | ->filter(fn (ReflectionMethod $method) => $this->isManyRelation($method)) 130 | ->map(function (ReflectionMethod $method) { 131 | return (string) new TypeScriptProperty( 132 | name: Str::snake($method->getName()) . '_count', 133 | types: TypeScriptType::NUMBER, 134 | optional: true, 135 | nullable: true 136 | ); 137 | }) 138 | ->join(PHP_EOL . ' '); 139 | } 140 | 141 | protected function getRelationMethods(): Collection 142 | { 143 | return $this->getMethods() 144 | ->filter(function (ReflectionMethod $method) { 145 | try { 146 | return $method->invoke($this->model) instanceof Relation; 147 | } catch (Throwable) { 148 | return false; 149 | } 150 | }) 151 | // [TODO] Resolve trait/parent relations as well (e.g. DatabaseNotification) 152 | // skip traits for awhile 153 | ->filter(function (ReflectionMethod $method) { 154 | return collect($this->reflection->getTraits()) 155 | ->filter(function (ReflectionClass $trait) use ($method) { 156 | return $trait->hasMethod($method->name); 157 | }) 158 | ->isEmpty(); 159 | }); 160 | } 161 | 162 | protected function getMethods(): Collection 163 | { 164 | return collect($this->reflection->getMethods(ReflectionMethod::IS_PUBLIC)) 165 | ->reject(fn (ReflectionMethod $method) => $method->isStatic()) 166 | ->reject(fn (ReflectionMethod $method) => $method->getNumberOfParameters()); 167 | } 168 | 169 | protected function getPropertyType(string $type): string|array 170 | { 171 | return match ($type) { 172 | Types::ARRAY => [TypeScriptType::array(), TypeScriptType::ANY], 173 | Types::ASCII_STRING => TypeScriptType::STRING, 174 | Types::BIGINT => TypeScriptType::NUMBER, 175 | Types::BINARY => TypeScriptType::STRING, 176 | Types::BLOB => TypeScriptType::STRING, 177 | Types::BOOLEAN => TypeScriptType::BOOLEAN, 178 | Types::DATE_MUTABLE => TypeScriptType::STRING, 179 | Types::DATE_IMMUTABLE => TypeScriptType::STRING, 180 | Types::DATEINTERVAL => TypeScriptType::STRING, 181 | Types::DATETIME_MUTABLE => TypeScriptType::STRING, 182 | Types::DATETIME_IMMUTABLE => TypeScriptType::STRING, 183 | Types::DATETIMETZ_MUTABLE => TypeScriptType::STRING, 184 | Types::DATETIMETZ_IMMUTABLE => TypeScriptType::STRING, 185 | Types::DECIMAL => TypeScriptType::NUMBER, 186 | Types::FLOAT => TypeScriptType::NUMBER, 187 | Types::GUID => TypeScriptType::STRING, 188 | Types::INTEGER => TypeScriptType::NUMBER, 189 | Types::JSON => [TypeScriptType::array(), TypeScriptType::ANY], 190 | Types::OBJECT => TypeScriptType::ANY, 191 | Types::SIMPLE_ARRAY => [TypeScriptType::array(), TypeScriptType::ANY], 192 | Types::SMALLINT => TypeScriptType::NUMBER, 193 | Types::STRING => TypeScriptType::STRING, 194 | Types::TEXT => TypeScriptType::STRING, 195 | Types::TIME_MUTABLE => TypeScriptType::NUMBER, 196 | Types::TIME_IMMUTABLE => TypeScriptType::NUMBER, 197 | default => TypeScriptType::ANY, 198 | }; 199 | } 200 | 201 | protected function getRelationType(ReflectionMethod $method): string 202 | { 203 | $relationReturn = $method->invoke($this->model); 204 | $related = str_replace('\\', '.', get_class($relationReturn->getRelated())); 205 | 206 | if ($this->isManyRelation($method)) { 207 | return TypeScriptType::array($related); 208 | } 209 | 210 | if ($this->isOneRelattion($method)) { 211 | return $related; 212 | } 213 | 214 | return TypeScriptType::ANY; 215 | } 216 | 217 | protected function isManyRelation(ReflectionMethod $method): bool 218 | { 219 | $relationType = get_class($method->invoke($this->model)); 220 | 221 | return in_array( 222 | $relationType, 223 | [ 224 | HasMany::class, 225 | BelongsToMany::class, 226 | HasManyThrough::class, 227 | MorphMany::class, 228 | MorphToMany::class, 229 | ] 230 | ); 231 | } 232 | 233 | protected function isOneRelattion(ReflectionMethod $method): bool 234 | { 235 | $relationType = get_class($method->invoke($this->model)); 236 | 237 | return in_array( 238 | $relationType, 239 | [ 240 | HasOne::class, 241 | BelongsTo::class, 242 | MorphOne::class, 243 | HasOneThrough::class, 244 | ] 245 | ); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/Generators/RequestGenerator.php: -------------------------------------------------------------------------------- 1 | */ 38 | protected array $customRules; 39 | 40 | public function getDefinition(): ?string 41 | { 42 | $rules = collect($this->validator->getRules()); 43 | 44 | if (method_exists($this->request, 'rules')) { 45 | $rules = $rules->merge($this->request->rules()); 46 | } 47 | 48 | if ($rules->isEmpty()) { 49 | return null; 50 | } 51 | 52 | $rules = $rules 53 | ->flatMap(fn (array|string $rules, string $property) => $this->parseRules($property, $rules)) 54 | ->filter(); 55 | 56 | if ($rules->isEmpty()) { 57 | return null; 58 | } 59 | 60 | return $this->rulesToStringArray($rules)->join(PHP_EOL . ' '); 61 | } 62 | 63 | /** 64 | * @throws \ReflectionException 65 | */ 66 | protected function boot(): void 67 | { 68 | /** @var FormRequest $request */ 69 | $request = $this->reflection->newInstance(); 70 | $this->request = $request->setContainer(app()); 71 | 72 | $clazz = new \ReflectionClass($this->request); 73 | $method = $clazz->getMethod('getValidatorInstance'); 74 | $method->setAccessible(true); 75 | 76 | /** @var \Illuminate\Validation\Validator $validator */ 77 | $this->validator = $method->invoke($this->request); 78 | 79 | $this->customRules = config('typescript.customRules'); 80 | } 81 | 82 | /** 83 | * @param array|string $rules 84 | * @param string $property 85 | * @return string[]|null 86 | */ 87 | private function parseRules(string $property, array|string $rules): ?array 88 | { 89 | if (is_string($rules)) { 90 | $rules = explode('|', $rules); 91 | } 92 | 93 | $rules = collect($rules) 94 | ->values() 95 | ->flatMap(fn (string|Rule $rule) => match (true) { 96 | is_object($rule) => $this->parseRuleObject($property, $rule), 97 | default => $this->parseRuleString($property, $rule) 98 | }) 99 | ->except(''); 100 | 101 | if ($rules->isEmpty()) { 102 | $rules['any'] = null; 103 | } 104 | 105 | if ($rules->has('prohibited')) { 106 | return null; 107 | } 108 | 109 | $types = $this->getPropertyTypes($rules); 110 | if (empty($types)) { 111 | return null; 112 | } 113 | 114 | $isOptional = $rules->has('sometimes') || (!$rules->has('present') && !$rules->has('required')); 115 | $isNullable = $rules->has('nullable'); 116 | 117 | $properties = [ 118 | $property => [ 119 | 'name' => $property, 120 | 'types' => $types, 121 | 'optional' => $isOptional, 122 | 'nullable' => $isNullable, 123 | ] 124 | ]; 125 | 126 | if ($rules->has('confirmed')) { 127 | $properties[$extraProperty = $rules['confirmed'] ?? "{$property}_confirmation"] = [ 128 | 'name' => $extraProperty, 129 | 'types' => $types, 130 | 'optional' => $isOptional, 131 | 'nullable' => $isNullable, 132 | ]; 133 | } 134 | 135 | return $properties; 136 | } 137 | 138 | /** 139 | * @param \Illuminate\Support\Collection $rules 140 | * @return string[] 141 | */ 142 | private function getPropertyTypes(Collection $rules): array 143 | { 144 | return $rules 145 | ->keys() 146 | ->filter(fn (string $rule) => !in_array($rule, static::CONTROL_KEYS, true)) 147 | ->values() 148 | ->all(); 149 | } 150 | 151 | /** 152 | * @throws \Doctrine\DBAL\Exception 153 | */ 154 | private function parseRuleObject(string $property, object $rule): Collection 155 | { 156 | if (method_exists($rule, '__toString')) { 157 | return $this->parseRuleString($property, (string) $rule); 158 | } 159 | 160 | return collect( 161 | match (true) { 162 | ($rule instanceof Password), ($rule instanceof FortifyPassword) => ['string' => null], 163 | ($rule instanceof ClosureValidationRule) => ['any' => null], 164 | in_array($clazz = get_class($rule), $this->customRules, true) => array_fill_keys( 165 | Arr::wrap($this->customRules[$clazz]), 166 | null 167 | ), 168 | default => null 169 | } 170 | ); 171 | } 172 | 173 | /** 174 | * @throws \Doctrine\DBAL\Exception 175 | */ 176 | private function parseRuleString(string $property, string $rule): Collection 177 | { 178 | return collect(explode(':', $rule, 2)) 179 | ->mapWithKeys( 180 | fn (string $args, int|string $key) => is_int($key) 181 | ? [$this->parseRuleName($property, $args) => null] 182 | : [$this->parseRuleName($property, $key, $args) => $args] 183 | ); 184 | } 185 | 186 | /** 187 | * @throws \Doctrine\DBAL\Exception 188 | */ 189 | private function parseRuleName(string $property, string $rule, string $args = null): ?string 190 | { 191 | return match ($rule) { 192 | 'nullable', 'sometimes', 'present', 'prohibited', 'required', 'same', 'confirmed' => $rule, 193 | 'exists', 'unique' => $this->resolveColumn($property, $args), 194 | 'accepted', 'accepted_if', 'boolean' => 'boolean', 195 | 'active_url', 'after', 'after_or_equal', 'alpha', 'alpha_dash', 'alpha_num', 'before', 'before_or_equal', 'current_password', 'date', 'date_equals', 'date_format', 'digits', 'digits_between', 'email', 'ends_with', 'ip', 'ipv4', 'ipv6', 'json', 'not_regex', 'password', 'regex', 'starts_with', 'string', 'timezone', 'url', 'uuid' => 'string', 196 | 'dimensions', 'file', 'image', 'mimetypes', 'mimes' => 'Blob | File', 197 | 'integer', 'numeric' => 'number', 198 | 'array' => 'array', 199 | default => null 200 | }; 201 | } 202 | 203 | /** 204 | * @param string $property 205 | * @param string|null $args 206 | * @return string|null 207 | * @throws \Doctrine\DBAL\Exception 208 | */ 209 | private function resolveColumn(string $property, ?string $args): ?string 210 | { 211 | $args = explode(',', $args); 212 | if (count($args) === 0) { 213 | return null; 214 | } 215 | 216 | $table = $args[0]; 217 | $columnName = Arr::get($args, 1) ?? $property; 218 | 219 | /** @var \Illuminate\Database\Connection $connection */ 220 | $connection = DB::connection(); 221 | 222 | $prefix = $connection->getTablePrefix(); 223 | 224 | if (!Schema::hasTable("$prefix$table") || !Schema::hasColumn("$prefix$table", $columnName)) { 225 | return null; 226 | } 227 | 228 | $schemaManager = $connection->getDoctrineSchemaManager(); 229 | $columns = collect($schemaManager->listTableColumns("$prefix$table")); 230 | 231 | /** @var Column $column */ 232 | $column = $columns->first(fn (Column $column) => $column->getName() === $columnName); 233 | 234 | return $this->getColumnType($column->getType()->getName()); 235 | } 236 | 237 | #[Pure] protected function getColumnType(string $type): string|array 238 | { 239 | return match ($type) { 240 | Types::ARRAY, Types::JSON, Types::SIMPLE_ARRAY => [TypeScriptType::array(), TypeScriptType::ANY], 241 | Types::ASCII_STRING, Types::BINARY, Types::BLOB, Types::DATE_MUTABLE, 242 | Types::DATE_IMMUTABLE, Types::DATEINTERVAL, Types::DATETIME_MUTABLE, 243 | Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATETIMETZ_IMMUTABLE, 244 | Types::GUID, Types::STRING, Types::TEXT => TypeScriptType::STRING, 245 | Types::BIGINT, Types::DECIMAL, Types::FLOAT, Types::INTEGER, 246 | Types::SMALLINT, Types::TIME_MUTABLE, Types::TIME_IMMUTABLE => TypeScriptType::NUMBER, 247 | Types::BOOLEAN => TypeScriptType::BOOLEAN, 248 | default => TypeScriptType::ANY, 249 | }; 250 | } 251 | 252 | private function rulesToStringArray(Collection $rules, int $depth = 0): Collection 253 | { 254 | /** @var Collection $arrayRules */ 255 | /** @var Collection $rules */ 256 | [$arrayRules, $rules] = $rules->partition( 257 | fn (array $value) => in_array('array', $value['types'], true) || 258 | str_contains($value['name'], '.') 259 | ); 260 | 261 | return $rules 262 | ->merge($this->mergeArrays($arrayRules, $depth + 1)) 263 | ->values() 264 | ->map(fn (array $value) => strval(app()->make(TypeScriptProperty::class, $value))) 265 | ->values(); 266 | } 267 | 268 | private function mergeArrays(Collection $rules, int $depth): Collection 269 | { 270 | /** @var Collection $dotRules */ 271 | /** @var Collection $rules */ 272 | [$dotRules, $rules] = $rules 273 | ->map(function (array $value, string $property) { 274 | $value['name'] = $property; 275 | $value['types'] = array_values( 276 | array_filter( 277 | $value['types'], 278 | fn (string $type) => $type !== 'array' 279 | ) 280 | ); 281 | $value['is_array'] = null; 282 | $value['children'] = []; 283 | 284 | return $value; 285 | }) 286 | ->partition(fn (array $value, string $property) => str_contains($property, '.')); 287 | 288 | $rules = $rules->all(); 289 | 290 | /** @var string $property */ 291 | /** @var array $value */ 292 | foreach ($dotRules as $property => $value) { 293 | [$property, $remainder] = explode('.', $property, 2); 294 | 295 | if (!array_key_exists($property, $rules)) { 296 | $rules[$property] = [ 297 | 'name' => $property, 298 | 'types' => [], 299 | 'optional' => false, 300 | 'nullable' => false, 301 | 'is_array' => null, 302 | 'children' => [], 303 | ]; 304 | } 305 | 306 | $isArray = $remainder === '*' || str_starts_with($remainder, '*.'); 307 | 308 | if ($rules[$property]['is_array'] !== null && $rules[$property]['is_array'] !== $isArray) { 309 | throw new \RuntimeException('Cannot combine array and object rules for the same property'); 310 | } 311 | 312 | $rules[$property]['is_array'] = $isArray; 313 | 314 | if ($remainder === '*') { 315 | $rules[$property]['types'] = array_merge( 316 | $rules[$property]['types'], 317 | $value['types'] 318 | ); 319 | 320 | continue; 321 | } 322 | 323 | if ($isArray) { 324 | $remainder = substr($remainder, 2); 325 | } 326 | 327 | $rules[$property]['children'][$remainder] = $value; 328 | } 329 | 330 | $rules = collect($rules); 331 | 332 | $prefix = str_repeat(" ", 8 + $depth * 4); 333 | $endPrefix = substr($prefix, 4); 334 | 335 | return $rules 336 | ->map(function (array $value) use ($depth, $prefix, $endPrefix) { 337 | $result = Arr::except($value, ['is_array', 'children']); 338 | 339 | if (empty($value['children'])) { 340 | if (empty($result['types'])) { 341 | $result['types'] = ['any']; 342 | } elseif ($value['is_array'] !== null) { 343 | $result['types'] = ['Array<' . implode(' | ', $result['types']) . '>']; 344 | } 345 | 346 | return $result; 347 | } 348 | 349 | /** @var Collection $plainArray */ 350 | /** @var Collection $children */ 351 | [$plainArray, $children] = collect($value['children']) 352 | ->partition(fn (array $value) => $value['name'] === '*'); 353 | 354 | if ($plainArray->isNotEmpty()) { 355 | $types = implode(' | ', $plainArray->first()['types']) ?: 'any'; 356 | 357 | $result['types'][] = "Array<{$types}>"; 358 | } 359 | 360 | if ($children->isNotEmpty()) { 361 | $typeObject = $this->rulesToStringArray($children, $depth) 362 | ->map(fn (string $line) => "$prefix$line") 363 | ->join(PHP_EOL); 364 | 365 | $typeObject = <<< END 366 | { 367 | $typeObject 368 | $endPrefix} 369 | END; 370 | 371 | 372 | if ($value['is_array']) { 373 | $typeObject = "Array<$typeObject>"; 374 | } 375 | 376 | $result['types'][] = $typeObject; 377 | } 378 | 379 | return $result; 380 | }); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/TypeScriptGenerator.php: -------------------------------------------------------------------------------- 1 | phpClasses() 25 | ->groupBy(fn (ReflectionClass $reflection) => $reflection->getNamespaceName()) 26 | ->map(fn (Collection $reflections, string $namespace) => $this->makeNamespace($namespace, $reflections)) 27 | ->reject(fn (string $namespaceDefinition) => empty($namespaceDefinition)) 28 | ->prepend( 29 | <<join(PHP_EOL); 39 | 40 | file_put_contents($this->output, $types); 41 | } 42 | 43 | protected function makeNamespace(string $namespace, Collection $reflections): string 44 | { 45 | return $reflections->map(fn (ReflectionClass $reflection) => $this->makeInterface($reflection)) 46 | ->whereNotNull() 47 | ->whenNotEmpty(function (Collection $definitions) use ($namespace) { 48 | $tsNamespace = str_replace('\\', '.', $namespace); 49 | 50 | return $definitions->prepend("declare namespace {$tsNamespace} {")->push('}' . PHP_EOL); 51 | }) 52 | ->join(PHP_EOL); 53 | } 54 | 55 | protected function makeInterface(ReflectionClass $reflection): ?string 56 | { 57 | $generator = collect($this->generators) 58 | ->filter(fn (string $generator, string $baseClass) => $reflection->isSubclassOf($baseClass)) 59 | ->values() 60 | ->first(); 61 | 62 | if (!$generator) { 63 | return null; 64 | } 65 | 66 | return (new $generator)->generate($reflection); 67 | } 68 | 69 | protected function phpClasses(): Collection 70 | { 71 | $composer = json_decode(file_get_contents(realpath('composer.json'))); 72 | 73 | return collect($composer->autoload->{'psr-4'}) 74 | ->when($this->autoloadDev, function (Collection $paths) use ($composer) { 75 | return $paths->merge( 76 | collect($composer->{'autoload-dev'}?->{'psr-4'}) 77 | ); 78 | }) 79 | ->merge($this->paths) 80 | ->flatMap(function (string $path, string $namespace) { 81 | return collect((new Finder)->in($path)->name('*.php')->files()) 82 | ->map(function (SplFileInfo $file) use ($path, $namespace) { 83 | return $namespace . str_replace( 84 | ['/', '.php'], 85 | ['\\', ''], 86 | Str::after($file->getRealPath(), realpath($path) . DIRECTORY_SEPARATOR) 87 | ); 88 | }) 89 | ->filter(function (string $className) { 90 | try { 91 | new ReflectionClass($className); 92 | 93 | return true; 94 | } catch (ReflectionException) { 95 | return false; 96 | } 97 | }) 98 | ->map(fn (string $className) => new ReflectionClass($className)) 99 | ->reject(fn (ReflectionClass $reflection) => $reflection->isAbstract()) 100 | ->values(); 101 | }); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/TypeScriptServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-typescript') 15 | ->hasConfigFile('typescript') 16 | ->hasCommand(TypeScriptGenerateCommand::class); 17 | } 18 | } 19 | --------------------------------------------------------------------------------