├── src ├── stubs │ └── action.stub ├── Contracts │ ├── Configurable.php │ └── ShouldInterpreter.php ├── Exceptions │ ├── PreventLoop.php │ ├── ActionNotRegistered.php │ ├── UnallowedActionDuplication.php │ ├── PreventDeferQueueSameTime.php │ └── DependencyUnresolvable.php ├── config.php ├── Configurable │ ├── RescuedUsing.php │ ├── DeferUsing.php │ ├── LoggedUsing.php │ └── QueueUsing.php ├── ValueObjects │ └── Then.php ├── Concerns │ ├── UsingThen.php │ ├── UsingRescue.php │ ├── UsingDefer.php │ ├── UsingQueue.php │ └── UsingLogged.php ├── Facades │ └── Fraction.php ├── helpers.php ├── Support │ ├── FractionName.php │ ├── Bootable.php │ └── DependencyResolver.php ├── FractionServiceProvider.php ├── Handlers │ ├── AsDefer.php │ ├── AsQueue.php │ ├── AsSync.php │ └── Concerns │ │ └── ShareableInterpreter.php ├── Jobs │ └── FractionJob.php ├── FractionManager.php ├── Console │ ├── MakeActionCommand.php │ └── UnregisteredActionsCommand.php └── FractionBuilder.php ├── LICENSE ├── README.md └── composer.json /src/stubs/action.stub: -------------------------------------------------------------------------------- 1 | base_path('app/Actions'), 15 | ]; 16 | -------------------------------------------------------------------------------- /src/Configurable/RescuedUsing.php: -------------------------------------------------------------------------------- 1 | $this->default, 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ValueObjects/Then.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private ?array $then = []; 18 | 19 | /** 20 | * Register a "then" hook. 21 | */ 22 | public function then(string|UnitEnum $action): self 23 | { 24 | $this->then[] = new Then($this->action, $action); 25 | 26 | return $this; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Concerns/UsingRescue.php: -------------------------------------------------------------------------------- 1 | rescued = new RescuedUsing($default); 24 | 25 | return $this; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Configurable/DeferUsing.php: -------------------------------------------------------------------------------- 1 | $this->name, 23 | 'always' => $this->always, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Facades/Fraction.php: -------------------------------------------------------------------------------- 1 | deferred = new DeferUsing($name, $always); 26 | 27 | return $this; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | message = $message ?? __('[:name] Action: [:action] executed.'); 16 | } 17 | 18 | /** {@inheritDoc} */ 19 | public function toArray(): array 20 | { 21 | return [ 22 | 'channel' => $this->channel, 23 | 'message' => $this->message, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Concerns/UsingQueue.php: -------------------------------------------------------------------------------- 1 | queued = new QueueUsing($delay, $queue, $connection); 27 | 28 | return $this; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Concerns/UsingLogged.php: -------------------------------------------------------------------------------- 1 | logged = new LoggedUsing($channel ?? config('logging.default')); 25 | 26 | return $this; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Configurable/QueueUsing.php: -------------------------------------------------------------------------------- 1 | $this->delay, 24 | 'queue' => $this->queue, 25 | 'connection' => $this->connection, 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Support/FractionName.php: -------------------------------------------------------------------------------- 1 | name 21 | : $action; 22 | 23 | if (mb_strlen($action) > 50) { 24 | throw new InvalidArgumentException('Fraction name cannot be longer than 50 characters.'); 25 | } 26 | 27 | return '__fraction.'.Str::of($action) 28 | ->lower() 29 | ->trim() 30 | ->snake() 31 | ->value(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Support/Bootable.php: -------------------------------------------------------------------------------- 1 | files(); 27 | 28 | if ($files === [] || $files === false) { 29 | return; 30 | } 31 | 32 | foreach ($files as $file) { 33 | require $file; 34 | } 35 | } 36 | 37 | /** 38 | * Get the files to be booted. 39 | */ 40 | private function files(): array|false 41 | { 42 | return glob(config('fraction.path').'/*.php'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/FractionServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton('fraction', fn (Application $app) => new FractionManager($app)); 16 | 17 | $this->mergeConfigFrom(__DIR__.'/config.php', 'fraction'); 18 | 19 | if ($this->app->runningInConsole()) { 20 | $this->commands([ 21 | Console\MakeActionCommand::class, 22 | Console\UnregisteredActionsCommand::class, 23 | ]); 24 | } 25 | } 26 | 27 | public function boot(): void 28 | { 29 | $this->publishes([ 30 | __DIR__.'/config.php' => config_path('fraction.php'), 31 | ], 'fraction-config'); 32 | 33 | Fraction::boot(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Handlers/AsDefer.php: -------------------------------------------------------------------------------- 1 | dependencies($container); 22 | 23 | \Illuminate\Support\defer( 24 | fn () => $dependencies->resolve($this->closure, $this->arguments), 25 | $this->defer->name, 26 | $this->defer->always, 27 | ); 28 | 29 | $this->hooks(); 30 | 31 | return true; 32 | } 33 | 34 | public function configure(array $data): void 35 | { 36 | $this->defer = new DeferUsing(...$data); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Handlers/AsQueue.php: -------------------------------------------------------------------------------- 1 | action, 25 | $this->arguments, 26 | $this->closure, 27 | $this->then 28 | ) 29 | ->delay($this->queue->delay) 30 | ->onQueue($this->queue->queue) 31 | ->onConnection($this->queue->connection); 32 | } 33 | 34 | public function configure(array $data): void 35 | { 36 | $this->queue = new QueueUsing(...$data); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 AJ Meireles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to _Fraction_ 🎯 2 | 3 | There's no denying that the "Action Pattern" in the Laravel ecosystem is extremely useful and widely used. However, action classes require "too much content" to do basic things. Let's review a basic action class: 4 | 5 | ```php 6 | namespace App\Actions; 7 | 8 | use App\Models\User; 9 | 10 | class CreateUser 11 | { 12 | public function handle(array $data): User 13 | { 14 | return User::create($data); 15 | } 16 | } 17 | ``` 18 | 19 | We have a namespace, class, method... **All of this to create a user?** It's overkill for such a simple task, isn't it? 20 | 21 | For this reason, the _Fraction_ solution is revolutionary in the context of Actions. _Fraction_ allows you to write actions in a _simpler and more direct way_, without the need for all this structure, **similar to what _PestPHP_ proposes.** 22 | 23 | --- 24 | 25 | ## Documentation 26 | 27 | Now that I've made you curious about what _Fraction_ does 😉 [check out the official website by clicking here](https://fractionforlaravel.com/). There you'll find a complete explanation of the package, as well as understanding what it really offers along with its many benefits. 28 | -------------------------------------------------------------------------------- /src/Handlers/AsSync.php: -------------------------------------------------------------------------------- 1 | dependencies($container)->resolve(...); 25 | 26 | $result = match (true) { 27 | $this->rescued !== null => rescue(fn () => $resolve($this->closure, $this->arguments), $this->rescued->default), 28 | default => $resolve($this->closure, $this->arguments), 29 | }; 30 | 31 | $this->hooks(); 32 | 33 | return $result; 34 | } 35 | 36 | public function configure(array $data): void 37 | { 38 | $this->rescued = new RescuedUsing(...$data); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Jobs/FractionJob.php: -------------------------------------------------------------------------------- 1 | make(DependencyResolver::class, [ 38 | 'action' => $this->action, 39 | ])->resolve($this->closure, $this->arguments); 40 | 41 | if ($this->then === []) { 42 | return; 43 | } 44 | 45 | /** @var Then $hook */ 46 | foreach ($this->then as $hook) { 47 | run($hook->then, ...$this->arguments); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Handlers/Concerns/ShareableInterpreter.php: -------------------------------------------------------------------------------- 1 | make(DependencyResolver::class, [ 33 | 'action' => $this->action, 34 | 'application' => $container, 35 | ]); 36 | } 37 | 38 | /** {@inheritDoc} */ 39 | final public function then(array $then): self 40 | { 41 | $this->then = $then; 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * Run the hooks after the action is handled. 48 | */ 49 | final public function hooks(): void 50 | { 51 | if ($this->then === []) { 52 | return; 53 | } 54 | 55 | /** @var Then $hook */ 56 | foreach ($this->then as $hook) { 57 | run($hook->then, ...$this->arguments); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/FractionManager.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | private array $fractions = []; 23 | 24 | public function __construct(public Application $application) 25 | { 26 | // 27 | } 28 | 29 | /** 30 | * Register a new action. 31 | * 32 | * @throws UnallowedActionDuplication 33 | */ 34 | public function register(string|UnitEnum $action, Closure $closure): FractionBuilder 35 | { 36 | $original = $action; 37 | 38 | $action = FractionName::format($action); 39 | 40 | if (isset($this->fractions[$action])) { 41 | throw new UnallowedActionDuplication($original); 42 | } 43 | 44 | $builder = new FractionBuilder($this->application, $original, $closure); 45 | 46 | $this->fractions[$action] = $builder; 47 | 48 | return $builder; 49 | } 50 | 51 | /** 52 | * Get the action by its name. 53 | * 54 | * @throws ActionNotRegistered 55 | */ 56 | public function get(string|UnitEnum $action): FractionBuilder 57 | { 58 | $original = $action; 59 | 60 | $action = FractionName::format($action); 61 | 62 | return $this->fractions[$action] ?? throw new ActionNotRegistered($original); 63 | } 64 | 65 | /** 66 | * Get all registered actions. 67 | */ 68 | public function all(): array 69 | { 70 | return $this->fractions; 71 | } 72 | 73 | /** 74 | * Bootstrap the actions. 75 | */ 76 | public function boot(): void 77 | { 78 | Bootable::fire(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fraction/fraction", 3 | "description": "New way to interact with the Laravel Action pattern.", 4 | "type": "library", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "Fraction\\": "src/" 9 | }, 10 | "files": [ 11 | "src/helpers.php" 12 | ] 13 | }, 14 | "autoload-dev": { 15 | "psr-4": { 16 | "Tests\\": "tests/" 17 | } 18 | }, 19 | "authors": [ 20 | { 21 | "name": "AJ Meireles", 22 | "email": "alvaro.meireles@live.com" 23 | } 24 | ], 25 | "require": { 26 | "php": "^8.2", 27 | "laravel/framework": "^11.0|^12.0" 28 | }, 29 | "require-dev": { 30 | "pestphp/pest": "^3.0", 31 | "pestphp/pest-plugin-type-coverage": "^3.0", 32 | "larastan/larastan": "^3.0", 33 | "laravel/pint": "^1.0", 34 | "orchestra/testbench": "^9.0", 35 | "rector/rector": "^2.0", 36 | "timacdonald/log-fake": "^2.4" 37 | }, 38 | "extra": { 39 | "laravel": { 40 | "providers": [ 41 | "Fraction\\FractionServiceProvider" 42 | ], 43 | "aliases": { 44 | "Fraction": "Fraction\\Facades\\Fraction" 45 | } 46 | } 47 | }, 48 | "scripts": { 49 | "coverage": [ 50 | "rm -fr coverage/", 51 | "./vendor/bin/pest --parallel --coverage --coverage-html=coverage" 52 | ], 53 | "test": [ 54 | "./vendor/bin/pest --parallel" 55 | ], 56 | "format": [ 57 | "./vendor/bin/pint" 58 | ], 59 | "analyse": [ 60 | "./vendor/bin/phpstan analyse --memory-limit=2G" 61 | ], 62 | "rector": [ 63 | "vendor/bin/rector process --dry-run" 64 | ], 65 | "ci": [ 66 | "./vendor/bin/pint --test", 67 | "./vendor/bin/phpstan analyse --memory-limit=2G", 68 | "./vendor/bin/pest --parallel --coverage --min=85" 69 | ] 70 | }, 71 | "minimum-stability": "dev", 72 | "prefer-stable": true, 73 | "config": { 74 | "allow-plugins": { 75 | "pestphp/pest-plugin": true 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Console/MakeActionCommand.php: -------------------------------------------------------------------------------- 1 | laravel['path'].'/Actions'; 50 | } 51 | 52 | $this->files->ensureDirectoryExists($path); 53 | 54 | $name = Str::of($name) 55 | ->afterLast('App\\') 56 | ->value(); 57 | 58 | return $path.'/'.str_replace('\\', '/', $name).'.php'; 59 | } 60 | 61 | // The reason why publishing this method is to ensure override the {{ name }} 62 | // placeholder in the stub file with the formatted name of the action 63 | protected function buildClass($name): string // @pest-ignore-type 64 | { 65 | $stub = $this->files->get($this->getStub()); 66 | 67 | $formatted = Str::of($this->getNameInput()) 68 | ->afterLast('\\') 69 | ->headline() 70 | ->lower() 71 | ->snake(' ') 72 | ->value(); 73 | 74 | return str_replace('{{ name }}', $formatted, $stub); 75 | } 76 | 77 | protected function promptForMissingArgumentsUsing(): array 78 | { 79 | return [ 80 | 'name' => [ 81 | 'What should the action be named?', 82 | 'E.g. CreateUserAction', 83 | ], 84 | ]; 85 | } 86 | 87 | protected function getArguments(): array 88 | { 89 | return [ 90 | ['name', InputArgument::REQUIRED, 'The name of the action'], 91 | ]; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Support/DependencyResolver.php: -------------------------------------------------------------------------------- 1 | getClosure() 36 | : $closure; 37 | 38 | $reflection = new ReflectionFunction($closure); 39 | 40 | $parameters = $reflection->getParameters(); 41 | $resolved = []; 42 | 43 | foreach ($parameters as $index => $parameter) { 44 | if (array_key_exists($index, $arguments)) { 45 | $resolved[] = $arguments[$index]; 46 | 47 | continue; 48 | } 49 | 50 | foreach ($parameter->getAttributes() as $attribute) { 51 | if (! str_contains($attribute->getName(), 'Illuminate\Container\Attributes')) { 52 | continue; 53 | } 54 | 55 | $instance = $attribute->newInstance(); 56 | 57 | if (! method_exists($instance, 'resolve')) { 58 | continue; 59 | } 60 | 61 | $resolved[] = $instance->resolve($instance, $this->application); 62 | } 63 | 64 | /** @var ReflectionParameter|ReflectionNamedType $type */ 65 | $type = $parameter->getType(); 66 | 67 | if ($type && (method_exists($type, 'isBuiltin') && ! $type->isBuiltin())) { 68 | $resolved[] = $this->application->make($type->getName()); 69 | } elseif ($parameter->isDefaultValueAvailable()) { 70 | $resolved[] = $parameter->getDefaultValue(); 71 | } else { 72 | throw new DependencyUnresolvable($parameter->getName(), $this->action); 73 | } 74 | } 75 | 76 | return call_user_func_array($closure, $resolved); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Console/UnregisteredActionsCommand.php: -------------------------------------------------------------------------------- 1 | mustRun(); 49 | 50 | return $this->output($process->getOutput()); 51 | } catch (ProcessFailedException) { 52 | $this->components->error('No unregistered actions found in the codebase.'); 53 | 54 | return self::SUCCESS; 55 | } catch (Exception $exception) { 56 | $this->components->error('Unexpected Error: '.$exception->getMessage()); 57 | 58 | return self::FAILURE; 59 | } 60 | } 61 | 62 | /** 63 | * Output the results of the search. 64 | */ 65 | private function output(string $output): int 66 | { 67 | if (blank($output)) { 68 | return self::SUCCESS; 69 | } 70 | 71 | $rows = []; 72 | 73 | $lines = collect(explode(PHP_EOL, $output))->filter(); 74 | 75 | if ($lines->count() === 0) { 76 | $this->components->info('No unregistered actions found.'); 77 | 78 | return self::SUCCESS; 79 | } 80 | 81 | $actions = collect(Fraction::all())->groupBy('action')->keys()->flip(); 82 | 83 | $lines->lazy()->each(function (string $line) use (&$rows, $actions): bool { 84 | preg_match("/^(\/[^\s:]+):\d+:\s*.*?run\(\s*'([^']+)'\s*\)/", $line, $matches); 85 | 86 | if (blank($line) || count($matches) < 3 || $actions->has($matches[2])) { 87 | return false; 88 | } 89 | 90 | $path = str($matches[0]) 91 | ->afterLast(base_path()) 92 | ->beforeLast(':') 93 | ->replaceFirst('/', '') 94 | ->value(); 95 | 96 | $rows[] = [$path, $matches[2]]; 97 | 98 | return true; 99 | }); 100 | 101 | if ($rows === []) { 102 | $this->components->info('No unregistered actions found.'); 103 | 104 | return self::SUCCESS; 105 | } else { 106 | $this->components->warn('Unregistered actions found:'); 107 | } 108 | 109 | table(['File', 'Unregistered Action'], $rows); 110 | 111 | return self::FAILURE; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/FractionBuilder.php: -------------------------------------------------------------------------------- 1 | action = $this->action instanceof UnitEnum 37 | ? $this->action->name 38 | : $this->action; 39 | } 40 | 41 | /** 42 | * Run the action. 43 | * 44 | * @throws BindingResolutionException|InvalidArgumentException|PreventDeferQueueSameTime 45 | */ 46 | public function __invoke(...$arguments): mixed 47 | { 48 | $arguments = $this->ignores($arguments); 49 | 50 | if ($this->queued !== null && $this->deferred !== null) { 51 | throw new PreventDeferQueueSameTime($this->action); 52 | } 53 | 54 | $interpret = match (true) { 55 | $this->queued instanceof QueueUsing => AsQueue::class, 56 | $this->deferred instanceof DeferUsing => AsDefer::class, 57 | default => AsSync::class, 58 | }; 59 | 60 | /** @var ShouldInterpreter|Configurable $interpreter */ 61 | $interpreter = $this->application->make($interpret, [ 62 | 'action' => $this->action, 63 | 'arguments' => $arguments, 64 | 'closure' => new SerializableClosure($this->closure), 65 | ]); 66 | 67 | if ([$has, $configuration] = $this->configuration()) { 68 | if ($has) { 69 | $interpreter->configure($configuration); 70 | } 71 | } 72 | 73 | $result = $interpreter->then($this->then ?? [])->handle($this->application); 74 | 75 | if ($this->logged !== null) { 76 | $this->application->make('log') 77 | ->channel($this->logged->channel) 78 | ->info(__($this->logged->message, [ 79 | 'name' => config('app.name'), 80 | 'action' => $this->action, 81 | ])); 82 | } 83 | 84 | return $result; 85 | } 86 | 87 | /** {@inheritDoc} */ 88 | public function toArray(): array 89 | { 90 | return [ 91 | 'action' => $this->action, 92 | 'closure' => $this->closure, 93 | 'then' => $this->then, 94 | 'queued' => $this->queued, 95 | 'deferred' => $this->deferred, 96 | 'rescued' => $this->rescued, 97 | ]; 98 | } 99 | 100 | /** 101 | * Determines which configurable should be applied. 102 | */ 103 | private function configuration(): array 104 | { 105 | foreach ([ 106 | $this->queued, 107 | $this->deferred, 108 | $this->rescued, 109 | ] as $instance) { 110 | if ($instance !== null) { 111 | return [true, $instance->toArray()]; 112 | } 113 | } 114 | 115 | return [false, []]; 116 | } 117 | 118 | /** 119 | * Determines if helpers usage should be ignored. 120 | */ 121 | private function ignores(array $arguments = []): array 122 | { 123 | if ($arguments === []) { 124 | return $arguments; 125 | } 126 | 127 | $helpers = ['queued', 'deferred', 'rescued', 'logged', 'then']; 128 | $unset = array_intersect($helpers, array_keys($arguments)); 129 | 130 | foreach ($unset as $helper) { 131 | if ($arguments[$helper] === false) { 132 | $this->{$helper} = null; 133 | } 134 | 135 | unset($arguments[$helper]); 136 | } 137 | 138 | return $arguments; 139 | } 140 | } 141 | --------------------------------------------------------------------------------