├── resources └── views │ └── .gitkeep ├── src ├── ModelActionFacade.php ├── ModelActionsServiceProvider.php ├── Concerns │ └── WithActions.php ├── ModelAction.php └── ImplicitlyBoundMethod.php ├── database ├── factories │ └── ModelFactory.php └── migrations │ └── create_skeleton_table.php.stub ├── CHANGELOG.md ├── config └── model-actions.php ├── LICENSE.md ├── composer.json └── README.md /resources/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ModelActionFacade.php: -------------------------------------------------------------------------------- 1 | id(); 13 | 14 | // add fields 15 | 16 | $table->timestamps(); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ModelActionsServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-model-actions') 19 | ->hasConfigFile(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Concerns/WithActions.php: -------------------------------------------------------------------------------- 1 | action($action, ...$input); 14 | } 15 | 16 | public function action($action = null, ...$input) 17 | { 18 | $key = md5(static::class . '.' . $action); 19 | 20 | $actionClass = static::$actionClassMap[$key] ??= new ModelAction( 21 | class: $this ?? static::class, 22 | namespace: config('model-actions.namespace'), 23 | ); 24 | 25 | return $action 26 | ? $actionClass->{$action}(...$input) 27 | : $actionClass; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/model-actions.php: -------------------------------------------------------------------------------- 1 | null, 25 | 26 | /** 27 | * You can overwrite the method used to handle the 28 | * action. By default this is __invoke. 29 | */ 30 | 'method' => '__invoke', 31 | ]; 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) uteq 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 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uteq/laravel-model-actions", 3 | "description": "Magically adds actions to a model", 4 | "keywords": [ 5 | "uteq", 6 | "laravel-model-actions" 7 | ], 8 | "homepage": "https://github.com/uteq/laravel-model-actions", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Nathan Jansen", 13 | "email": "info@nathanjansen.nl", 14 | "role": "Developer" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.0", 19 | "illuminate/contracts": "^8.0", 20 | "spatie/laravel-package-tools": "^1.1" 21 | }, 22 | "require-dev": { 23 | "orchestra/testbench": "^6.0", 24 | "phpunit/phpunit": "^9.3", 25 | "spatie/laravel-ray": "^1.9", 26 | "vimeo/psalm": "^4.4" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Uteq\\ModelActions\\": "src", 31 | "Uteq\\ModelActions\\Database\\Factories\\": "database/factories" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Uteq\\ModelActions\\Tests\\": "tests" 37 | } 38 | }, 39 | "scripts": { 40 | "psalm": "vendor/bin/psalm", 41 | "test": "vendor/bin/phpunit --colors=always", 42 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 43 | }, 44 | "config": { 45 | "sort-packages": true 46 | }, 47 | "extra": { 48 | "laravel": { 49 | "providers": [ 50 | "Uteq\\ModelActions\\ModelActionsServiceProvider" 51 | ], 52 | "aliases": { 53 | "ModelActions": "ModelActionFacade" 54 | } 55 | } 56 | }, 57 | "minimum-stability": "dev", 58 | "prefer-stable": true 59 | } 60 | -------------------------------------------------------------------------------- /src/ModelAction.php: -------------------------------------------------------------------------------- 1 | init(); 19 | } 20 | 21 | public function init() 22 | { 23 | $this->namespace = $this->getNamespace(); 24 | 25 | $this->filesystem ??= new Filesystem; 26 | 27 | $this->actions = collect($this->filesystem->allFiles($this->getPath())) 28 | ->mapWithKeys(fn (SplFileInfo $file) => [ 29 | lcfirst($file->getBasename('.php')) => $this->namespace . '\\' . $file->getBasename('.php'), 30 | ]) 31 | ->toArray(); 32 | } 33 | 34 | public function getNamespace(): string 35 | { 36 | if ($this->namespace) { 37 | $filename = Str::of($this->class::class)->afterLast('\\'); 38 | 39 | return $this->namespace . '\\' . $filename; 40 | } 41 | 42 | return str_replace('\\Models\\', '\\Actions\\', $this->class::class); 43 | } 44 | 45 | public function getPath(): string 46 | { 47 | $class = new ReflectionClass(new ($this->class::class)); 48 | $rootPath = Str::beforeLast(Str::beforeLast($class->getFileName(), DIRECTORY_SEPARATOR), DIRECTORY_SEPARATOR); 49 | 50 | if (config('model-actions.namespace')) { 51 | return $this->generatePathFromNamespace($this->namespace); 52 | } 53 | 54 | return realpath($rootPath . DIRECTORY_SEPARATOR . '/Actions'); 55 | } 56 | 57 | public static function generatePathFromNamespace($namespace) 58 | { 59 | $path = config('model-actions.path', app('path')); 60 | $name = Str::of($namespace)->finish('\\') 61 | ->replaceFirst(config('model-actions.app_namespace', app()->getNamespace()), ''); 62 | 63 | return $path . '/' . str_replace('\\', '/', $name); 64 | } 65 | 66 | public function getName(): string 67 | { 68 | return (new ReflectionClass($this->class))->getShortName(); 69 | } 70 | 71 | public function __call($method, $arguments) 72 | { 73 | if ($actionClass = $this->resolveActionClass($method)) { 74 | $actionMethod = config('model-actions.method') ?: '__invoke'; 75 | $action = app()->make($actionClass); 76 | 77 | if ($this->class->getKey()) { 78 | $arguments['model'] = $this->class; 79 | } 80 | 81 | return ImplicitlyBoundMethod::call(app(), [$action, $actionMethod], $arguments); 82 | } 83 | 84 | $class = $this->namespace . '\\' . ucfirst($method); 85 | 86 | throw new \Exception(sprintf('Class not found `%s`', $class), 500); 87 | } 88 | 89 | public function resolveActionClass($method) 90 | { 91 | if (isset($this->actions[$method])) { 92 | return $this->actions[$method]; 93 | } 94 | 95 | if (in_array($method, $this->actions)) { 96 | return $method; 97 | } 98 | 99 | return null; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/ImplicitlyBoundMethod.php: -------------------------------------------------------------------------------- 1 | getParameters() as $parameter) { 18 | static::substituteNameBindingForCallParameter($parameter, $parameters, $paramIndex); 19 | static::substituteImplicitBindingForCallParameter($container, $parameter, $parameters); 20 | static::addDependencyForCallParameter($container, $parameter, $parameters, $dependencies); 21 | } 22 | 23 | return array_values(array_merge($dependencies, $parameters)); 24 | } 25 | 26 | protected static function substituteNameBindingForCallParameter($parameter, array &$parameters, int &$paramIndex) 27 | { 28 | // check if we have a candidate for name/value binding 29 | if (! array_key_exists($paramIndex, $parameters)) { 30 | return; 31 | } 32 | 33 | if ($parameter->isVariadic()) { 34 | // this last param will pick up the rest - reindex any remaining parameters 35 | $parameters = array_merge( 36 | array_filter($parameters, function ($key) { 37 | return ! is_int($key); 38 | }, ARRAY_FILTER_USE_KEY), 39 | array_values(array_filter($parameters, function ($key) { 40 | return is_int($key); 41 | }, ARRAY_FILTER_USE_KEY)) 42 | ); 43 | 44 | return; 45 | } 46 | 47 | // stop if this one is due for dependency injection 48 | if (! is_null($className = static::getClassForDependencyInjection($parameter)) && ! $parameters[$paramIndex] instanceof $className) { 49 | return; 50 | } 51 | 52 | if (! array_key_exists($paramName = $parameter->getName(), $parameters)) { 53 | // have a parameter value that is bound by sequential order 54 | // and not yet bound by name, so bind it to parameter name 55 | 56 | $parameters[$paramName] = $parameters[$paramIndex]; 57 | unset($parameters[$paramIndex]); 58 | $paramIndex++; 59 | } 60 | } 61 | 62 | protected static function substituteImplicitBindingForCallParameter($container, $parameter, array &$parameters) 63 | { 64 | $paramName = $parameter->getName(); 65 | 66 | // check if we have a candidate for implicit binding 67 | if (is_null($className = static::getClassForImplicitBinding($parameter))) { 68 | return; 69 | } 70 | 71 | // Check if the value we have for this param is an instance 72 | // of the desired class, attempt implicit binding if not 73 | if (array_key_exists($paramName, $parameters) && ! $parameters[$paramName] instanceof $className) { 74 | $parameters[$paramName] = static::getImplicitBinding($container, $className, $parameters[$paramName]); 75 | } elseif (array_key_exists($className, $parameters) && ! $parameters[$className] instanceof $className) { 76 | $parameters[$className] = static::getImplicitBinding($container, $className, $parameters[$className]); 77 | } 78 | } 79 | 80 | protected static function getClassForDependencyInjection($parameter) 81 | { 82 | if (! is_null($className = static::getParameterClassName($parameter)) && ! static::implementsInterface($parameter)) { 83 | return $className; 84 | } 85 | } 86 | 87 | protected static function getClassForImplicitBinding($parameter) 88 | { 89 | if (! is_null($className = static::getParameterClassName($parameter)) && static::implementsInterface($parameter)) { 90 | return $className; 91 | } 92 | 93 | return null; 94 | } 95 | 96 | protected static function getImplicitBinding($container, $className, $value) 97 | { 98 | $model = $container->make($className); 99 | 100 | if (! $model) { 101 | throw (new ModelNotFoundException)->setModel($className, [$value]); 102 | } 103 | 104 | return $model; 105 | } 106 | 107 | public static function getParameterClassName($parameter) 108 | { 109 | $type = $parameter->getType(); 110 | 111 | return ($type && ! $type->isBuiltin()) ? $type->getName() : null; 112 | } 113 | 114 | public static function implementsInterface($parameter) 115 | { 116 | return (new ReflectionClass($parameter->getType()->getName()))->implementsInterface(ImplicitlyBindable::class); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magically adds actions to a model 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/uteq/laravel-model-actions.svg?style=flat-square)](https://packagist.org/packages/uteq/laravel-model-actions) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/uteq/laravel-model-actions/run-tests?label=tests)](https://github.com/uteq/laravel-model-actions/actions?query=workflow%3ATests+branch%3Amaster) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/workflow/status/uteq/laravel-model-actions/Check%20&%20fix%20styling?label=code%20style)](https://github.com/uteq/laravel-model-actions/actions?query=workflow%3A"Check+%26+fix+styling"+branch%3Amaster) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/uteq/laravel-model-actions.svg?style=flat-square)](https://packagist.org/packages/uteq/laravel-model-actions) 7 | 8 | 9 | This package will magically add actions to a model. Simply adding the WithActions trait: 10 | 11 | Add the trait to your model 12 | ```php 13 | use Uteq\ModelActions\Concerns\WithActions; 14 | use Illuminate\Database\Eloquent\Model; 15 | 16 | class User extends Model 17 | { 18 | use WithActions; 19 | } 20 | ``` 21 | 22 | And than use it in all sorts of ways: 23 | ```php 24 | $user->action()->update($input); 25 | $user->action('update', $input); 26 | $user->action(\App\Actions\User\Update::class, $input); 27 | ``` 28 | 29 | Whenever you need to call a method on a model that does not (yet) have an active record in the database, you can also use this static method. 30 | ```php 31 | User::do(Create::class, $input); 32 | ``` 33 | 34 | This package was inspired by this read about OOP: https://www.tonysm.com/when-objects-are-not-enough/#objects-in-the-large 35 | Especially the last part about actions being added to a model made sense to me. 36 | This will keep your models clean, and your actions separated. 37 | 38 | The idea behind this package is that adding actions makes your application more scalable. 39 | 'Grouping' these actions by adding them to a model, makes it easier to comprehend where the action is for. It is more declarative having your model $user->action() perform an action than having the action out of the blue. To create more context you would have to always prefix your action UserCreateAction for example. 40 | 41 | Not convinced about using Actions in your application? Read this excellent blog post of Brent from Spatie.be https://stitcher.io/blog/laravel-beyond-crud-03-actions 42 | 43 | You will need php 8.0 44 | 45 | ## Installation 46 | 47 | You can install the package via composer: 48 | 49 | ```bash 50 | composer require uteq/laravel-model-actions 51 | ``` 52 | 53 | You can publish the config file with: 54 | ```bash 55 | php artisan vendor:publish --provider="Uteq\ModelActions\ModelActionsServiceProvider" --tag="laravel-model-actions-config" 56 | ``` 57 | 58 | This is the contents of the published config file: 59 | 60 | ```php 61 | return [ 62 | /** 63 | * You can overwrite the method used to handle the 64 | * action. By default this is __invoke. 65 | */ 66 | 'method' => '__invoke', 67 | ]; 68 | ``` 69 | ## Usage 70 | 71 | ### On your model 72 | Add the WithActions trait to the model 73 | ```php 74 | use Uteq\ModelActions\Concerns\WithActions; 75 | use Illuminate\Database\Eloquent\Model; 76 | 77 | class User extends Model 78 | { 79 | use WithActions; 80 | } 81 | ``` 82 | 83 | ### Directory 84 | 85 | The actions should be added in the following folder structure 86 | 87 | ``` 88 | App 89 | ├── Actions 90 | │ └── User 91 | │ ├── Create.php 92 | │ ├── Update.php 93 | │ ├── Destroy.php 94 | │ └── AddImage.php 95 | └── Models 96 | └── User.php 97 | ``` 98 | 99 | 100 | 101 | After that you can always access the actions from your model: 102 | 103 | This is how an action class looks like: 104 | 105 | ```php 106 | class Update 107 | { 108 | public function __invoke(User $model, array $input = []) 109 | { 110 | // Now add you own logic here 111 | } 112 | } 113 | ``` 114 | As you can see the $user will automatically be injected into the __invoke method. The system knows the user because you are calling in from the user. Please note that the model is always the first parameter. 115 | 116 | The name of the Action class will be used as the method name. 117 | So a class UpdateImage will be accessible using User::action()->updateImage($input); 118 | 119 | ```php 120 | $user->action(Update::class, $input); 121 | ``` 122 | 123 | ### Dependency injection in Actions 124 | Dependency injection in the __construct of the action is by default. 125 | So you can do this: 126 | 127 | ```php 128 | class Destroy 129 | { 130 | public function __construct( 131 | protected PublicDestroyer $destroyer, 132 | ) { } 133 | 134 | public __invoke(User $user, array $input = []) 135 | { 136 | ($this->destroyer)($user, $input); 137 | } 138 | } 139 | ``` 140 | 141 | ### Parameter binding 142 | Parameter binding for model actions is pretty straight forward. We have Named parameters (see below) and simply using the given order: 143 | 144 | ```php 145 | class Action 146 | { 147 | public function __invoke($var1, $var2) 148 | { 149 | 150 | } 151 | } 152 | ``` 153 | 154 | ```php 155 | User::do(Action::class, 'var1', 'var2'); 156 | ``` 157 | 158 | ### Named parameters 159 | Utilizing php 8's named paramters you are able to be very strict into what your action class accepts. 160 | 161 | ```php 162 | class Action 163 | { 164 | public function __invoke($name) 165 | { 166 | 167 | } 168 | } 169 | ``` 170 | 171 | 172 | ```php 173 | User::do(Action::class, name: 'test'); 174 | ``` 175 | 176 | ## Examples 177 | 178 | ### Apply with actions to all your models 179 | A convenient way to add the WithActions to all your Models is by simply extending the Eloquent Model class and extend upon that class. 180 | 181 | ```php 182 | namespace App\Support; 183 | 184 | use Uteq\ModelActions\Concerns\WithActions; 185 | 186 | class Model extends \Illuminate\Database\Eloquent\Model 187 | { 188 | use WithActions; 189 | } 190 | ``` 191 | 192 | ```php 193 | class ActionModel extends \App\Support\Model 194 | { 195 | 196 | } 197 | ``` 198 | 199 | 200 | ## Testing 201 | 202 | ```bash 203 | composer test 204 | ``` 205 | 206 | ## Changelog 207 | 208 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 209 | 210 | ## Contributing 211 | 212 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 213 | 214 | ## Security Vulnerabilities 215 | 216 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 217 | 218 | ## Credits 219 | 220 | - [Nathan Jansen](https://github.com/nathanjansen) 221 | - [All Contributors](../../contributors) 222 | 223 | ## License 224 | 225 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 226 | --------------------------------------------------------------------------------