├── src ├── Exceptions │ ├── ColumnDoesNotExistException.php │ ├── TableDoesNotExistException.php │ ├── UnsupportedDbDriverException.php │ ├── MultipleTablesSuppliedException.php │ └── FailedToCreateRequestClassException.php ├── Contracts │ └── SchemaRulesResolverInterface.php ├── Resolvers │ ├── BaseSchemaRulesResolver.php │ ├── SchemaRulesResolverSqlite.php │ ├── SchemaRulesResolverPgSql.php │ └── SchemaRulesResolverMySql.php ├── LaravelSchemaRulesServiceProvider.php └── Commands │ └── GenerateRulesCommand.php ├── config └── schema-rules.php ├── LICENSE.md ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── composer.json ├── .php-cs-fixer.cache └── README.md /src/Exceptions/ColumnDoesNotExistException.php: -------------------------------------------------------------------------------- 1 | env('SCHEMA_RULES_TINYINT1_TO_BOOL', true), 11 | 12 | /** 13 | * The min default length for a required string validation rule is 1 character. 14 | * Changes this to what ever fits best for you! 15 | */ 16 | 'string_min_length' => env('SCHEMA_RULES_STRING_MIN_LENGTH', 1), 17 | 18 | /** 19 | * Always skip these columns 20 | */ 21 | 'skip_columns' => ['created_at', 'updated_at', 'deleted_at'], 22 | 23 | ]; 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) laracraft-tech 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 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->ignoreDotFiles(true) 10 | ->ignoreVCS(true); 11 | 12 | return (new PhpCsFixer\Config()) 13 | ->setRules([ 14 | '@PSR12' => true, 15 | 'array_syntax' => ['syntax' => 'short'], 16 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 17 | 'no_unused_imports' => true, 18 | 'not_operator_with_successor_space' => true, 19 | 'trailing_comma_in_multiline' => true, 20 | 'phpdoc_scalar' => true, 21 | 'unary_operator_spaces' => true, 22 | 'binary_operator_spaces' => true, 23 | 'blank_line_before_statement' => [ 24 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 25 | ], 26 | 'phpdoc_single_line_var_spacing' => true, 27 | 'phpdoc_var_without_name' => true, 28 | 'class_attributes_separation' => [ 29 | 'elements' => [ 30 | 'method' => 'one', 31 | ], 32 | ], 33 | 'method_argument_space' => [ 34 | 'on_multiline' => 'ensure_fully_multiline', 35 | 'keep_multiple_spaces_after_comma' => true, 36 | ], 37 | 'single_trait_insert_per_statement' => true, 38 | ]) 39 | ->setFinder($finder); 40 | -------------------------------------------------------------------------------- /src/Resolvers/BaseSchemaRulesResolver.php: -------------------------------------------------------------------------------- 1 | table = $table; 17 | $this->columns = $columns; 18 | } 19 | 20 | public function generate(): array 21 | { 22 | $tableColumns = $this->getColumnsDefinitionsFromTable(); 23 | 24 | $skip_columns = config('schema-rules.skip_columns', []); 25 | 26 | $tableRules = []; 27 | foreach ($tableColumns as $column) { 28 | $field = $this->getField($column); 29 | 30 | // If specific columns where supplied only process those... 31 | if (! empty($this->columns()) && ! in_array($field, $this->columns())) { 32 | continue; 33 | } 34 | 35 | // If column should be skipped 36 | if (in_array($field, $skip_columns)) { 37 | continue; 38 | } 39 | 40 | // We do not need a rule for auto increments 41 | if ($this->isAutoIncrement($column)) { 42 | continue; 43 | } 44 | 45 | $tableRules[$field] = $this->generateColumnRules($column); 46 | } 47 | 48 | return $tableRules; 49 | } 50 | 51 | protected function table() 52 | { 53 | return $this->table; 54 | } 55 | 56 | protected function columns() 57 | { 58 | return $this->columns; 59 | } 60 | 61 | abstract protected function isAutoIncrement($column): bool; 62 | 63 | abstract protected function getField($column): string; 64 | 65 | abstract protected function getColumnsDefinitionsFromTable(); 66 | 67 | abstract protected function generateColumnRules(stdClass $column): array; 68 | } 69 | -------------------------------------------------------------------------------- /src/LaravelSchemaRulesServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-schema-rules') 26 | ->hasConfigFile() 27 | ->hasCommand(GenerateRulesCommand::class); 28 | } 29 | 30 | /** 31 | * @throws InvalidPackage 32 | */ 33 | public function register() 34 | { 35 | parent::register(); 36 | 37 | $this->app->bind(SchemaRulesResolverInterface::class, function ($app, $parameters) { 38 | $connection = config('database.default'); 39 | $driver = config("database.connections.{$connection}.driver"); 40 | 41 | switch ($driver) { 42 | case 'sqlite': $class = SchemaRulesResolverSqlite::class; 43 | 44 | break; 45 | case 'mysql': $class = SchemaRulesResolverMySql::class; 46 | 47 | break; 48 | case 'pgsql': $class = SchemaRulesResolverPgSql::class; 49 | 50 | break; 51 | default: throw new UnsupportedDbDriverException('This db driver is not supported: '.$driver); 52 | } 53 | 54 | return new $class(...array_values($parameters)); 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-schema-rules` will be documented in this file. 4 | 5 | ## v1.5.0 - 2025-02-23 6 | 7 | ### What's Changed 8 | 9 | * Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/laracraft-tech/laravel-schema-rules/pull/29 10 | * Bump aglipanci/laravel-pint-action from 2.4 to 2.5 by @dependabot in https://github.com/laracraft-tech/laravel-schema-rules/pull/30 11 | * Laravel 12.x Compatibility by @laravel-shift in https://github.com/laracraft-tech/laravel-schema-rules/pull/31 12 | 13 | ### New Contributors 14 | 15 | * @laravel-shift made their first contribution in https://github.com/laracraft-tech/laravel-schema-rules/pull/31 16 | 17 | **Full Changelog**: https://github.com/laracraft-tech/laravel-schema-rules/compare/v1.4.1...v1.5.0 18 | 19 | ## v1.4.1 - 2024-08-13 20 | 21 | ### What's changed 22 | 23 | * fixed default `schema-rules.skip_columns` by @dreammonkey in #28 24 | 25 | ## v1.4.0 - 2024-06-24 26 | 27 | ### What's changed 28 | 29 | * Laravel 11 support by @dreammonkey 30 | 31 | ## v1.3.6 - 2023-12-06 32 | 33 | ### What's changed 34 | 35 | * fixed Laravel 10.35 dependency issue 36 | 37 | ## v1.3.5 - 2023-11-29 38 | 39 | ### What's changed 40 | 41 | * fixed pgsql column order 42 | 43 | ## v1.3.4 - 2023-10-25 44 | 45 | ### What's changed 46 | 47 | - fixed min length for sqlite driver 48 | 49 | ## v1.3.3 - 2023-10-19 50 | 51 | ### What's changed 52 | 53 | - output generated rules info text only in console mode 54 | 55 | ## v1.3.2 - 2023-08-21 56 | 57 | ### What's Changed 58 | 59 | - Added support for jsonb on PostgreSQL by @mathieutu 60 | 61 | ## v1.3.1 - 2023-07-20 62 | 63 | ### What's Changed 64 | 65 | - Fixed bug on `mysql` 5.8 by @giagara 66 | 67 | ## v1.3.0 - 2023-07-19 68 | 69 | ### What's Changed 70 | 71 | - Added `skip_columns` in config (default skip `deleted_at`, `updated_at` and `created_at`) by @giagara 72 | - Some refactoring by @giagara 73 | 74 | ### New Contributors 75 | 76 | - @giagara made their first contribution 77 | 78 | ## v1.2.0 - 2023-06-21 79 | 80 | ### What's Changed 81 | 82 | - Added `--create-request` flag to create **Form Request Classes** 83 | 84 | ## v1.1.0 - 2023-06-19 85 | 86 | ### What's Changed 87 | 88 | - Support for foreigen key validation rules 89 | 90 | ## v1.0.0 - 2023-06-19 91 | 92 | ### Version 1 93 | 94 | Automatically generate Laravel validation rules based on your database table schema! 95 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laracraft-tech/laravel-schema-rules", 3 | "description": "Automatically generate Laravel validation rules based on your database table schema!", 4 | "keywords": [ 5 | "laracraft-tech", 6 | "laravel", 7 | "laravel-schema-rules" 8 | ], 9 | "homepage": "https://github.com/laracraft-tech/laravel-schema-rules", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Zacharias Creutznacher", 14 | "email": "zacharias@laracraft.tech", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.4 || ^8.0 || ^8.1", 20 | "brick/varexporter": "^0.3.8 || ^0.5.0", 21 | "doctrine/dbal": "^3.6 || ^4.0.2", 22 | "illuminate/contracts": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 23 | "illuminate/database": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 24 | "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 25 | "illuminate/testing": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 26 | "spatie/laravel-package-tools": "^1.12 || ^1.14" 27 | }, 28 | "require-dev": { 29 | "friendsofphp/php-cs-fixer": "^3.13", 30 | "nunomaduro/larastan": "^1.0 || ^2.5 || ^3.1", 31 | "orchestra/testbench": "^6.27 || ^7.0 || ^8.0 || ^9.0 || ^10.0", 32 | "pestphp/pest": "^1.22 || ^2.0 || ^3.7", 33 | "pestphp/pest-plugin-laravel": "^1.22 || ^2.0 || ^3.1", 34 | "spatie/laravel-ray": "^1.32" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "LaracraftTech\\LaravelSchemaRules\\": "src" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "LaracraftTech\\LaravelSchemaRules\\Tests\\": "tests" 44 | } 45 | }, 46 | "scripts": { 47 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 48 | "test": "vendor/bin/pest", 49 | "test-coverage": "vendor/bin/pest --coverage", 50 | "format": "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes" 51 | }, 52 | "config": { 53 | "sort-packages": true, 54 | "allow-plugins": { 55 | "pestphp/pest-plugin": true 56 | } 57 | }, 58 | "extra": { 59 | "laravel": { 60 | "providers": [ 61 | "LaracraftTech\\LaravelSchemaRules\\LaravelSchemaRulesServiceProvider" 62 | ] 63 | } 64 | }, 65 | "minimum-stability": "dev", 66 | "prefer-stable": true 67 | } 68 | -------------------------------------------------------------------------------- /src/Resolvers/SchemaRulesResolverSqlite.php: -------------------------------------------------------------------------------- 1 | table()}')"))->keyBy('name')->toArray(); 16 | 17 | $foreignKeys = DB::select("PRAGMA foreign_key_list({$this->table()})"); 18 | 19 | foreach ($foreignKeys as $foreignKey) { 20 | $tableColumns[$foreignKey->from]->Foreign = [ 21 | 'table' => $foreignKey->table, 22 | 'id' => $foreignKey->to, 23 | ]; 24 | } 25 | 26 | return $tableColumns; 27 | } 28 | 29 | protected function generateColumnRules(stdClass $column): array 30 | { 31 | $columnRules = []; 32 | $columnRules[] = $column->notnull ? 'required' : 'nullable'; 33 | 34 | if (! empty($column->Foreign)) { 35 | $columnRules[] = 'exists:'.implode(',', $column->Foreign); 36 | 37 | return $columnRules; 38 | } 39 | 40 | $type = Str::of($column->type); 41 | switch (true) { 42 | case $type == 'tinyint(1)' && config('schema-rules.tinyint1_to_bool'): 43 | $columnRules[] = 'boolean'; 44 | 45 | break; 46 | case $type == 'varchar' || $type == 'text': 47 | $columnRules[] = 'string'; 48 | $columnRules[] = 'min:'.config('schema-rules.string_min_length'); 49 | 50 | break; 51 | case $type == 'integer': 52 | $columnRules[] = 'integer'; 53 | $columnRules[] = 'min:-9223372036854775808'; 54 | $columnRules[] = 'max:9223372036854775807'; 55 | 56 | break; 57 | case $type->contains('numeric') || $type->contains('float'): 58 | // should we do more specific here? 59 | // some kind of regex validation for double, double unsigned, double(8, 2), decimal etc...? 60 | $columnRules[] = 'numeric'; 61 | 62 | break; 63 | case $type == 'date' || $type == 'time' || $type == 'datetime': 64 | $columnRules[] = 'date'; 65 | 66 | break; 67 | default: 68 | // I think we skip BINARY and BLOB for now 69 | break; 70 | } 71 | 72 | return $columnRules; 73 | } 74 | 75 | protected function isAutoIncrement($column): bool 76 | { 77 | return $column->pk; 78 | } 79 | 80 | protected function getField($column): string 81 | { 82 | return $column->name; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.php-cs-fixer.cache: -------------------------------------------------------------------------------- 1 | {"php":"8.2.9","version":"3.25.0","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":true,"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_typehint":true,"curly_braces_position":{"allow_single_line_empty_anonymous_classes":true},"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_braces":true,"no_blank_lines_after_class_opening":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"unary_operator_spaces":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"class_attributes_separation":{"elements":{"method":"one"}}},"hashes":{"src\/Commands\/GenerateRulesCommand.php":"efe430d939ae29e942fd34608d465458","src\/LaravelSchemaRulesServiceProvider.php":"7e21700c24396b1b3ae4093f5a611a34","src\/Contracts\/SchemaRulesResolverInterface.php":"0661d95c8ab20c86180132b20002e8b9","src\/Resolvers\/SchemaRulesResolverPgSql.php":"395df9998f37e8ea4feeebc2f528fbef","src\/Resolvers\/BaseSchemaRulesResolver.php":"85f5dcca23e897416d93b4830437a445","src\/Resolvers\/SchemaRulesResolverSqlite.php":"f6d30cd50082722c8991f8afeaf6bbbd","src\/Resolvers\/SchemaRulesResolverMySql.php":"c04a173bcfc7796a6e1021c297c50912","src\/Exceptions\/TableDoesNotExistException.php":"d19bb30aab5ede9920cc925f9d4b3cde","src\/Exceptions\/ColumnDoesNotExistException.php":"e1b74bfac1771e4ae77962805adb3173","src\/Exceptions\/MultipleTablesSuppliedException.php":"44534013008741de6a024fd8c0fc62e2","src\/Exceptions\/UnsupportedDbDriverException.php":"55e1f4f48764869ea78e5444357e7a3a","src\/Exceptions\/FailedToCreateRequestClassException.php":"88d97c20dfda22508445696a600edd8f","tests\/Pest.php":"71da07894ee59b1ebc0e98d205618731","tests\/SchemaRulesTest.php":"2279a35708710e8a0a78b4edfc8f0095","tests\/TestCase.php":"17d5b78ef193fff416305ee717483177"}} -------------------------------------------------------------------------------- /src/Resolvers/SchemaRulesResolverPgSql.php: -------------------------------------------------------------------------------- 1 | ['-32768', '32767'], 14 | 'integer' => ['-2147483648', '2147483647'], 15 | 'bigint' => ['-9223372036854775808', '9223372036854775807'], 16 | ]; 17 | 18 | protected function getColumnsDefinitionsFromTable() 19 | { 20 | $databaseName = config('database.connections.mysql.database'); 21 | $tableName = $this->table(); 22 | 23 | $tableColumns = collect(DB::select( 24 | ' 25 | SELECT column_name, data_type, character_maximum_length, is_nullable, column_default 26 | FROM INFORMATION_SCHEMA.COLUMNS 27 | WHERE table_name = :table ORDER BY ordinal_position', 28 | ['table' => $tableName] 29 | ))->keyBy('column_name')->toArray(); 30 | 31 | $foreignKeys = DB::select(" 32 | SELECT 33 | kcu.column_name, 34 | ccu.table_name AS foreign_table_name, 35 | ccu.column_name AS foreign_column_name 36 | FROM 37 | information_schema.table_constraints AS tc 38 | JOIN information_schema.key_column_usage AS kcu 39 | ON tc.constraint_name = kcu.constraint_name 40 | AND tc.table_schema = kcu.table_schema 41 | JOIN information_schema.constraint_column_usage AS ccu 42 | ON ccu.constraint_name = tc.constraint_name 43 | AND ccu.table_schema = tc.table_schema 44 | WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name=? AND tc.table_catalog=? 45 | ", [$tableName, $databaseName]); 46 | 47 | foreach ($foreignKeys as $foreignKey) { 48 | $tableColumns[$foreignKey->column_name]->Foreign = [ 49 | 'table' => $foreignKey->foreign_table_name, 50 | 'id' => $foreignKey->foreign_column_name, 51 | ]; 52 | } 53 | 54 | return $tableColumns; 55 | } 56 | 57 | protected function generateColumnRules(stdClass $column): array 58 | { 59 | $columnRules = []; 60 | $columnRules[] = $column->is_nullable === 'YES' ? 'nullable' : 'required'; 61 | 62 | if (! empty($column->Foreign)) { 63 | $columnRules[] = 'exists:'.implode(',', $column->Foreign); 64 | 65 | return $columnRules; 66 | } 67 | 68 | $type = Str::of($column->data_type); 69 | switch (true) { 70 | case $type == 'boolean': 71 | $columnRules[] = 'boolean'; 72 | 73 | break; 74 | case $type->contains('char'): 75 | $columnRules[] = 'string'; 76 | $columnRules[] = 'min:'.config('schema-rules.string_min_length'); 77 | $columnRules[] = 'max:'.$column->character_maximum_length; 78 | 79 | break; 80 | case $type == 'text': 81 | $columnRules[] = 'string'; 82 | $columnRules[] = 'min:'.config('schema-rules.string_min_length'); 83 | 84 | break; 85 | case $type->contains('int'): 86 | $columnRules[] = 'integer'; 87 | $columnRules[] = 'min:'.self::$integerTypes[$type->__toString()][0]; 88 | $columnRules[] = 'max:'.self::$integerTypes[$type->__toString()][1]; 89 | 90 | break; 91 | case $type->contains('double') || 92 | $type->contains('decimal') || 93 | $type->contains('numeric') || 94 | $type->contains('real'): 95 | // should we do more specific here? 96 | // some kind of regex validation for double, double unsigned, double(8, 2), decimal etc...? 97 | $columnRules[] = 'numeric'; 98 | 99 | break; 100 | // unfortunately, it's not so easy in pgsql to find out if a column is an enum 101 | // case $type->contains('enum') || $type->contains('set'): 102 | // preg_match_all("/'([^']*)'/", $type, $matches); 103 | // $columnRules[] = "in:".implode(',', $matches[1]); 104 | // 105 | // break; 106 | case $type == 'date' || $type->contains('time '): 107 | $columnRules[] = 'date'; 108 | 109 | break; 110 | case $type->contains('json'): 111 | $columnRules[] = 'json'; 112 | 113 | break; 114 | default: 115 | // I think we skip BINARY and BLOB for now 116 | break; 117 | } 118 | 119 | return $columnRules; 120 | } 121 | 122 | protected function isAutoIncrement($column): bool 123 | { 124 | return Str::contains($column->column_default, 'nextval'); 125 | } 126 | 127 | protected function getField($column): string 128 | { 129 | return $column->column_name; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Resolvers/SchemaRulesResolverMySql.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'unsigned' => ['0', '255'], 15 | 'signed' => ['-128', '127'], 16 | ], 17 | 'smallint' => [ 18 | 'unsigned' => ['0', '65535'], 19 | 'signed' => ['-32768', '32767'], 20 | ], 21 | 'mediumint' => [ 22 | 'unsigned' => ['0', '16777215'], 23 | 'signed' => ['-8388608', '8388607'], 24 | ], 25 | 'int' => [ 26 | 'unsigned' => ['0', '4294967295'], 27 | 'signed' => ['-2147483648', '2147483647'], 28 | ], 29 | 'bigint' => [ 30 | 'unsigned' => ['0', '18446744073709551615'], 31 | 'signed' => ['-9223372036854775808', '9223372036854775807'], 32 | ], 33 | ]; 34 | 35 | protected function getColumnsDefinitionsFromTable() 36 | { 37 | $databaseName = config('database.connections.mysql.database'); 38 | $tableName = $this->table(); 39 | 40 | $tableColumns = collect(DB::select('SHOW COLUMNS FROM '.$tableName))->keyBy('Field')->toArray(); 41 | 42 | $foreignKeys = DB::select(" 43 | SELECT k.COLUMN_NAME, k.REFERENCED_TABLE_NAME, k.REFERENCED_COLUMN_NAME 44 | FROM information_schema.TABLE_CONSTRAINTS i 45 | LEFT JOIN information_schema.KEY_COLUMN_USAGE k ON i.CONSTRAINT_NAME = k.CONSTRAINT_NAME 46 | WHERE i.CONSTRAINT_TYPE = 'FOREIGN KEY' 47 | AND i.TABLE_SCHEMA = '{$databaseName}' 48 | AND i.TABLE_NAME = '{$tableName}' 49 | "); 50 | 51 | foreach ($foreignKeys as $foreignKey) { 52 | $tableColumns[$foreignKey->COLUMN_NAME]->Foreign = [ 53 | 'table' => $foreignKey->REFERENCED_TABLE_NAME, 54 | 'id' => $foreignKey->REFERENCED_COLUMN_NAME, 55 | ]; 56 | } 57 | 58 | return $tableColumns; 59 | } 60 | 61 | protected function generateColumnRules(stdClass $column): array 62 | { 63 | $columnRules = []; 64 | $columnRules[] = $column->Null === 'YES' ? 'nullable' : 'required'; 65 | 66 | if (! empty($column->Foreign)) { 67 | $columnRules[] = 'exists:'.implode(',', $column->Foreign); 68 | 69 | return $columnRules; 70 | } 71 | 72 | $type = Str::of($column->Type); 73 | switch (true) { 74 | case $type == 'tinyint(1)' && config('schema-rules.tinyint1_to_bool'): 75 | $columnRules[] = 'boolean'; 76 | 77 | break; 78 | case $type->contains('char'): 79 | $columnRules[] = 'string'; 80 | $columnRules[] = 'min:'.config('schema-rules.string_min_length'); 81 | $columnRules[] = 'max:'.filter_var($type, FILTER_SANITIZE_NUMBER_INT); 82 | 83 | break; 84 | case $type == 'text': 85 | $columnRules[] = 'string'; 86 | $columnRules[] = 'min:'.config('schema-rules.string_min_length'); 87 | 88 | break; 89 | case $type->contains('int'): 90 | $columnRules[] = 'integer'; 91 | $sign = ($type->contains('unsigned')) ? 'unsigned' : 'signed'; 92 | $intType = $type->before(' unsigned')->__toString(); 93 | 94 | // prevent int(xx) for mysql 95 | $intType = preg_replace("/\([^)]+\)/", '', $intType); 96 | 97 | if (! array_key_exists($intType, self::$integerTypes)) { 98 | $intType = 'int'; 99 | } 100 | 101 | $columnRules[] = 'min:'.self::$integerTypes[$intType][$sign][0]; 102 | $columnRules[] = 'max:'.self::$integerTypes[$intType][$sign][1]; 103 | 104 | break; 105 | case $type->contains('double') || 106 | $type->contains('decimal') || 107 | $type->contains('dec') || 108 | $type->contains('float'): 109 | // should we do more specific here? 110 | // some kind of regex validation for double, double unsigned, double(8, 2), decimal etc...? 111 | $columnRules[] = 'numeric'; 112 | 113 | break; 114 | case $type->contains('enum') || $type->contains('set'): 115 | preg_match_all("/'([^']*)'/", $type, $matches); 116 | $columnRules[] = 'string'; 117 | $columnRules[] = 'in:'.implode(',', $matches[1]); 118 | 119 | break; 120 | case $type->contains('year'): 121 | $columnRules[] = 'integer'; 122 | $columnRules[] = 'min:1901'; 123 | $columnRules[] = 'max:2155'; 124 | 125 | break; 126 | case $type == 'date' || $type == 'time': 127 | $columnRules[] = 'date'; 128 | 129 | break; 130 | case $type == 'timestamp': 131 | // handle mysql "year 2038 problem" 132 | $columnRules[] = 'date'; 133 | $columnRules[] = 'after_or_equal:1970-01-01 00:00:01'; 134 | $columnRules[] = 'before_or_equal:2038-01-19 03:14:07'; 135 | 136 | break; 137 | case $type == 'json': 138 | $columnRules[] = 'json'; 139 | 140 | break; 141 | 142 | default: 143 | // I think we skip BINARY and BLOB for now 144 | break; 145 | } 146 | 147 | return $columnRules; 148 | } 149 | 150 | protected function isAutoIncrement($column): bool 151 | { 152 | return $column->Extra === 'auto_increment'; 153 | } 154 | 155 | protected function getField($column): string 156 | { 157 | return $column->Field; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Commands/GenerateRulesCommand.php: -------------------------------------------------------------------------------- 1 | argument('table'); 40 | 41 | // Options 42 | $columns = (array) array_filter(explode(',', $this->option('columns'))); 43 | $create = (bool) $this->option('create-request'); 44 | $force = (bool) $this->option('force'); 45 | $file = (string) $this->option('file'); 46 | 47 | $this->checkTableAndColumns($table, $columns); 48 | 49 | $rules = app()->make(SchemaRulesResolverInterface::class, [ 50 | 'table' => $table, 51 | 'columns' => $columns, 52 | ])->generate(); 53 | 54 | if ($create) { 55 | $this->createRequest($table, $rules, $force, $file); 56 | } else { 57 | $this->createOutput($table, $rules); 58 | } 59 | 60 | return Command::SUCCESS; 61 | } 62 | 63 | private function format($rules): string 64 | { 65 | return VarExporter::export($rules, VarExporter::INLINE_SCALAR_LIST); 66 | } 67 | 68 | /** 69 | * @throws MultipleTablesSuppliedException 70 | * @throws ColumnDoesNotExistException 71 | * @throws TableDoesNotExistException 72 | */ 73 | private function checkTableAndColumns(string $table, array $columns = []): void 74 | { 75 | if (count($tables = array_filter(explode(',', $table))) > 1) { 76 | $msg = 'The command can only handle one table at a time - you gave: '.implode(', ', $tables); 77 | 78 | throw new MultipleTablesSuppliedException($msg); 79 | } 80 | 81 | if (! Schema::hasTable($table)) { 82 | throw new TableDoesNotExistException("Table '$table' not found!"); 83 | } 84 | 85 | if (empty($columns)) { 86 | return; 87 | } 88 | 89 | $missingColumns = []; 90 | foreach ($columns as $column) { 91 | if (! Schema::hasColumn($table, $column)) { 92 | $missingColumns[] = $column; 93 | } 94 | } 95 | 96 | if (! empty($missingColumns)) { 97 | $msg = "The following columns do not exists on the table '$table': ".implode(', ', $missingColumns); 98 | 99 | throw new ColumnDoesNotExistException($msg); 100 | } 101 | } 102 | 103 | private function createOutput(string $table, array $rules): void 104 | { 105 | if (app()->runningInConsole()) { 106 | $this->info("Schema-based validation rules for table \"$table\" have been generated!"); 107 | $this->info('Copy & paste these to your controller validation or form request or where ever your validation takes place:'); 108 | } 109 | 110 | $this->line($this->format($rules)); 111 | } 112 | 113 | /** 114 | * @throws FailedToCreateRequestClassException 115 | */ 116 | private function createRequest(string $table, array $rules, bool $force = false, string $file = '') 117 | { 118 | // As a default, we create a store request based on the table name. 119 | if (empty($file)) { 120 | $file = 'Store'.Str::of($table)->singular()->ucfirst()->__toString().'Request'; 121 | } 122 | 123 | Artisan::call('make:request', [ 124 | 'name' => $file, 125 | '--force' => $force, 126 | ]); 127 | 128 | $output = trim(Artisan::output()); 129 | 130 | preg_match('/\[(.*?)\]/', $output, $matches); 131 | 132 | // The original $file we passed to the command may have changed on creation validation inside the command. 133 | // We take the actual path which was used to create the file! 134 | $actuaFile = $matches[1] ?? null; 135 | 136 | if ($actuaFile) { 137 | try { 138 | $fileContent = File::get($actuaFile); 139 | // Add spaces to indent the array in the request class file. 140 | $rulesFormatted = str_replace("\n", "\n ", $this->format($rules)); 141 | $pattern = '/(public function rules\(\): array\n\s*{\n\s*return )\[.*\](;)/s'; 142 | $replaceContent = preg_replace($pattern, '$1'.$rulesFormatted.'$2', $fileContent); 143 | File::put($actuaFile, $replaceContent); 144 | } catch (Exception $exception) { 145 | throw new FailedToCreateRequestClassException($exception->getMessage()); 146 | } 147 | } 148 | 149 | if (Str::startsWith($output, 'INFO')) { 150 | $this->info($output); 151 | } else { 152 | $this->error($output); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Schema Rules 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/laracraft-tech/laravel-schema-rules.svg?style=flat-square)](https://packagist.org/packages/laracraft-tech/laravel-schema-rules) 4 | [![Tests](https://github.com/laracraft-tech/laravel-schema-rules/actions/workflows/run-tests.yml/badge.svg?branch=main)](https://github.com/laracraft-tech/laravel-schema-rules/actions/workflows/run-tests.yml) 5 | [![Check & fix styling](https://github.com/laracraft-tech/laravel-schema-rules/actions/workflows/fix-php-code-style-issues.yml/badge.svg?branch=main)](https://github.com/laracraft-tech/laravel-schema-rules/actions/workflows/fix-php-code-style-issues.yml) 6 | [![License](https://img.shields.io/packagist/l/laracraft-tech/laravel-schema-rules.svg?style=flat-square)](https://packagist.org/packages/laracraft-tech/laravel-schema-rules) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/laracraft-tech/laravel-schema-rules.svg?style=flat-square)](https://packagist.org/packages/laracraft-tech/laravel-schema-rules) 8 | 9 | Automatically generate basic Laravel validation rules based on your database table schema! 10 | Use these as a starting point to fine-tune and optimize your validation rules as needed. 11 | 12 | Here you can use the web version, if you like: [https://validationforlaravel.com](https://validationforlaravel.com) 13 | 14 | ## Installation 15 | 16 | You can install the package via composer: 17 | 18 | ```bash 19 | composer require laracraft-tech/laravel-schema-rules --dev 20 | ``` 21 | 22 | Then publish the config file with: 23 | 24 | ```bash 25 | php artisan vendor:publish --tag="schema-rules-config" 26 | ``` 27 | 28 | ## ToC 29 | 30 | - [Laravel Schema Rules](#laravel-schema-rules) 31 | - [Installation](#installation) 32 | - [ToC](#toc) 33 | - [Usage](#usage) 34 | - [Generate rules for a whole table](#generate-rules-for-a-whole-table) 35 | - [Generate rules for specific columns](#generate-rules-for-specific-columns) 36 | - [Generate Form Request Class](#generate-form-request-class) 37 | - [Always skip columns](#always-skip-columns) 38 | - [Supported Drivers](#supported-drivers) 39 | - [Testing](#testing) 40 | - [Changelog](#changelog) 41 | - [Contributing](#contributing) 42 | - [Security Vulnerabilities](#security-vulnerabilities) 43 | - [Credits](#credits) 44 | - [License](#license) 45 | 46 | ## Usage 47 | 48 | Let's say you've migrated this fictional table: 49 | 50 | ```php 51 | Schema::create('persons', function (Blueprint $table) { 52 | $table->id(); 53 | $table->string('first_name', 100); 54 | $table->string('last_name', 100); 55 | $table->string('email'); 56 | $table->foreignId('address_id')->constrained(); 57 | $table->text('bio')->nullable(); 58 | $table->enum('gender', ['m', 'f', 'd']); 59 | $table->date('birth'); 60 | $table->year('graduated'); 61 | $table->float('body_size'); 62 | $table->unsignedTinyInteger('children_count')->nullable(); 63 | $table->integer('account_balance'); 64 | $table->unsignedInteger('net_income'); 65 | $table->boolean('send_newsletter')->nullable(); 66 | }); 67 | ``` 68 | 69 | ### Generate rules for a whole table 70 | 71 | Now if you run: 72 | 73 | `php artisan schema:generate-rules persons` 74 | 75 | You'll get: 76 | 77 | ``` 78 | Schema-based validation rules for table "persons" have been generated! 79 | Copy & paste these to your controller validation or form request or where ever your validation takes place: 80 | [ 81 | 'first_name' => ['required', 'string', 'min:1', 'max:100'], 82 | 'last_name' => ['required', 'string', 'min:1', 'max:100'], 83 | 'email' => ['required', 'string', 'min:1', 'max:255'], 84 | 'address_id' => ['required', 'exists:addresses,id'], 85 | 'bio' => ['nullable', 'string', 'min:1'], 86 | 'gender' => ['required', 'string', 'in:m,f,d'], 87 | 'birth' => ['required', 'date'], 88 | 'graduated' => ['required', 'integer', 'min:1901', 'max:2155'], 89 | 'body_size' => ['required', 'numeric'], 90 | 'children_count' => ['nullable', 'integer', 'min:0', 'max:255'], 91 | 'account_balance' => ['required', 'integer', 'min:-2147483648', 'max:2147483647'], 92 | 'net_income' => ['required', 'integer', 'min:0', 'max:4294967295'], 93 | 'send_newsletter' => ['nullable', 'boolean'] 94 | ] 95 | ``` 96 | 97 | As you may have noticed the float-column `body_size`, just gets generated to `['required', 'numeric']`. 98 | Proper rules for `float`, `decimal` and `double`, are not yet implemented! 99 | 100 | ### Generate rules for specific columns 101 | 102 | You can also explicitly specify the columns: 103 | 104 | `php artisan schema:generate-rules persons --columns first_name,last_name,email` 105 | 106 | Which gives you: 107 | 108 | ``` 109 | Schema-based validation rules for table "persons" have been generated! 110 | Copy & paste these to your controller validation or form request or where ever your validation takes place: 111 | [ 112 | 'first_name' => ['required', 'string', 'min:1', 'max:100'], 113 | 'last_name' => ['required', 'string', 'min:1', 'max:100'], 114 | 'email' => ['required', 'string', 'min:1', 'max:255'] 115 | ] 116 | ``` 117 | 118 | ### Generate Form Request Class 119 | 120 | Optionally, you can add a `--create-request` or `-c` flag, 121 | which will create a form request class with the generated rules for you! 122 | 123 | ```bash 124 | # creates app/Http/Requests/StorePersonRequest.php (store request is the default) 125 | php artisan schema:generate-rules persons --create-request 126 | 127 | # creates/overwrites app/Http/Requests/StorePersonRequest.php 128 | php artisan schema:generate-rules persons --create-request --force 129 | 130 | # creates app/Http/Requests/UpdatePersonRequest.php 131 | php artisan schema:generate-rules persons --create-request --file UpdatePersonRequest 132 | 133 | # creates app/Http/Requests/Api/V1/StorePersonRequest.php 134 | php artisan schema:generate-rules persons --create-request --file Api\\V1\\StorePersonRequest 135 | 136 | # creates/overwrites app/Http/Requests/Api/V1/StorePersonRequest.php (using shortcuts) 137 | php artisan schema:generate-rules persons -cf --file Api\\V1\\StorePersonRequest 138 | ``` 139 | 140 | ### Always skip columns 141 | 142 | To always skip columns add it in the config file, under `skip_columns` parameter. 143 | 144 | ```php 145 | 'skip_columns' => ['whatever', 'some_other_column'], 146 | ``` 147 | 148 | ## Supported Drivers 149 | 150 | Currently, the supported database drivers are `MySQL`, `PostgreSQL`, and `SQLite`. 151 | 152 | Please note, since each driver supports different data types and range specifications, 153 | the validation rules generated by this package may vary depending on the database driver you are using. 154 | 155 | ## Testing 156 | 157 | Before running tests, you need to set up a local MySQL database named `laravel_schema_rules` and update its _username_ and _password_ in the `phpunit.xml.dist` file. 158 | 159 | ```bash 160 | composer test 161 | ``` 162 | 163 | ## Changelog 164 | 165 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 166 | 167 | ## Contributing 168 | 169 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 170 | 171 | ## Security Vulnerabilities 172 | 173 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 174 | 175 | ## Credits 176 | 177 | - [Zacharias Creutznacher](https://github.com/laracraft-tech) 178 | - [All Contributors](../../contributors) 179 | 180 | ## License 181 | 182 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 183 | --------------------------------------------------------------------------------