├── .gitignore ├── tests ├── TestEnum.php ├── Item.php ├── Wrapper.php ├── TestEnumConverter.php ├── Status.php ├── UnionTypeBean.php └── BeanTest.php ├── src ├── Attributes │ ├── Autowired.php │ ├── Logic.php │ ├── Component.php │ ├── Service.php │ ├── Repository.php │ ├── EventDriven │ │ ├── Event.php │ │ ├── Subscriber.php │ │ └── Listener.php │ ├── Requests │ │ └── RequestBody.php │ ├── Routes │ │ ├── Response.php │ │ ├── PutMapping.php │ │ ├── Prefix.php │ │ ├── PostMapping.php │ │ ├── GetMapping.php │ │ └── DeleteMapping.php │ ├── BeanList.php │ ├── Value.php │ ├── TypeConverter.php │ ├── UnionType.php │ ├── Validators │ │ ├── ParameterValidator.php │ │ └── Param.php │ ├── Alias.php │ └── Mock.php ├── Exceptions │ ├── ValidatedErrorException.php │ ├── NotSuchElementException.php │ └── PropertyNotFoundException.php ├── MockType.php ├── Base │ ├── ICurlRepository.php │ └── CurlRepository.php ├── Helpers │ ├── NamespaceHelper.php │ ├── TypeScriptExampleGenerator.php │ ├── ControllerHelper.php │ ├── RouteHelper.php │ ├── VitePressConfigHelper.php │ ├── CollectHelper.php │ └── MarkdownHelper.php ├── Commands │ ├── DocsRun.php │ ├── TableFieldEnumGenerator.php │ └── DocsBuild.php ├── Traits │ └── Injectable.php ├── EasyRouter.php ├── Optional.php ├── Middlewares │ ├── RequestBodyInjector.php │ ├── RequestParamsDefaultValueInjector.php │ └── ParameterValidation.php ├── Providers │ └── ComponentScanProvider.php └── Bean.php ├── phpunit.xml ├── LICENSE ├── composer.json ├── README_CN.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | vendor -------------------------------------------------------------------------------- /tests/TestEnum.php: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Attributes/EventDriven/Event.php: -------------------------------------------------------------------------------- 1 | TextSettings::class, 28 | 'number' => NumberSettings::class 29 | ])] 30 | protected TextSettings | NumberSettings $settings; 31 | } 32 | -------------------------------------------------------------------------------- /src/Attributes/Routes/PostMapping.php: -------------------------------------------------------------------------------- 1 | ! empty($path))); 20 | $indexOfApp = -1; 21 | foreach ($explodedPath as $index => $part) { 22 | if ($part === 'app') { 23 | $indexOfApp = $index; 24 | } 25 | } 26 | $namespaceArray = array_slice($explodedPath, $indexOfApp); 27 | $namespace = implode('\\', $namespaceArray); 28 | return ucfirst(str_replace('.php', '', $namespace)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Zenith Team 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 | -------------------------------------------------------------------------------- /src/Commands/DocsRun.php: -------------------------------------------------------------------------------- 1 | setTimeout(null); 33 | $this->info('Docs development server is start on http://localhost:5173'); 34 | try { 35 | $process->mustRun(); 36 | } catch (ProcessFailedException $exception) { 37 | $this->error('Docs development server failed to start!'); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenithsu/laravel-plus", 3 | "description": "Laravel-Plus is an efficient Laravel extension package designed to enhance development productivity. It integrates powerful annotation handling and practical helper classes, making the development of Laravel applications more convenient and flexible.", 4 | "type": "library", 5 | "require": { 6 | "php": "^8.3", 7 | "laravel/framework": "^11.0" 8 | }, 9 | "license": "MIT", 10 | "autoload": { 11 | "psr-4": { 12 | "Zenith\\LaravelPlus\\": "src/" 13 | } 14 | }, 15 | "authors": [ 16 | { 17 | "name": "ZenithSu", 18 | "email": "happy@hacking.icu" 19 | } 20 | ], 21 | "extra": { 22 | "laravel": { 23 | "providers": [ 24 | "Zenith\\LaravelPlus\\Providers\\ComponentScanProvider" 25 | ] 26 | } 27 | }, 28 | "minimum-stability": "dev", 29 | "require-dev": { 30 | "pestphp/pest": "3.x-dev" 31 | }, 32 | "config": { 33 | "allow-plugins": { 34 | "pestphp/pest-plugin": true 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Traits/Injectable.php: -------------------------------------------------------------------------------- 1 | getProperties() as $property) { 20 | $autowired = $property->getAttributes(Autowired::class); 21 | if ($autowired) { 22 | $property->setValue($this, app()->make($property->getType()->getName())); 23 | } 24 | $values = $property->getAttributes(Value::class); 25 | if (isset($values[0])) { 26 | $instance = $values[0]->newInstance(); 27 | $pattern = $instance->pattern; 28 | $defaultValue = $instance->defaultValue; 29 | $property->setValue($this, Config::get($pattern, $defaultValue)); 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/EasyRouter.php: -------------------------------------------------------------------------------- 1 | path('Http/Controllers'); 19 | $controllerFiles = ControllerHelper::scanForFiles($controllerPath); 20 | 21 | // Scan attributes in controllers and register routes. 22 | foreach ($controllerFiles as $file) { 23 | try { 24 | $controller = ControllerHelper::convertPathToNamespace($file); 25 | $reflectionClass = new ReflectionClass($controller); 26 | } catch (ReflectionException $e) { 27 | dd($e->getMessage()); 28 | } 29 | 30 | $controllerPrefixAttributes = collect($reflectionClass->getAttributes(Prefix::class)); 31 | $prefix = $controllerPrefixAttributes->isEmpty() ? '' : RouteHelper::handleControllerPrefix($controllerPrefixAttributes->first()); 32 | 33 | $methods = $reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC); 34 | RouteHelper::handleMethodsAttributes($methods, $prefix, $controller); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Optional.php: -------------------------------------------------------------------------------- 1 | value = $value; 26 | } 27 | 28 | 29 | /** 30 | * @return T 31 | * @throws NotSuchElementException 32 | */ 33 | public function get(): mixed 34 | { 35 | if ($this->value === null) { 36 | throw new NotSuchElementException(); 37 | } 38 | 39 | return $this->value; 40 | } 41 | 42 | public static function empty(): self 43 | { 44 | return new self(null); 45 | } 46 | 47 | /** 48 | * @param T|null $value 49 | * @return self 50 | */ 51 | public static function of(mixed $value): self 52 | { 53 | if ($value === null) { 54 | throw new UnexpectedValueException(); 55 | } 56 | 57 | return new self($value); 58 | } 59 | 60 | /** 61 | * @param T|null $value 62 | * @return self 63 | */ 64 | public static function ofNullable(mixed $value): self 65 | { 66 | return new self($value); 67 | } 68 | 69 | public function ifPresent(callable $func): void 70 | { 71 | if ($this->value !== null) { 72 | $func($this->value); 73 | } 74 | } 75 | 76 | public function isPresent(): bool 77 | { 78 | return ! is_null($this->value); 79 | } 80 | 81 | public function ofElseGet(callable $func): mixed 82 | { 83 | if ($this->value === null) { 84 | return $func(); 85 | } 86 | return $this->value; 87 | } 88 | 89 | public function ofElseThrow(callable $exception): self 90 | { 91 | if ($this->value !== null) { 92 | return $this; 93 | } 94 | throw $exception(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Commands/TableFieldEnumGenerator.php: -------------------------------------------------------------------------------- 1 | getTables(); 33 | $bar = $this->output->createProgressBar(count($tables)); 34 | foreach ($tables as $table) { 35 | $columns = Schema::getColumnListing($table['name']); 36 | $info[$table['name']] = [ 37 | 'comment' => $table['comment'], 38 | 'columns' => $columns, 39 | ]; 40 | $className = Str::singular(Str::ucfirst(Str::camel($table['name']))).'Fields'; 41 | $fileName = $className.'.php'; 42 | $path = app()->path('Enums/Tables/'.$fileName); 43 | $content = 'advance(); 57 | } 58 | $bar->finish(); 59 | $this->info("\n"); 60 | $this->info('Table field enumeration generated!'); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Middlewares/RequestBodyInjector.php: -------------------------------------------------------------------------------- 1 | route(); 30 | if ($route === null) { 31 | return $next($request); 32 | } 33 | $controller = $route->getController(); 34 | $action = $route->getActionMethod(); 35 | $reflectionMethod = new ReflectionMethod($controller, $action); 36 | $parameters = $reflectionMethod->getParameters(); 37 | foreach ($parameters as $parameter) { 38 | $attributes = collect($parameter->getAttributes(RequestBody::class)); 39 | if ($attributes->isEmpty()) { 40 | continue; 41 | } 42 | $this->addBodyToRequestIfBean($request, $parameter); 43 | } 44 | 45 | return $next($request); 46 | } 47 | 48 | /** 49 | * Method to add a body to the request, if the object is an instance of the Bean class. 50 | * 51 | * @param Request $request The incoming HTTP request 52 | * @param ReflectionParameter $parameter The parameter object from the PHP Reflection class. 53 | */ 54 | private function addBodyToRequestIfBean(Request $request, ReflectionParameter $parameter): void 55 | { 56 | $params = $request->input(); 57 | $headers = $request->header(); 58 | foreach ($headers as $key => $header) { 59 | $params[$key] = $header[0]; 60 | } 61 | $bean = new ($parameter->getType()->getName())($params); 62 | if ($bean instanceof Bean) { 63 | app()->instance($parameter->getType()->getName(), $bean); 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /src/Providers/ComponentScanProvider.php: -------------------------------------------------------------------------------- 1 | scanClasses(app()->path()); 27 | foreach ($classes as $clazz) { 28 | $reflectionClazz = new ReflectionClass($clazz); 29 | $interfaces = $reflectionClazz->getInterfaceNames(); 30 | foreach ($interfaces as $interfaceClazz) { 31 | app()->singleton($interfaceClazz, fn() => $reflectionClazz->newInstance()); 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * @throws ReflectionException 38 | */ 39 | public function scanClasses(string $basePath): array 40 | { 41 | $directoryIterator = new RecursiveDirectoryIterator($basePath); 42 | $recursiveIterator = new RecursiveIteratorIterator($directoryIterator); 43 | $classes = []; 44 | foreach ($recursiveIterator as $file) { 45 | if ($file->isFile() && str_contains($file->getFilename(), '.php')) { 46 | $clazz = NamespaceHelper::path2namespace($file->getPathname()); 47 | try { 48 | $reflection = new ReflectionClass($clazz); 49 | } catch (ReflectionException $_) { 50 | continue; 51 | } 52 | $isEmpty = collect($reflection->getAttributes())->filter(fn ($attribute) => in_array($attribute->getName(), [ 53 | Component::class, Logic::class, Service::class, Repository::class 54 | ]))->isEmpty(); 55 | if ($isEmpty) { 56 | continue; 57 | } 58 | $classes[] = $clazz; 59 | } 60 | } 61 | 62 | return $classes; 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /src/Middlewares/RequestParamsDefaultValueInjector.php: -------------------------------------------------------------------------------- 1 | route()->getController(); 22 | $action = $request->route()->getActionMethod(); 23 | $reflectionMethod = new ReflectionMethod($controller, $action); 24 | $defaultValues = collect($reflectionMethod->getAttributes(Param::class)) 25 | ->flatMap(function (ReflectionAttribute $attribute) { 26 | $instance = $attribute->newInstance(); 27 | 28 | return [ 29 | $instance->key => $instance->default, 30 | ]; 31 | })->filter(fn ($value) => ! is_null($value))->all(); 32 | $this->handleDefaultValues($request, $defaultValues); 33 | 34 | return $next($request); 35 | } 36 | 37 | private function handleDefaultValues(Request $request, array $defaultValues): void 38 | { 39 | $params = $request->all(); 40 | foreach ($defaultValues as $keyPath => $defaultValue) { 41 | $keys = explode('.', $keyPath); 42 | 43 | $this->setDefault($params, $keys, $defaultValue); 44 | } 45 | foreach ($params as $key => $param) { 46 | $request->request->set($key, $param); 47 | } 48 | } 49 | 50 | private function setDefault(array &$params, array $keys, mixed $defaultValue): void 51 | { 52 | $key = array_shift($keys); 53 | if ($key === '*') { 54 | foreach ($params as &$item) { 55 | $this->setDefault($item, $keys, $defaultValue); 56 | } 57 | } else { 58 | if (count($keys) == 0) { 59 | if (! isset($params[$key])) { 60 | $params[$key] = $defaultValue; 61 | } 62 | } else { 63 | if (! isset($params[$key]) || ! is_array($params[$key])) { 64 | $params[$key] = []; 65 | } 66 | $this->setDefault($params[$key], $keys, $defaultValue); 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Helpers/TypeScriptExampleGenerator.php: -------------------------------------------------------------------------------- 1 | parseObject($data, $interfaceName); 16 | 17 | return implode("\n\n", array_merge($this->interfaces, $this->enums)); 18 | } 19 | 20 | private function parseObject($obj, string $name): string 21 | { 22 | if (! is_array($obj) && ! is_object($obj)) { 23 | return ''; 24 | } 25 | 26 | $fields = []; 27 | foreach ($obj as $key => $value) { 28 | $comment = $value['comment'] ?? ''; 29 | $type = $value['type'] ?? ''; 30 | $enumValues = $value['enums'] ?? []; 31 | 32 | switch ($type) { 33 | case 'int': 34 | $tsType = 'number'; 35 | break; 36 | case 'string': 37 | $tsType = 'string'; 38 | break; 39 | case 'enum': 40 | $tsType = $name.ucfirst($key); 41 | $this->enums[] = $this->generateEnum($tsType, $enumValues); 42 | break; 43 | case 'object_array': 44 | $nestedName = ucfirst($key); 45 | $tsType = $this->parseObject($value['value'], $nestedName).'[]'; 46 | break; 47 | default: 48 | if (is_array($value['value']) || is_object($value['value'])) { 49 | $nestedName = ucfirst($key); 50 | $tsType = $this->parseObject($value['value'], $nestedName); 51 | } else { 52 | $tsType = 'any'; 53 | } 54 | } 55 | 56 | $fields[] = " /** $comment */\n $key: $tsType;"; 57 | } 58 | 59 | $interfaceDef = "interface $name {\n".implode("\n", $fields)."\n}"; 60 | $this->interfaces[] = $interfaceDef; 61 | 62 | return $name; 63 | } 64 | 65 | private function generateEnum(string $name, array $values): string 66 | { 67 | $enumFields = []; 68 | foreach ($values as $key => $value) { 69 | $enumFields[] = " /** $value */\n $key = \"$key\""; 70 | } 71 | 72 | return "enum $name {\n".implode(",\n", $enumFields)."\n}"; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Helpers/ControllerHelper.php: -------------------------------------------------------------------------------- 1 | isDir() || in_array($file->getFilename(), self::FILENAME_FILTERS)) { 36 | continue; 37 | } 38 | if (str_contains($file->getFilename(), 'Controller.php')) { 39 | 40 | $controllerFiles[] = $file->getPathname(); 41 | } 42 | } 43 | 44 | return $controllerFiles; 45 | } 46 | 47 | /** 48 | * Given a file path, this function converts it into a namespace that can be used within the Laravel framework. 49 | * It looks for the 'Controllers' directory in the given path, replaces all directory 50 | * separator symbols with namespace separator symbols and removes the '.php' extension. 51 | * 52 | * @param string $filePath - The full path of the controller file that needs to be converted to a namespace. 53 | * @return string - A string that constitutes the namespace for the controller file. 54 | */ 55 | public static function convertPathToNamespace(string $filePath): string 56 | { 57 | $startAt = strpos($filePath, 'Controllers'); 58 | $nameSpace = substr($filePath, $startAt); 59 | $nameSpace = str_replace(DIRECTORY_SEPARATOR, '\\', $nameSpace); 60 | 61 | return 'App\\Http\\'.str_replace('.php', '', $nameSpace); 62 | } 63 | } -------------------------------------------------------------------------------- /src/Middlewares/ParameterValidation.php: -------------------------------------------------------------------------------- 1 | route()->getController(); 26 | $action = $request->route()->getActionMethod(); 27 | $reflectionMethod = new ReflectionMethod($controller, $action); 28 | $params = collect($reflectionMethod->getAttributes(Param::class)) 29 | ->map(function (ReflectionAttribute $attribute) { 30 | $instance = $attribute->newInstance(); 31 | $rules = explode('|', $attribute->newInstance()->rules); 32 | foreach ($rules as &$rule) { 33 | if (class_exists($rule)) { 34 | $rule = new $rule(); 35 | } 36 | } 37 | $isContainRequiredRule = ! collect($rules) 38 | ->filter(fn ($rule) => is_string($rule)) 39 | ->filter(fn ($rule) => str_contains($rule, 'required')) 40 | ->isEmpty(); 41 | if (! $isContainRequiredRule && $instance->required) { 42 | $rules[] = 'required'; 43 | } 44 | return [ 45 | 'key' => $instance->key, 46 | 'rule' => $rules, 47 | 'message' => $instance->message, 48 | 'required' => $instance->required, 49 | ]; 50 | }); 51 | $rules = $keys = $messages = []; 52 | foreach ($params as $param) { 53 | $keys[] = $param['key']; 54 | $rules[$param['key']] = $param['rule']; 55 | // Multiple language support. 56 | if (str_starts_with($param['message'], 'trans:')) { 57 | $key = substr($param['message'], strlen('trans:')); 58 | $messages[$param['key']] = trans($key) ?? $param['message']; 59 | } else { 60 | $messages[$param['key']] = $param['message']; 61 | } 62 | } 63 | $routeParameters = $request->route()->parameters(); 64 | $parameters = array_merge($routeParameters, $request->all()); 65 | $validator = Validator::make($parameters, $rules, $messages); 66 | if ($validator->stopOnFirstFailure()->fails()) { 67 | throw new ValidatedErrorException($validator->errors()->first()); 68 | } 69 | 70 | return $next($request); 71 | } 72 | } -------------------------------------------------------------------------------- /src/Base/CurlRepository.php: -------------------------------------------------------------------------------- 1 | model = $model; 28 | $this->primaryKey = $this->model->getKeyName(); 29 | } 30 | 31 | public function findById(int|string $id): Optional 32 | { 33 | return Optional::ofNullable($this->model->query()->where($this->primaryKey, $id)->first()); 34 | } 35 | 36 | public function existsById(int|string $id): bool 37 | { 38 | return $this->model->query()->where($this->primaryKey, $id)->exists(); 39 | } 40 | 41 | public function existsInIds(array $ids): bool 42 | { 43 | $records = $this->model->whereIn($this->primaryKey, $ids)->get([$this->primaryKey]); 44 | 45 | return count($records) === count($ids); 46 | } 47 | 48 | /** 49 | * @throws ReflectionException 50 | */ 51 | public function create(Bean $bean): int|string 52 | { 53 | $model = $this->model->create($bean->toArray()); 54 | 55 | return $model->id; 56 | } 57 | 58 | public function createWithArray(array $data): int|string 59 | { 60 | $model = $this->model->create($data); 61 | 62 | return $model->id; 63 | } 64 | 65 | public function batchCreate(array $records): void 66 | { 67 | $this->model->insert($records); 68 | } 69 | 70 | public function remove(int|string $id): void 71 | { 72 | $this->model->query()->where($this->primaryKey, $id)->delete(); 73 | } 74 | 75 | /** 76 | * @throws ReflectionException 77 | */ 78 | public function modify(int|string $id, Bean $bean, array $excludes = []): void 79 | { 80 | $data = collect($bean->toArray())->filter(fn($value, $key) => ! in_array($key, $excludes))->toArray(); 81 | $this->model->query()->where($this->primaryKey, $id)->update($data); 82 | } 83 | 84 | public function modifyWithArray(int|string $id, array $params, array $excludes = []): void 85 | { 86 | if (isset($params[$this->primaryKey])) { 87 | unset($params[$this->primaryKey]); 88 | } 89 | $data = collect($params)->filter(fn($value, $key) => ! in_array($key, $excludes))->toArray(); 90 | $this->model->query()->where($this->primaryKey, $id)->update($data); 91 | } 92 | 93 | public function findAll(): Collection 94 | { 95 | return $this->model->query()->get(); 96 | } 97 | 98 | public function findByIds(array $ids): array 99 | { 100 | return $this->model->query()->whereIn($this->primaryKey, $ids)->get()->toArray(); 101 | } 102 | 103 | public function existsByFields(array $conditions): bool 104 | { 105 | $query = $this->model->query(); 106 | foreach ($conditions as $field => $value) { 107 | $query->where($field, $value); 108 | } 109 | return $query->exists(); 110 | } 111 | } -------------------------------------------------------------------------------- /src/Helpers/RouteHelper.php: -------------------------------------------------------------------------------- 1 | newInstance())->path; 29 | 30 | return str_starts_with($prefix, '/') ? $prefix : '/'.$prefix; 31 | } 32 | 33 | /** 34 | * Iterate over provided methods and map associated routes attributes to routes. 35 | * 36 | * @param array $methods Array of methods 37 | * @param string $prefix Prefix for the route 38 | * @param string $controller Controller responsible for the route handling 39 | */ 40 | public static function handleMethodsAttributes(array $methods, string $prefix, string $controller): void 41 | { 42 | $routesMapping = [ 43 | GetMapping::class => 'get', 44 | PostMapping::class => 'post', 45 | PutMapping::class => 'put', 46 | DeleteMapping::class => 'delete', 47 | ]; 48 | foreach ($methods as $method) { 49 | $attributes = collect($method->getAttributes())->filter(function (ReflectionAttribute $attribute) use ($routesMapping) { 50 | return array_key_exists($attribute->getName(), $routesMapping); 51 | }); 52 | if ($attributes->isEmpty()) { 53 | continue; 54 | } 55 | self::mapAttributesToRoutes($attributes->first(), $prefix, $controller, $method, $routesMapping); 56 | } 57 | } 58 | 59 | 60 | /** 61 | * Maps attribute to routes by creating a new instance of attribute, formulating the uri 62 | * and mapping the corresponding route method in Laravel Route facade. 63 | * 64 | * @param ReflectionAttribute $attribute Instance of PHP's ReflectionAttribute class identifying a route attribute 65 | * @param string $prefix Prefix for the route uri 66 | * @param string $controller Name of the controller handling the route 67 | * @param ReflectionMethod $method Instance of PHP's ReflectionMethod representing a method in the controller 68 | * @param array $routesMapping An associative array having class references as keys and their corresponding routings as values 69 | */ 70 | private static function mapAttributesToRoutes(ReflectionAttribute $attribute, string $prefix, string $controller, ReflectionMethod $method, array $routesMapping): void 71 | { 72 | $instance = $attribute->newInstance(); 73 | $uri = $prefix.$instance->path; 74 | Route::{$routesMapping[$attribute->getName()]}($uri, [$controller, $method->getName()]); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/BeanTest.php: -------------------------------------------------------------------------------- 1 | 'bob']) extends Bean { 10 | protected string $username; 11 | }; 12 | expect($testBean->getUsername())->toBe('bob'); 13 | }); 14 | 15 | it('converts to JSON', function () { 16 | $testBean = new class(['username' => 'bob', 'password' => 'passW0rd']) extends Bean { 17 | protected string $username; 18 | protected string $password; 19 | }; 20 | $arr = json_decode($testBean->toJson(), true); 21 | expect($arr['username'])->toBe('bob')->and($arr['password'])->toBe('passW0rd'); 22 | }); 23 | 24 | it('initializes with BeanList', function () { 25 | $wrapper = new Wrapper([ 26 | 'items' => [ 27 | ['name' => 'bob'], 28 | ['name' => 'tom'] 29 | ] 30 | ]); 31 | $arr = $wrapper->toArray(); 32 | expect($arr['items'][0]['name'])->toBe('bob') 33 | ->and($arr['items'][1]['name'])->toBe('tom') 34 | ->and($wrapper->getItems()[0]->getName())->toBe('bob') 35 | ->and($wrapper->getItems()[1]->getName())->toBe('tom'); 36 | }); 37 | 38 | 39 | it('convert type with class', function () { 40 | $bean = new Status(['value' => 'VALID', 'page' => '1']); 41 | expect($bean->getValue())->toBe(TestEnum::VALID) 42 | ->and($bean->getPage())->toEqual(1); 43 | }); 44 | 45 | it('test setter and getter method', function () { 46 | $testBean = new class extends Bean { 47 | protected string $username; 48 | }; 49 | $testBean->setUsername('bob'); 50 | expect($testBean->getUsername())->toBe('bob'); 51 | }); 52 | 53 | it('skip property', function () { 54 | $testBean = new class extends Bean { 55 | protected array $_skip = ['skip']; 56 | protected string $skip; 57 | }; 58 | $arr = $testBean->toArray(); 59 | expect($arr)->toBeEmpty(); 60 | }); 61 | 62 | it('test non-existing property', function () { 63 | $testBean = new class extends Bean {}; 64 | $testBean->setNotExistsProperty("undefined"); 65 | })->throws(PropertyNotFoundException::class); 66 | 67 | it('test to array with snake', function () { 68 | $testBean = new class(['userId' => 1]) extends Bean { 69 | protected int $userId; 70 | }; 71 | $arr = $testBean->toArray(); 72 | expect($arr)->toHaveKey('user_id'); 73 | $arr = $testBean->toArray(false); 74 | expect($arr)->toHaveKey('userId'); 75 | }); 76 | 77 | it('test union type', function () { 78 | $testBean = new Component([ 79 | 'type' => 'text', 80 | 'settings' => ['minLength' => 3, 'maxLength' => 10], 81 | ]); 82 | expect($testBean->getSettings()->getMaxLength())->toBe(10) 83 | ->and($testBean->getSettings()->getMinLength())->toBe(3); 84 | $testBean = new Component([ 85 | 'type' => 'number', 86 | 'settings' => ['minValue' => 3, 'maxValue' => 10], 87 | ]); 88 | expect($testBean->getSettings()->getMaxValue())->toBe(10) 89 | ->and($testBean->getSettings()->getMinValue())->toBe(3); 90 | }); 91 | 92 | it('test union type to array', function () { 93 | $testBean = new Component([ 94 | 'type' => 'text', 95 | 'settings' => ['minLength' => 3, 'maxLength' => 10], 96 | ]); 97 | $arr = $testBean->toArray(); 98 | expect($arr['settings']['min_length'])->toBe(3) 99 | ->and($arr['settings']['max_length'])->toBe(10); 100 | }); -------------------------------------------------------------------------------- /src/Helpers/VitePressConfigHelper.php: -------------------------------------------------------------------------------- 1 | 'The API', 17 | 'description' => 'The api docs', 18 | 'nav' => [], 19 | 'sidebar' => [], 20 | ]; 21 | 22 | /** @var string Template for vitepress config */ 23 | private string $template; 24 | 25 | /** 26 | * VitePressConfigHelper constructor 27 | * Initializes the template string 28 | */ 29 | public function __construct() 30 | { 31 | /*Initialize the template for configuration*/ 32 | $this->template = <<<'EOT' 33 | import { defineConfig } from 'vitepress' 34 | export default defineConfig({ 35 | title: "{{title}}", 36 | description: "{{description}}", 37 | themeConfig: { 38 | nav: [ 39 | {{nav}} 40 | ], 41 | sidebar: [ 42 | {{sidebar}} 43 | ] 44 | } 45 | }) 46 | EOT; 47 | } 48 | 49 | /** 50 | * Define navigation items 51 | * 52 | * @return $this 53 | */ 54 | public function nav(string $text, string $link): self 55 | { 56 | /*Add navigation item to the configuration*/ 57 | foreach ($this->config['nav'] as $item) { 58 | if ($item['text'] === $text) { 59 | return $this; 60 | } 61 | } 62 | $this->config['nav'][] = compact('text', 'link'); 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * Define sidebar items 69 | * 70 | * @return $this 71 | */ 72 | public function sidebar(string $text): self 73 | { 74 | $items = []; 75 | $this->config['sidebar'][] = compact('text', 'items'); 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Append sidebar items 82 | * 83 | * @return $this 84 | */ 85 | public function sidebarAppendItem(string $sidebar, string $text, string $link): self 86 | { 87 | /*Append item to the specified sidebar in the configuration*/ 88 | foreach ($this->config['sidebar'] as &$bar) { 89 | if ($bar['text'] != $sidebar) { 90 | continue; 91 | } 92 | $bar['items'][] = compact('text', 'link'); 93 | } 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * Replace the placeholders in the template with actual values 100 | */ 101 | private function replace(): void 102 | { 103 | /*Replace placeholders in the template with actual values from configuration*/ 104 | $this->template = Str::replace('{{title}}', $this->config['title'], $this->template); 105 | $this->template = Str::replace('{{description}}', $this->config['description'], $this->template); 106 | $navItems = ''; 107 | foreach ($this->config['nav'] as $nav) { 108 | $navItems .= "\t\t\t{ text: '".$nav['text']."', link: '".$nav['link']."' },".PHP_EOL; 109 | } 110 | $this->template = Str::replace('{{nav}}', $navItems, $this->template); 111 | $sidebar = ''; 112 | foreach ($this->config['sidebar'] as $bar) { 113 | $sidebar .= "\t\t\t{".PHP_EOL; 114 | $sidebar .= "\t\t\t\ttext: '".$bar['text']."',".PHP_EOL; 115 | $sidebar .= "\t\t\t\titems: [".PHP_EOL; 116 | foreach ($bar['items'] as $item) { 117 | $sidebar .= "\t\t\t\t\t{ text: '".$item['text']."', link: '".$item['link']."' },".PHP_EOL; 118 | } 119 | $sidebar .= "\t\t\t\t]".PHP_EOL; 120 | $sidebar .= "\t\t\t},"; 121 | } 122 | $this->template = Str::replace('{{sidebar}}', $sidebar, $this->template); 123 | } 124 | 125 | /** 126 | * Build the VitePress config 127 | */ 128 | public function build(): string 129 | { 130 | /*Build the final configuration by replacing placeholders*/ 131 | $this->replace(); 132 | 133 | return $this->template; 134 | } 135 | } -------------------------------------------------------------------------------- /src/Helpers/CollectHelper.php: -------------------------------------------------------------------------------- 1 | map(function ($item) use ($attributeName, $mapAttributeName, $defaultValue, $map) { 26 | if (! isset($item[$mapAttributeName])) { 27 | $item[$attributeName] = $defaultValue; 28 | } else { 29 | $item[$attributeName] = $map[$item[$mapAttributeName]] ?? $defaultValue; 30 | } 31 | 32 | return $item; 33 | })->all(); 34 | } 35 | 36 | /** 37 | * Extracts a specific column from an array of items. 38 | * 39 | * This method takes an array of items and extracts the values of a specific column. 40 | * to retrieve the values and returns them as an array. 41 | * 42 | * @param array $items An array of items to extract the column from. 43 | * @param string $column The name of the column to extract. 44 | * @return array An array containing the values of the specified column. 45 | */ 46 | public static function column(array $items, string $column): array 47 | { 48 | return collect($items)->pluck($column)->unique()->values()->toArray(); 49 | } 50 | 51 | /** 52 | * Extracts specified columns from an array of items and returns them as a flat array. 53 | * 54 | * This method takes an array of items and an array of columns. It iterates over each item in the array and extracts 55 | * the values of the specified columns, creating a flat array of these values. 56 | * 57 | * @param array $items An array of items to extract columns from. 58 | * @param array $columns An array of column names to extract values from. 59 | * @return array A flat array containing the extracted values from the specified columns. 60 | */ 61 | public static function extractColumnsToArray(array $items, array $columns): array 62 | { 63 | return collect($items)->flatMap(function ($item) use ($columns) { 64 | $elements = []; 65 | foreach ($columns as $column) { 66 | $elements[] = $item[$column]; 67 | } 68 | 69 | return $elements; 70 | })->filter(fn ($item) => !empty($item))->unique()->values()->toArray(); 71 | } 72 | 73 | /** 74 | * Build a tree structure from an array of items. 75 | * 76 | * @param array $items The array of items to build the tree from. 77 | * @param string $parentKey The key for the parent identifier in each item. Default is 'parent_id'. 78 | * @param string $uniqueKey The key for the unique identifier in each item. Default is 'id'. 79 | * @param string $childrenKey The key for the children array in each item. Default is 'children'. 80 | * @return array The tree structure built from the array of items. 81 | */ 82 | public static function buildTree(array $items, string $parentKey = 'parent_id', string $uniqueKey = 'id', string $childrenKey = 'children'): array 83 | { 84 | $tree = []; 85 | $items = collect($items)->keyBy($uniqueKey)->toArray(); 86 | foreach ($items as $item) { 87 | if ($item[$parentKey] !== 0) { 88 | $items[$item[$parentKey]][$childrenKey][] = &$items[$item[$uniqueKey]]; 89 | } else { 90 | $tree[] = &$items[$item[$uniqueKey]]; 91 | } 92 | } 93 | 94 | return $tree; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Helpers/MarkdownHelper.php: -------------------------------------------------------------------------------- 1 | meta = '---'.PHP_EOL; 31 | foreach ($items as $key => $value) { 32 | $this->meta .= "$key: $value".PHP_EOL; 33 | } 34 | $this->meta .= '---'.PHP_EOL; 35 | 36 | return $this; 37 | } 38 | 39 | /** 40 | * Method for creating a level-1 heading in Markdown. 41 | * 42 | * @param string $title The title of the heading. 43 | * @return self Returns the instance of the class. 44 | */ 45 | public function h1(string $title): self 46 | { 47 | $this->context .= "# $title #".PHP_EOL; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Method for creating a level-2 heading in Markdown. 54 | * 55 | * @param string $title The title of the heading. 56 | * @return self Returns the instance of the class. 57 | */ 58 | public function h2(string $title): self 59 | { 60 | $this->context .= "## $title ##".PHP_EOL; 61 | 62 | return $this; 63 | } 64 | 65 | /** 66 | * Method for creating a level-3 heading in Markdown. 67 | * 68 | * @param string $title The title of the heading. 69 | * @return self Returns the instance of the class. 70 | */ 71 | public function h3(string $title): self 72 | { 73 | $this->context .= "### $title ###".PHP_EOL; 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * Method for creating a level-4 heading in Markdown. 80 | * 81 | * @param string $title The title of the heading. 82 | * @return self Returns the instance of the class. 83 | */ 84 | public function h4(string $title): self 85 | { 86 | $this->context .= "#### $title ####".PHP_EOL; 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * Method for creating a level-5 heading in Markdown. 93 | * 94 | * @param string $title The title of the heading. 95 | * @return self Returns the instance of the class. 96 | */ 97 | public function h5(string $title): self 98 | { 99 | $this->context .= "##### $title #####".PHP_EOL; 100 | 101 | return $this; 102 | } 103 | 104 | /** 105 | * Method for creating a level-6 heading in Markdown. 106 | * 107 | * @param string $title The title of the heading. 108 | * @return self Returns the instance of the class. 109 | */ 110 | public function h6(string $title): self 111 | { 112 | $this->context .= "###### $title ######".PHP_EOL; 113 | 114 | return $this; 115 | } 116 | 117 | /** 118 | * Method for creating a new paragraph in Markdown. 119 | * 120 | * @param string $context The content of the paragraph. 121 | * @return self Returns the instance of the class. 122 | */ 123 | public function p(string $context): self 124 | { 125 | $this->context .= $context.PHP_EOL; 126 | 127 | return $this; 128 | } 129 | 130 | /** 131 | * Method for creating a new inline-code in Markdown. 132 | * 133 | * @return self Returns the instance of the class. 134 | */ 135 | public function inlineCode(string $code): self 136 | { 137 | $this->context .= " `$code` "; 138 | 139 | return $this; 140 | } 141 | 142 | /** 143 | * Method for creating a new code in Markdown. 144 | * 145 | * @return self Returns the instance of the class. 146 | */ 147 | public function code(string $code, string $language = 'plain'): self 148 | { 149 | $this->context .= PHP_EOL."```$language".PHP_EOL; 150 | $this->context .= $code.PHP_EOL; 151 | $this->context .= '```'.PHP_EOL; 152 | 153 | return $this; 154 | } 155 | 156 | /** 157 | * This function is used to add a horizontal rule to the markdown context. 158 | * 159 | * @return self Returns the instance of the class to support method chaining. 160 | */ 161 | public function hr(): self 162 | { 163 | $this->context .= PHP_EOL.'-------'.PHP_EOL; 164 | 165 | return $this; 166 | } 167 | 168 | /** 169 | * Generate a Markdown table 170 | * 171 | * @param array $headers Array of table headers 172 | * @param array $rows Array of arrays where each array contains a row of table cells 173 | */ 174 | public function table(array $headers, array $rows): self 175 | { 176 | $this->context .= PHP_EOL; 177 | $this->context .= '| '.implode(' | ', $headers).' |'.PHP_EOL; 178 | $this->context .= str_repeat('|---', count($headers)).'|'.PHP_EOL; 179 | foreach ($rows as $row) { 180 | $this->context .= '| '.implode(' | ', $row).' |'.PHP_EOL; 181 | } 182 | $this->context .= PHP_EOL; 183 | 184 | return $this; 185 | } 186 | 187 | /** 188 | * This function allows adding additional context/info and marks it as a warning in the Markdown format. 189 | * 190 | * @param string $context - A string that represents the context/info to be added. 191 | * @return self - Returns $this for method chaining purposes. 192 | */ 193 | public function warning(string $context): self 194 | { 195 | $this->context .= "> $context".PHP_EOL; 196 | 197 | return $this; 198 | } 199 | 200 | /** 201 | * Turns a string into bold text in Markdown format. 202 | * 203 | * @param string $context The text that needs to be bolded. 204 | * @return self Returns the instance of the class itself for chaining. 205 | */ 206 | public function bold(string $context): self 207 | { 208 | $this->context = "**$context**"; 209 | 210 | return $this; 211 | } 212 | 213 | /** 214 | * Method for creating a new li in Markdown. 215 | * 216 | * @return $this 217 | */ 218 | public function li(array $items): self 219 | { 220 | $i = 0; 221 | foreach ($items as $item) { 222 | $this->context .= (++$i).$item.PHP_EOL; 223 | } 224 | 225 | return $this; 226 | } 227 | 228 | /** 229 | * Method for creating a new ol in Markdown. 230 | * 231 | * @return $this 232 | */ 233 | public function ol(array $items): self 234 | { 235 | foreach ($items as $item) { 236 | $this->context .= "* $item".PHP_EOL; 237 | } 238 | 239 | return $this; 240 | } 241 | 242 | /** 243 | * Generates an image Markdown string and appends it to the current context. 244 | * 245 | * @param string $url The URL of the image. 246 | * @param string $alt The alternative text for the image. Default value is an empty string. 247 | * @return self Returns the current instance of MarkdownHelper to allow method chaining. 248 | */ 249 | public function image(string $url, string $alt = ''): self 250 | { 251 | $this->context .= PHP_EOL."![$alt]($url)".PHP_EOL; 252 | 253 | return $this; 254 | } 255 | 256 | /** 257 | * Adds a Markdown link to the context. 258 | * 259 | * @param string $text Text to be displayed as a link. 260 | * @param string $url URL of the link. 261 | * @return self Returns instance of the current class. 262 | */ 263 | public function link(string $text, string $url): self 264 | { 265 | $this->context .= " [$text]($url) ".PHP_EOL; 266 | 267 | return $this; 268 | } 269 | 270 | /** 271 | * Method to get the generated Markdown. 272 | * 273 | * @return string The generated Markdown. 274 | */ 275 | public function build(): string 276 | { 277 | return $this->meta.PHP_EOL.$this->context; 278 | } 279 | } -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Laravel-Plus-Logo 4 | 5 |

6 | 7 | ![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-blue.svg) 8 | ![Laravel Version](https://img.shields.io/badge/laravel-%3E%3D11.0-red.svg) 9 | [![Setup Automated](https://img.shields.io/badge/setup-automated-blue?logo=gitpod)](https://packagist.org) 10 | Download statistics 11 | ![License](https://img.shields.io/badge/license-MIT-green.svg) 12 | 13 | ## Laravel Plus 14 | 15 | 中文文档 | [English](README.md) 16 | 17 | Laravel 是一个优雅的框架,极大地简化了开发工作。然而,没有任何框架能够真正做到对所有用例都"开箱即用";基于个人习惯和项目需求进行定制往往是必要的。 18 | 19 | Laravel Plus 正是为了满足这一需求而生,它融合了 Java Spring Boot 的 AOP 概念,并广泛利用 PHP8 注解来简化开发流程。 20 | 21 | > 该项目目前正在开发中,在生产环境中使用时请谨慎。 22 | 23 | ## 安装 24 | 25 | 通过 [Composer](https://getcomposer.org/) 安装 [zenithsu/laravel-plus](https://packagist.org/packages/zenithsu/laravel-plus)。 26 | 27 | ```shell 28 | composer require zenithsu/laravel-plus 29 | ``` 30 | 31 | ## 简易路由 32 | 33 | 在 Laravel 中,路由需要在 `web.php` 或 `api.php` 中单独配置,这在开发过程中并不方便,因为需要在不同文件之间切换。 34 | 35 | 相比之下,像 Spring Boot 或 Flask 这样的框架允许使用注解配置路由,使编码过程更加流畅。因此,我封装了基于注解的路由功能。 36 | 37 | 首先,你需要在 `api.php` 中注册,代码如下: 38 | ```php 39 | use Zenith\LaravelPlus\EasyRouter; 40 | 41 | EasyRouter::register(); 42 | ``` 43 | 44 | 然后,你可以在控制器方法前使用路由注解: 45 | ```php 46 | class UserController 47 | { 48 | #[GetMapping(path: '/login')] 49 | public function login() {} 50 | } 51 | ``` 52 | 53 | 你可以通过 `/api/login` 访问此 API。除了 `GetMapping` 之外,还支持 `PostMapping`、`PutMapping` 和 `DeleteMapping`。 54 | 55 | 此外,你可以在控制器上添加 Prefix 注解,为控制器内的所有方法统一添加路由前缀。 56 | 57 | ```php 58 | use Zenith\LaravelPlus\Attributes\Routes\GetMapping; 59 | use Zenith\LaravelPlus\Attributes\Routes\PostMapping; 60 | use Zenith\LaravelPlus\Attributes\Routes\Prefix; 61 | 62 | #[Prefix(path: '/user')] 63 | class UserController 64 | { 65 | #[GetMapping(path: '/login')] 66 | public function login() {} 67 | 68 | #[PostMapping(path: '/register')] 69 | public function register() {} 70 | } 71 | ``` 72 | 73 | ## 请求处理 74 | 75 | 在 Laravel-Plus 中,受 SpringBoot 的 RequestBody 注解启发,你可以使用一个类来承载来自请求体的参数: 76 | 77 | ```php 78 | use Zenith\LaravelPlus\Middlewares\RequestBodyInjector; 79 | 80 | abstract class Controller implements HasMiddleware 81 | { 82 | public static function middleware(): array 83 | { 84 | return [ 85 | RequestBodyInjector::class, 86 | ]; 87 | } 88 | } 89 | ``` 90 | 91 | 然后,你可以使用 RequestBody 注解来注入来自请求体的参数: 92 | 93 | ```php 94 | use Zenith\LaravelPlus\Attributes\Routes\GetMapping; 95 | use Zenith\LaravelPlus\Attributes\Requests\RequestBody; 96 | use Zenith\LaravelPlus\Bean; 97 | 98 | class UserController extends Controller 99 | { 100 | #[GetMapping(path: '/login')] 101 | public function login(#[RequestBody] RegisterParams $params) {} 102 | } 103 | 104 | // RegisterParams 类必须继承 Bean 类 105 | class RegisterParams extends Bean 106 | { 107 | // 修饰符必须是 public 或 protected 108 | protected string $username; 109 | 110 | protected string $password; 111 | } 112 | ``` 113 | 114 | ## 验证器 115 | 116 | 在 Laravel 中,参数验证并不是一项困难的任务。然而,通过使用注解可以让它变得更加简单。 117 | 118 | 首先,你需要启用参数验证中间件: 119 | ```php 120 | use Zenith\LaravelPlus\Middlewares\RequestParamsDefaultValueInjector; 121 | 122 | abstract class Controller implements HasMiddleware 123 | { 124 | public static function middleware(): array 125 | { 126 | return [ 127 | RequestParamsDefaultValueInjector::class, 128 | ParameterValidation::class, 129 | ]; 130 | } 131 | } 132 | ``` 133 | 134 | 然后,你可以使用 Param 注解来验证参数: 135 | 136 | ```php 137 | use Zenith\LaravelPlus\Attributes\Validators\Param; 138 | 139 | class UserController extends Controller 140 | { 141 | #[GetMapping(path: '/login')] 142 | #[Param(key: 'username', rules: 'required|string|max:32', message: 'Username is required.')] 143 | public function login() {} 144 | } 145 | ``` 146 | 147 | `rule` 支持 Laravel 的内置规则,除了正则表达式。 148 | 149 | 对于特别复杂的规则,建议使用自定义验证器: 150 | ```php 151 | use Closure; 152 | use Illuminate\Contracts\Validation\ValidationRule; 153 | 154 | class PasswordRule implements ValidationRule 155 | { 156 | public function validate(string $attribute, mixed $value, Closure $fail): void 157 | { 158 | $isPass = strlen($value) >= 8 && preg_match('/[a-zA-Z]/', $value) && 159 | preg_match('/\d/', $value) && 160 | preg_match('/[^a-zA-Z\d]/', $value); 161 | if (! $isPass) { 162 | $fail('The :attribute must be least 8 characters and contain at least one letter, one number and one special character.'); 163 | } 164 | } 165 | } 166 | ``` 167 | 168 | 在上面的示例中,我编写了一个常见密码验证的自定义规则: 169 | ```php 170 | use Zenith\LaravelPlus\Attributes\Validators\Param; 171 | 172 | class UserController 173 | { 174 | #[GetMapping(path: '/login')] 175 | #[Param(key: 'username', rules: PasswordRule::class, message: 'password is error')] 176 | public function login() {} 177 | } 178 | ``` 179 | 180 | 默认情况下,所有参数都是必需的。你可以使用 `required` 参数将它们设置为可选,并使用 `default` 参数设置默认值: 181 | ```php 182 | use Zenith\LaravelPlus\Attributes\Validators\Param; 183 | use Zenith\LaravelPlus\Attributes\Requests\RequestBody; 184 | 185 | class UserController extends Controller 186 | { 187 | #[Param(key: 'page', rule: 'integer|min:1|max:100', default: 1, required: false, message: 'page is error')] 188 | public function queryList(#[RequestBody] QueryParams $params) 189 | { 190 | dump($params->getPage()); // 输出: 1 191 | } 192 | } 193 | ``` 194 | 195 | ## Bean 196 | 197 | 长期以来,PHP 开发者习惯于使用功能强大的数组作为所有数据的载体。这不是一个优雅的做法,并且存在以下问题: 198 | 199 | * 数组键容易拼写错误,当发现这些错误时,已经是运行时了 200 | * 编码过程不够流畅;你总是需要暂停思考下一个键是什么 201 | * 违反了单一职责原则,经常将所有数据放在一个巨大的数组中 202 | * 降低了代码的可扩展性、可读性和健壮性... 203 | 204 | 因此,我引入了 Bean 的概念。Bean 是一个具有强类型属性的数据载体,让你在编码过程中获得更好的提示: 205 | 206 | ```php 207 | use Zenith\LaravelPlus\Bean; 208 | 209 | /** 210 | * @method getUsername() 211 | * @method setUsername() 212 | * @method getPassword() 213 | * @method setPassword() 214 | */ 215 | class RegisterParams extends Bean 216 | { 217 | protected string $username; 218 | 219 | protected string $password; 220 | } 221 | 222 | new RegisterParams(['username' => 'bob', 'password' => 'passw0rd']); 223 | ``` 224 | 225 | 你可以使用数组初始化 Bean,这是最常见的方法。当然,有时你也可以从一个 Bean 转换为另一个 Bean,它会过滤掉不匹配的字段: 226 | ```php 227 | use Zenith\LaravelPlus\Bean; 228 | 229 | $bean = new Bean(); 230 | class Bar extends Bean { 231 | // 一些属性 232 | } 233 | Bar::fromBean($bean) 234 | ``` 235 | 236 | 你可以轻松地将 Bean 转换为数组或 JSON。默认情况下,将使用蛇形命名法。你可以使用 `usingSnakeCase` 参数关闭此功能: 237 | ```php 238 | use Zenith\LaravelPlus\Bean; 239 | 240 | $bean = new Bean(); 241 | $arr = $bean->toArray(usingSnakeCase: false); 242 | $json = $bean->toJson(usingSnakeCase: true); 243 | ``` 244 | 245 | 有时,你可能需要比较两个 Bean: 246 | ```php 247 | use Zenith\LaravelPlus\Bean; 248 | (new Bean())->equals($bean2); 249 | ``` 250 | 251 | 通常,我们需要对从客户端传递的数据进行类型转换等预处理工作: 252 | ```php 253 | use Zenith\LaravelPlus\Bean; 254 | use Zenith\LaravelPlus\Attributes\TypeConverter; 255 | 256 | class User extends Bean 257 | { 258 | #[TypeConverter(value: BoolConverter::class)] 259 | protected BoolEnum $isGuest; 260 | } 261 | 262 | class BoolConverter 263 | { 264 | public function convert(bool|string $value): BoolEnum 265 | { 266 | if ($value === 'YES' || $value === 'yes' || $value === 'y' || $value === 'Y') { 267 | return BoolEnum::TRUE; 268 | } 269 | if ($value === 'NO' || $value === 'no' || $value === 'N' || $value === 'n') { 270 | return BoolEnum::FALSE; 271 | } 272 | 273 | return $value ? BoolEnum::TRUE : BoolEnum::FALSE; 274 | } 275 | } 276 | ``` 277 | 278 | 你甚至可以执行 XSS 过滤。 279 | 280 | Bean 的一个特别有用的功能是支持嵌套: 281 | ```php 282 | use Zenith\LaravelPlus\Bean; 283 | 284 | class User extends Bean 285 | { 286 | protected Company $company; 287 | } 288 | 289 | class Company extends Bean 290 | { 291 | protected string $name; 292 | } 293 | ``` 294 | 295 | 它甚至支持数组嵌套: 296 | ```php 297 | use Zenith\LaravelPlus\Bean; 298 | use Zenith\LaravelPlus\Attributes\BeanList; 299 | 300 | /** 301 | * @method Company[] getCompanies() 302 | */ 303 | class User extends Bean 304 | { 305 | /** 306 | * @var Company[] 307 | */ 308 | #[BeanList(value: Company::class)] 309 | protected array $companies; 310 | } 311 | 312 | $user = new User(['companies' => [['name' => 'Zenith'], ['name' => 'Google']]]); 313 | foreach ($user->getCompanies() as $company) { 314 | dump($company->getName()); 315 | } 316 | ``` 317 | 318 | ## 自动装配 319 | 320 | 在 Java Spring Boot 框架中,`@Autowired` 注解用于自动注入依赖。在 Laravel-Plus 中,我们可以使用 `#[Autowired]` 注解来实现相同的效果。 321 | 322 | ```php 323 | use Zenith\LaravelPlus\Traits\Injectable; 324 | 325 | class UserController 326 | { 327 | use Injectable; 328 | 329 | #[Autowired] 330 | private UserService $userService; 331 | 332 | public function register() 333 | { 334 | $this->userService->register(); 335 | } 336 | } 337 | 338 | use Zenith\LaravelPlus\Attributes\Service; 339 | 340 | #[Service] 341 | class UserService 342 | { 343 | public function register() {} 344 | } 345 | ``` 346 | 347 | `#[Autowired]` 注解可以用于属性。`#[Service]` 注解用于将类标记为服务,这是自动装配所必需的。 348 | 349 | ## 配置值注入 350 | 351 | 除了依赖注入之外,Laravel-Plus 还支持配置值注入功能,让你可以直接将 Laravel 配置文件中的值注入到类属性中: 352 | 353 | ```php 354 | use Zenith\LaravelPlus\Traits\Injectable; 355 | use Zenith\LaravelPlus\Attributes\Value; 356 | 357 | class DatabaseService 358 | { 359 | use Injectable; 360 | 361 | #[Value('database.connections.mysql.host', 'localhost')] 362 | private string $dbHost; 363 | 364 | #[Value('database.connections.mysql.port', 3306)] 365 | private int $dbPort; 366 | 367 | #[Value('app.timezone', 'UTC')] 368 | private string $timezone; 369 | 370 | public function connect() 371 | { 372 | // 使用注入的配置值 373 | echo "Connecting to {$this->dbHost}:{$this->dbPort}"; 374 | } 375 | } 376 | ``` 377 | 378 | `#[Value]` 注解的第一个参数是配置键名,第二个参数是可选的默认值。如果配置不存在,将使用默认值。 379 | -------------------------------------------------------------------------------- /src/Bean.php: -------------------------------------------------------------------------------- 1 | collectMetaInfo(); 40 | $this->init($data); 41 | } 42 | 43 | /** 44 | * @return void 45 | * @throws ReflectionException 46 | */ 47 | private function collectMetaInfo(): void 48 | { 49 | $reflectionClass = new ReflectionClass($this); 50 | $properties = $reflectionClass->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PUBLIC); 51 | foreach ($properties as $property) { 52 | if (str_starts_with($property->getName(), '_') || in_array($property->getName(), $this->_skip)) { 53 | continue; 54 | } 55 | $attributes = $property->getAttributes(); 56 | $alias = $converter = $beanList = null; 57 | $mock = []; 58 | $unionType = ['is' => false, 'type' =>'', 'map' => []]; 59 | foreach ($attributes as $attribute) { 60 | $enums = []; 61 | if ($attribute->getName() === Alias::class) { 62 | $alias = $attribute->newInstance()->value; 63 | } 64 | if ($attribute->getName() === TypeConverter::class) { 65 | $converter = $attribute->newInstance()->value; 66 | } 67 | if ($attribute->getName() === BeanList::class) { 68 | $beanList = $attribute->newInstance()->value; 69 | } 70 | if ($attribute->getName() === Mock::class) { 71 | $mockInstance = $attribute->newInstance(); 72 | if ($mockInstance->type === MockType::OBJECT || $mockInstance->type === MockType::OBJECT_ARRAY) { 73 | $mockValue = (new $mockInstance->value([]))->getMockData(); 74 | } else if ($mockInstance->type === MockType::ENUM) { 75 | $mockValue = $mockInstance->value; 76 | $enums = $this->getEnumInfo($mockInstance->value); 77 | } else { 78 | $mockValue = $mockInstance->value; 79 | } 80 | $mock = [ 81 | 'value' => $mockValue, 82 | 'comment' => $mockInstance->comment, 83 | 'type' => strtolower($mockInstance->type->name), 84 | 'enums' => $enums, 85 | ]; 86 | } 87 | if ($attribute->getName() === UnionType::class) { 88 | $unionType = [ 89 | 'is' => true, 90 | 'type' => $attribute->newInstance()->type, 91 | 'map' => $attribute->newInstance()->map, 92 | ]; 93 | } 94 | } 95 | $snake = $alias !== null ? Str::snake($alias) : Str::snake($property->getName()); 96 | 97 | $type = 'UnionType'; 98 | if (!$unionType['is']) { 99 | $type = $property->getType()?->getName(); 100 | } 101 | 102 | $this->_meta[$property->getName()] = [ 103 | 'type' => $type, 104 | 'alias' => $alias, 105 | 'snake' => $snake, 106 | 'reflectProperty' => $property, 107 | 'converter' => $converter, 108 | 'beanList' => $beanList, 109 | 'value' => $property->hasDefaultValue() ? $property->getDefaultValue() : null, 110 | 'mock' => $mock, 111 | 'union' => $unionType, 112 | ]; 113 | } 114 | } 115 | 116 | /** 117 | * @throws ReflectionException 118 | */ 119 | private function getEnumInfo(string $enumClazz): array 120 | { 121 | $reflectionEnum = new ReflectionClass($enumClazz); 122 | if (!$reflectionEnum->isEnum()) { 123 | return []; 124 | } 125 | $enums = []; 126 | $constants = $reflectionEnum->getConstants(); 127 | foreach ($constants as $name => $constant) { 128 | $reflectionConstant = new ReflectionClassConstant($enumClazz, $name); 129 | $alias = collect($reflectionConstant->getAttributes())->filter(fn (ReflectionAttribute $attribute) => $attribute->getName() === Alias::class) 130 | ->first(); 131 | if ($alias === null) { 132 | $enums[$name] = ''; 133 | continue; 134 | } 135 | $enums[$name] = $alias->newInstance()->value; 136 | } 137 | return $enums; 138 | } 139 | 140 | private function init(array $data): void 141 | { 142 | foreach ($this->_meta as $propertyName => $meta) { 143 | $value = $data[$meta['alias']] ?? $data[$propertyName] ?? $data[$meta['snake']] ?? null; 144 | if ($value === null) { 145 | continue; 146 | } 147 | if ($meta['converter'] !== null) { 148 | $value = $this->convertValue($meta['converter'], $value); 149 | } 150 | if ($meta['union']['is'] === true) { 151 | $typeValue = $this->_meta[$meta['union']['type']]['value']; 152 | $wrapper = $meta['union']['map'][$typeValue]; 153 | $value = new $wrapper($value); 154 | } 155 | if ($meta['beanList'] !== null) { 156 | $value = $this->convertBeanList($meta['beanList'], $value); 157 | } 158 | if (is_subclass_of($meta['type'], Bean::class)) { 159 | $wrapper = $meta['type']; 160 | $value = new $wrapper($value); 161 | } 162 | $meta['reflectProperty']->setValue($this, $value); 163 | $this->_meta[$propertyName]['value'] = $value; 164 | } 165 | } 166 | 167 | 168 | private function convertValue(string $convertor, mixed $value): mixed 169 | { 170 | if (function_exists($convertor)) { 171 | return $convertor($value); 172 | } 173 | try { 174 | return (new $convertor())->convert($value); 175 | } catch (Throwable $exception) { 176 | return $value; 177 | } 178 | } 179 | 180 | private function convertBeanList(string $clazz, array $items): array 181 | { 182 | $beanList = []; 183 | foreach ($items as $item) { 184 | $beanList[] = new $clazz($item); 185 | } 186 | return $beanList; 187 | } 188 | 189 | /** 190 | * Convert the Bean to array 191 | * 192 | * @throws ReflectionException 193 | */ 194 | #[Override] 195 | public function toArray(bool $usingSnakeCase = true): array 196 | { 197 | $arr = []; 198 | foreach ($this->_meta as $propertyName => $meta) { 199 | $value = $meta['value']; 200 | if (is_object($value) && (is_subclass_of($value, Bean::class) || method_exists($value, 'toArray'))) { 201 | $value = $value->toArray(); 202 | } 203 | if (is_array($value) && isset($value[0]) && is_subclass_of($value[0], Bean::class)) { 204 | $values = []; 205 | foreach ($value as $item) { 206 | $values[] = $item->toArray(); 207 | }; 208 | $value = $values; 209 | } 210 | 211 | $key = $usingSnakeCase ? $meta['snake'] : $propertyName; 212 | $arr[$key] = $value; 213 | } 214 | return $arr; 215 | } 216 | 217 | /** 218 | * Convert the Bean to JSON 219 | * @throws ReflectionException 220 | */ 221 | public function toJson(bool $usingSnakeCase = true): string 222 | { 223 | return json_encode($this->toArray($usingSnakeCase)); 224 | } 225 | 226 | public function getMockData(): array 227 | { 228 | $properties = []; 229 | foreach ($this->_meta as $property => $meta) { 230 | $properties[$property] = $meta['mock']; 231 | } 232 | 233 | return $properties; 234 | } 235 | 236 | /** 237 | * @throws PropertyNotFoundException 238 | */ 239 | public function __call(string $name, array $arguments) 240 | { 241 | if (str_starts_with($name, 'set')) { 242 | $property = lcfirst(substr($name, strlen('set'))); 243 | if (property_exists($this, $property)) { 244 | $this->_meta[$property]['reflectProperty']->setValue($this, $arguments[0]); 245 | $this->_meta[$property]['value'] = $arguments[0]; 246 | return $this; 247 | } 248 | throw new PropertyNotFoundException("The property '{$property}' does not exist."); 249 | } 250 | if (str_starts_with($name, 'get')) { 251 | $property = lcfirst(substr($name, strlen('get'))); 252 | return $this->_meta[$property]['value']; 253 | } 254 | return $this; 255 | } 256 | 257 | /** 258 | * @throws ReflectionException 259 | */ 260 | public function equals(Bean $bean): bool 261 | { 262 | $firstBean = $bean->toArray(); 263 | $secondBean = $this->toArray(); 264 | foreach ($firstBean as $key => $value) { 265 | if ($value !== $secondBean[$key]) { 266 | return false; 267 | } 268 | } 269 | 270 | return true; 271 | } 272 | 273 | /** 274 | * @throws ReflectionException 275 | */ 276 | public static function fromBean(Bean $bean): static 277 | { 278 | return new static($bean->toArray()); 279 | } 280 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Laravel-Plus-Logo 4 | 5 |

6 | 7 | ![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-blue.svg) 8 | ![Laravel Version](https://img.shields.io/badge/laravel-%3E%3D11.0-red.svg) 9 | [![Setup Automated](https://img.shields.io/badge/setup-automated-blue?logo=gitpod)](https://packagist.org) 10 | Download statistics 11 | ![License](https://img.shields.io/badge/license-MIT-green.svg) 12 | 13 | ## Laravel Plus 14 | 15 | [中文文档](README_CN.md) | English 16 | 17 | Laravel is an elegant framework that greatly simplifies development. However, no framework is truly "out-of-the-box" ready for all use cases; customization based on individual habits and project requirements is often necessary. 18 | 19 | Laravel Plus addresses this need by incorporating AOP concepts from Java's Spring Boot and extensively utilizing PHP 8 attributes to streamline the development process. 20 | 21 | > This project is currently under development. Please be cautious when using it in a production environment. 22 | 23 | ## Installation 24 | 25 | This is installable via [Composer](https://getcomposer.org/) as [https://packagist.org/packages/zenithsu/laravel-plus](zenithsu/laravel-plus). 26 | 27 | ```shell 28 | composer require zenithsu/laravel-plus 29 | ``` 30 | 31 | ## Easy Router 32 | 33 | In Laravel, routes need to be configured separately in `web.php` or `api.php`, which is not convenient during development as it requires switching between different files. 34 | 35 | In contrast, frameworks like Spring Boot or Flask allow route configuration using annotations, making the coding process more fluid. Therefore, I have encapsulated annotation-based routing. 36 | 37 | First, you need to register in api.php, the code is as follows: 38 | ```php 39 | use Zenith\LaravelPlus\EasyRouter; 40 | 41 | EasyRouter::register(); 42 | ``` 43 | Then, you can use route annotations before controller methods: 44 | ```php 45 | class UserController 46 | { 47 | #[GetMapping(path: '/login')] 48 | public function login() {} 49 | } 50 | ``` 51 | You can access this API via `/api/login`. In addition to `GetMapping`, `PostMapping`, `PutMapping`, and `DeleteMapping` are also supported. 52 | 53 | Furthermore, you can add a Prefix annotation to the controller to uniformly add a route prefix for all methods within the controller. 54 | ```php 55 | use Zenith\LaravelPlus\Attributes\Routes\GetMapping; 56 | use Zenith\LaravelPlus\Attributes\Routes\PostMapping; 57 | use Zenith\LaravelPlus\Attributes\Routes\Prefix; 58 | 59 | #[Prefix(path: '/user')] 60 | class UserController 61 | { 62 | #[GetMapping(path: '/login')] 63 | public function login() {} 64 | 65 | #[PostMapping(path: '/register')] 66 | public function register() {} 67 | } 68 | ``` 69 | 70 | ## Request 71 | 72 | In Laravel-Plus, inspired by SpringBoot's RequestBody annotation, you can use a class to carry parameters from the body: 73 | 74 | ```php 75 | use Zenith\LaravelPlus\Middlewares\RequestBodyInjector; 76 | 77 | abstract class Controller implements HasMiddleware 78 | { 79 | public static function middleware(): array 80 | { 81 | return [ 82 | RequestBodyInjector::class, 83 | ]; 84 | } 85 | } 86 | ``` 87 | 88 | Then, you can use the RequestBody annotation to inject parameters from the body: 89 | 90 | ```php 91 | use Zenith\LaravelPlus\Attributes\Routes\GetMapping; 92 | use Zenith\LaravelPlus\Attributes\Requests\RequestBody; 93 | use Zenith\LaravelPlus\Bean; 94 | 95 | class UserController extends Controller 96 | { 97 | #[GetMapping(path: '/login')] 98 | public function login(#[RequestBody] RegisterParams $params) {} 99 | } 100 | 101 | // The RegisterParams class must extend the Bean class. 102 | class RegisterParams extends Bean 103 | { 104 | // The modifiers must be public or protected. 105 | protected string $username; 106 | 107 | protected string $password; 108 | } 109 | ``` 110 | 111 | ## Validators 112 | 113 | In Laravel, parameter validation is not a difficult task. However, it can be made even simpler through the use of annotations. 114 | 115 | First, you need to enable the parameter validation middleware: 116 | ```php 117 | use Zenith\LaravelPlus\Middlewares\RequestParamsDefaultValueInjector; 118 | 119 | abstract class Controller implements HasMiddleware 120 | { 121 | public static function middleware(): array 122 | { 123 | return [ 124 | RequestParamsDefaultValueInjector::class 125 | ParameterValidation::class, 126 | ]; 127 | } 128 | } 129 | ``` 130 | 131 | Then, you can use the Param annotation to validate parameters: 132 | 133 | ```php 134 | use Zenith\LaravelPlus\Attributes\Validators\Param; 135 | 136 | class UserController extends Controller 137 | { 138 | #[GetMapping(path: '/login')] 139 | #[Param(key: 'username', rules: 'required|string|max:32', message: 'Username is required.')] 140 | public function login() {} 141 | } 142 | ``` 143 | The `rule` supports Laravel's built-in rules, except for regular expressions. 144 | 145 | For particularly complex rules, it is recommended to use custom validators: 146 | ```php 147 | use Closure; 148 | use Illuminate\Contracts\Validation\ValidationRule; 149 | 150 | class PasswordRule implements ValidationRule 151 | { 152 | public function validate(string $attribute, mixed $value, Closure $fail): void 153 | { 154 | $isPass = strlen($value) >= 8 && preg_match('/[a-zA-Z]/', $value) && 155 | preg_match('/\d/', $value) && 156 | preg_match('/[^a-zA-Z\d]/', $value); 157 | if (! $isPass) { 158 | $fail('The :attribute must be least 8 characters and contain at least one letter, one number and one special character.'); 159 | } 160 | } 161 | } 162 | ``` 163 | In the example above, I wrote a custom rule for a common password validation: 164 | ```php 165 | use Zenith\LaravelPlus\Attributes\Validators\Param; 166 | 167 | class UserController 168 | { 169 | #[GetMapping(path: '/login')] 170 | #[Param(key: 'username', rules: PasswordRule::class, message: 'password is error')] 171 | public function login() {} 172 | } 173 | ``` 174 | 175 | By default, all parameters are required. You can use the `required` parameter to set them as optional, and use the `default` parameter to set default values: 176 | ```php 177 | use Zenith\LaravelPlus\Attributes\Validators\Param; 178 | use Zenith\LaravelPlus\Attributes\Requests\RequestBody; 179 | 180 | class UserController extends Controller 181 | { 182 | 183 | #[Param(key: 'page', rule: 'integer|min:1|max:100', default: 1, required: false, message: 'page is error')] 184 | public function queryList(#[RequestBody] QueryParams $params) 185 | { 186 | dump($params->getPage()); // output: 1 187 | } 188 | } 189 | ``` 190 | 191 | 192 | ## Bean 193 | 194 | Long-term dependency, PHPers are accustomed to using powerful arrays as carriers for all data. This is not an elegant practice and has the following problems: 195 | 196 | * Array keys are easily misspelled, and when these errors are discovered, it's already at runtime. 197 | * The coding process is not smooth; you always need to pause to think about what the next key is. 198 | * It violates the single responsibility principle, often having all data in one huge array. 199 | * It reduces code extensibility, readability, and robustness... 200 | 201 | Therefore, I introduced the concept of Bean. A Bean is a data carrier with strongly typed properties, allowing you to get better hints during the coding process: 202 | 203 | ```php 204 | use Zenith\LaravelPlus\Bean; 205 | 206 | /** 207 | * @method getUsername() 208 | * @method setUsername() 209 | * @method getPassword() 210 | * @method setPassword() 211 | */ 212 | class RegisterParams extends Bean 213 | { 214 | protected string $username; 215 | 216 | protected string $password; 217 | } 218 | 219 | new RegisterParams(['username' => 'bob', 'password' => 'passw0rd']); 220 | ``` 221 | You can initialize a Bean using an array, which is the most common method.Of course, sometimes you can also convert from one Bean to another Bean, and it will filter out mismatched fields: 222 | ```php 223 | use Zenith\LaravelPlus\Bean; 224 | 225 | $bean = new Bean(); 226 | class Bar extends Bean { 227 | // some properties 228 | } 229 | Bar::fromBean($bean) 230 | ``` 231 | You can easily convert a Bean to an array or JSON, By default, snake case naming will be used. You can turn off this feature using the usingSnakeCase parameter: 232 | ```php 233 | use Zenith\LaravelPlus\Bean; 234 | 235 | $bean = new Bean(); 236 | $arr = $bean->toArray(usingSnakeCase: false) 237 | $json = $bean->toJson(usingSnakeCase: true); 238 | ``` 239 | 240 | Sometimes, you may need to compare two Beans: 241 | ```php 242 | use Zenith\LaravelPlus\Bean; 243 | (new Bean())->equals($bean2); 244 | ``` 245 | 246 | Often, we need to perform preliminary work such as type conversion on the data passed from the client: 247 | ```php 248 | use Zenith\LaravelPlus\Bean:: 249 | use Zenith\LaravelPlus\Attributes\TypeConverter; 250 | 251 | class User extends Bean 252 | { 253 | 254 | #[TypeConverter(value: BoolConverter::class)] 255 | protected BoolEnum $isGuest; 256 | } 257 | 258 | class BoolConverter 259 | { 260 | public function convert(bool|string $value): BoolEnum 261 | { 262 | if ($value === 'YES' || $value === 'yes' || $value === 'y' || $value === 'Y') { 263 | return BoolEnum::TRUE; 264 | } 265 | if ($value === 'NO' || $value === 'no' || $value === 'N' || $value === 'n') { 266 | return BoolEnum::FALSE; 267 | } 268 | 269 | return $value ? BoolEnum::TRUE : BoolEnum::FALSE; 270 | } 271 | } 272 | ``` 273 | You can even perform XSS filtering. 274 | 275 | A particularly useful feature of Beans is their support for nesting: 276 | ```php 277 | use Zenith\LaravelPlus\Bean; 278 | 279 | class User extends Bean 280 | { 281 | protected Company $company; 282 | } 283 | 284 | class Company extends Bean 285 | { 286 | protected string $name; 287 | } 288 | ``` 289 | It even supports array nesting: 290 | ```php 291 | use Zenith\LaravelPlus\Bean; 292 | use Zenith\LaravelPlus\Attributes\BeanList; 293 | 294 | /** 295 | * @method Company[] getCompanies() 296 | */ 297 | class User extends Bean 298 | { 299 | /** 300 | * @var Company[] 301 | */ 302 | #[BeanList(value: Company::class)] 303 | protected array $companies; 304 | } 305 | 306 | $user = new User(['companies' => [['name' => 'Zenith'], ['name' => 'Google']]]); 307 | foreach ($user->getCompanies() as $company) { 308 | dump($company->getName()); 309 | } 310 | ``` 311 | 312 | ### Autowired 313 | 314 | In the Java Spring Boot framework, the `@Autowired` annotation is used to automatically inject dependencies. In Laravel-Plus, we can use the `#[Autowired]` annotation to achieve the same effect。 315 | 316 | ```php 317 | use Zenith\LaravelPlus\Traits\Injectable; 318 | 319 | class UserController 320 | { 321 | use Injectable; 322 | 323 | #[Autowired] 324 | private UserService $userService; 325 | 326 | public function register() 327 | { 328 | $this->userService->register(); 329 | } 330 | } 331 | 332 | use Zenith\LaravelPlus\Attributes\Service; 333 | 334 | #[Service] 335 | class UserService 336 | { 337 | public function register() {} 338 | } 339 | ``` 340 | The `#[Autowired]` annotation can be used on properties. The `#[Service]` annotation is used to mark the class as a service, which is required for autowiring. -------------------------------------------------------------------------------- /src/Commands/DocsBuild.php: -------------------------------------------------------------------------------- 1 | 'GET', 40 | PostMapping::class => 'POST', 41 | PutMapping::class => 'PUT', 42 | DeleteMapping::class => 'DELETE', 43 | ]; 44 | 45 | /** 46 | * The name and signature of the console command. 47 | * 48 | * @var string 49 | */ 50 | protected $signature = 'docs:build'; 51 | 52 | /** 53 | * The console command description. 54 | * 55 | * @var string 56 | */ 57 | protected $description = 'Scan attributes in controllers, generate api docs.'; 58 | 59 | /** 60 | * Execute the console command. 61 | * 62 | * @throws ReflectionException 63 | */ 64 | public function handle(): void 65 | { 66 | $apiInfo = $this->scanApiInfo(); 67 | $modules = collect($apiInfo)->groupBy('module')->all(); 68 | $builder = new VitePressConfigHelper(); 69 | $builder->nav('Home', '/'); 70 | $docsDir = $this->getDocsDir(); 71 | foreach ($modules as $module => $apis) { 72 | $this->generateDocs($apis->toArray(), $builder, $module, $docsDir); 73 | } 74 | File::put($docsDir.'/.vitepress/config.mjs', $builder->build()); 75 | } 76 | 77 | /** 78 | * Generates API documentation from $apis array and save it in Markdown format in designated directory. 79 | * Each markdown file represents an API action. 80 | * 81 | * @param array $apis The array of APIs to document. Each API is an associative array with 'namespace' and 'actions' keys. 82 | * 'actions' itself is an array of action names. 83 | */ 84 | private function generateDocs(array $apis, VitePressConfigHelper $builder, string $module, string $docsDir): void 85 | { 86 | $builder->sidebar($module); 87 | $this->deleteMdFilesExceptIndex($docsDir); 88 | $isFirstApi = true; 89 | foreach ($apis as $api) { 90 | $dir = $this->createDirectoryFromClassNamespace($api['namespace'], $docsDir); 91 | foreach ($api['actions'] as $action) { 92 | $link = Str::replace('\\', '/', Str::after($dir, 'docs').'/'.$action['name']); 93 | if ($isFirstApi) { 94 | $builder->nav('Api', $link); 95 | $isFirstApi = false; 96 | } 97 | $builder->sidebarAppendItem($module, $action['name'], $link); 98 | $filename = $dir.DIRECTORY_SEPARATOR.$action['name'].'.md'; 99 | File::put($filename, $this->generateApiContent($api, $action)); 100 | } 101 | } 102 | 103 | } 104 | 105 | /** 106 | * This private function generates API content using the provided $api and $action arrays. 107 | * 108 | * @param array $api : This array contains relevant API data. 109 | * @param array $action : This array contains action data such as 'params','name','method','path',and so on. 110 | * @return string: The function returns a string, which contains the generated API content in Markdown format. 111 | */ 112 | private function generateApiContent(array $api, array $action): string 113 | { 114 | $params = collect($action['params'])->map(fn($param) => array_values($param))->toArray(); 115 | $path = $api['prefix'].$action['path']; 116 | $response = $enums = []; 117 | $typescriptExample = ''; 118 | $this->buildResponseTable($action['response'], $response, $enums, 0); 119 | if (!empty($response)) { 120 | $typescriptExample = (new TypeScriptExampleGenerator())->convert($action['response'], 'Result'); 121 | } 122 | $builder = (new MarkdownHelper()) 123 | ->meta(['outline' => 'deep']) 124 | ->h1($action['name'].' API') 125 | ->table(['Path', 'Method', 'Created At'], 126 | [['/api' . $api['prefix'].$action['path'], $action['method'], Carbon::now()]]) 127 | ->h2('Request') 128 | ->table(['Key', 'Rule', 'Description'], $params) 129 | ->h2('Response') 130 | ->table(['Key', 'Type', 'Example', 'Comment'], $response); 131 | if (!empty($typescriptExample)) { 132 | $builder->p('TypeScript Result Example:') 133 | ->code($typescriptExample, 'TypeScript'); 134 | } 135 | 136 | if (empty($enums)) { 137 | return $builder->build(); 138 | } 139 | $builder->h2('Enums'); 140 | foreach ($enums as $name => $enum) { 141 | $rows = []; 142 | foreach ($enum as $field => $description) { 143 | $rows[] = [$field, $description]; 144 | } 145 | $builder->h3($name)->table(['Const', 'Description'], $rows); 146 | } 147 | 148 | return $builder->build(); 149 | } 150 | 151 | private function buildResponseTable(array $fields, array &$rows, array &$enums, int $level = 0): void 152 | { 153 | foreach ($fields as $key => $field) { 154 | if ($field['type'] === 'enum') { 155 | $tokens = explode('\\', $field['value']); 156 | $field['value'] = array_pop($tokens); 157 | $enums[$field['value']] = $field['enums'] ?? []; 158 | } 159 | $key = str_repeat(' -> ', $level).$key; 160 | if (is_array($field['value'])) { 161 | $rows[] = [$key, $field['type'], '', $field['comment']]; 162 | $this->buildResponseTable($field['value'], $rows, $enums, $level + 1); 163 | continue; 164 | } 165 | $rows[] = [$key, $field['type'], $field['value'], $field['comment']]; 166 | } 167 | } 168 | 169 | /** 170 | * Creates a directory from a class namespace. 171 | * 172 | * This function takes a namespace and a base directory as parameters. 173 | * It determines a relative path based on the provided namespace, 174 | * replacing namespace separators with directory separators. 175 | * If the resulting directory does not exist, it creates it. 176 | * 177 | * @param string $namespace Namespace from which to create directory. 178 | * @param string $baseDirectory Base directory where new directory will be created. 179 | */ 180 | public function createDirectoryFromClassNamespace(string $namespace, string $baseDirectory): string 181 | { 182 | $baseNamespace = 'App\Http\Controllers\\'; 183 | $relativeNamespace = Str::after($namespace, $baseNamespace); 184 | if (empty($relativeNamespace)) { 185 | return $baseDirectory; 186 | } 187 | $relativeNamespace = Str::before($relativeNamespace, 'Controller'); 188 | $relativeDirectory = Str::replace('\\', DIRECTORY_SEPARATOR, $relativeNamespace); 189 | $directory = $baseDirectory.'/'.$relativeDirectory; 190 | if (!File::exists($directory)) { 191 | File::makeDirectory($directory, 0775, true); 192 | } 193 | 194 | return $directory; 195 | } 196 | 197 | /** 198 | * The `deleteMdFilesExceptIndex` function is used to delete all markdown files in the 'docs' 199 | * directory of the public path, except for 'index.md'. 200 | */ 201 | public function deleteMdFilesExceptIndex(string $directory): void 202 | { 203 | $files = File::files($directory); 204 | foreach ($files as $file) { 205 | if ($file->getExtension() == 'md' && $file->getFilename() != 'index.md') { 206 | File::delete($file->getPathname()); 207 | } 208 | } 209 | } 210 | 211 | /** 212 | * Fetches and processes information about the API defined by controller files. 213 | * 214 | * @return array Collection of controller related details, including namespace and actions. 215 | * 216 | * @throws ReflectionException 217 | */ 218 | private function scanApiInfo(): array 219 | { 220 | $controllerDir = app()->path('Http/Controllers'); 221 | $files = ControllerHelper::scanForFiles($controllerDir); 222 | $controllerInfo = []; 223 | foreach ($files as $file) { 224 | $info = $this->getControllerInfo($file); 225 | if ($info['isAbstract']) { 226 | continue; 227 | } 228 | $info['actions'] = $this->getActionsInfo($info['namespace']); 229 | // 读取方法注解 230 | $controllerInfo[] = $info; 231 | } 232 | 233 | return $controllerInfo; 234 | } 235 | 236 | /** 237 | * Get information about a controller. 238 | * 239 | * @param string $file The path of the controller file. 240 | * @return array The controller information. 241 | * 242 | * @throws ReflectionException If an error occurs during the reflection process. 243 | */ 244 | private function getControllerInfo(string $file): array 245 | { 246 | $info['path'] = $file; 247 | $ns = ControllerHelper::convertPathToNamespace($file); 248 | $info['namespace'] = $ns; 249 | // 读取类注解 250 | $reflectClazz = new ReflectionClass($ns); 251 | $clazzAttributes = collect($reflectClazz->getAttributes()); 252 | $clazzAlias = $clazzAttributes 253 | ->filter(fn(ReflectionAttribute $attribute) => $attribute->getName() === Alias::class)->first(); 254 | $info['alias'] = $clazzAlias?->newInstance()->value; 255 | $clazzPrefix = $clazzAttributes 256 | ->filter(fn(ReflectionAttribute $attribute) => $attribute->getName() === Prefix::class)->first(); 257 | $info['prefix'] = $clazzPrefix?->newInstance()->path; 258 | $info['module'] = $clazzPrefix?->newInstance()->module; 259 | $info['isAbstract'] = $reflectClazz->isAbstract(); 260 | 261 | return $info; 262 | } 263 | 264 | /** 265 | * Get information about the public methods of a given class. 266 | * 267 | * @param string $ns The namespace of the class. 268 | * @return array An array containing information about the methods of the class. 269 | * 270 | * @throws ReflectionException if the given class does not exist. 271 | */ 272 | private function getActionsInfo(string $ns): array 273 | { 274 | $reflectClazz = new ReflectionClass($ns); 275 | $methods = collect($reflectClazz->getMethods(ReflectionMethod::IS_PUBLIC)) 276 | ->filter(fn(ReflectionMethod $method) => !$method->isConstructor())->toArray(); 277 | $infos = []; 278 | foreach ($methods as $method) { 279 | if ($method->isStatic() || !$method->isPublic()) { 280 | continue; 281 | } 282 | $infos[] = $this->getActionInfo(collect($method->getAttributes()), $method->getName()); 283 | } 284 | 285 | return $infos; 286 | } 287 | 288 | /** 289 | * This method retrieves the information associated with an action. 290 | * 291 | * @param Collection $attributes The attributes related to the action. 292 | * @param string $methodName The name of the specific method being operated on. 293 | * @return ?array The information related to the action, or null if the route is not found. 294 | */ 295 | private function getActionInfo(Collection $attributes, string $methodName): ?array 296 | { 297 | [$info['method'], $info['path']] = $this->getActionRoute($attributes); 298 | if ($info['path'] === null) { 299 | return null; 300 | } 301 | $info['name'] = $this->getActionAlias($attributes) ?? $methodName; 302 | $info['response'] = $this->getActionResponse($attributes); 303 | $info['params'] = $this->getActionParameters($attributes); 304 | 305 | return $info; 306 | } 307 | 308 | /** 309 | * Handles the extraction of method parameters from a given collection of attributes. 310 | * 311 | * @param Collection $attributes - A collection of ReflectionAttribute instances. 312 | * @return array - An array consisting of method parameters as associative arrays. Each parameter array 313 | * contains 'key', 'rules', and 'message' derived from an instance of 'Param' class. 314 | */ 315 | private function getActionParameters(Collection $attributes): array 316 | { 317 | $paramAttributes = $attributes->filter( 318 | fn(ReflectionAttribute $attribute) => $attribute->getName() === Param::class 319 | ); 320 | $params = []; 321 | foreach ($paramAttributes as $paramAttribute) { 322 | $paramInstance = $paramAttribute->newInstance(); 323 | $params[] = [ 324 | 'key' => $paramInstance->key, 325 | // Because the | symbol conflicts with the table syntax in the final generated Markdown. 326 | 'rule' => Str::replace('|', ',', $paramInstance->rules), 327 | 'message' => $paramInstance->message, 328 | ]; 329 | } 330 | 331 | return $params; 332 | } 333 | 334 | /** 335 | * Gets the return type of method based on the provided attributes collection. 336 | * 337 | * @param Collection $attributes A collection of ReflectionAttribute objects representing the attributes of the method. 338 | * @return array The return type of the method, or null if no return type is found. 339 | */ 340 | private function getActionResponse(Collection $attributes): array 341 | { 342 | $attribute = $attributes->filter( 343 | fn(ReflectionAttribute $attribute) => $attribute->getName() === Response::class 344 | )->first(); 345 | if (is_null($attribute)) { 346 | return []; 347 | } 348 | 349 | /** @var ReflectionAttribute $attribute */ 350 | $mock = $attribute->newInstance()->clazz; 351 | 352 | return (new $mock())->getMockData(); 353 | } 354 | 355 | /** 356 | * Get the alias of a method based on its attributes. 357 | * 358 | * @param Collection $attributes A collection of ReflectionAttribute objects representing the method's attributes. 359 | * @return string|null The alias of the method, or null if no Alias attribute is found. 360 | */ 361 | private function getActionAlias(Collection $attributes): ?string 362 | { 363 | $methodAlias = $attributes->filter( 364 | fn(ReflectionAttribute $attribute) => $attribute->getName() === Alias::class 365 | )->first(); 366 | 367 | /** @var ReflectionAttribute $methodAlias */ 368 | return $methodAlias?->newInstance()->value; 369 | } 370 | 371 | /** 372 | * Get the route path from the given collection of attributes. 373 | * 374 | * @param Collection $attributes The collection of attributes. 375 | * @return array The route path if found, null otherwise. 376 | */ 377 | private function getActionRoute(Collection $attributes): array 378 | { 379 | $routeAttribute = $attributes->filter( 380 | fn(ReflectionAttribute $attribute) => in_array($attribute->getName(), array_keys(self::ROUTE_ATTRIBUTES)) 381 | )->first(); 382 | 383 | /** @var ReflectionAttribute $routeAttribute */ 384 | $path = $routeAttribute?->newInstance()->path; 385 | if ($path === null) { 386 | return [null, null]; 387 | } 388 | $method = self::ROUTE_ATTRIBUTES[$routeAttribute->getName()] ?? 'undefined'; 389 | 390 | return [$method, $path]; 391 | } 392 | 393 | /** 394 | * Get the path to the directory containing the documentation files. 395 | * 396 | * @return string The path to the documentation directory. 397 | */ 398 | private function getDocsDir(): string 399 | { 400 | return app()->basePath('docs'); 401 | } 402 | } --------------------------------------------------------------------------------