From 8d2cfd7eed912838002f49d107fe88753e8ceee3 Mon Sep 17 00:00:00 2001 From: Tom Schlick Date: Tue, 30 Jun 2026 12:55:32 -0400 Subject: [PATCH] Expand README: requirements, actions-pattern synopsis, end-to-end example Document the new PHP 8.3+/Laravel 12+ minimums, add a synopsis of the actions pattern, and show an end-to-end Entrypoint -> Action -> Persistence example. Also fix the Action Class example to implement the interface/trait and correct the CI badges to point at master. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 327 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 309 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 88de49c..667f7de 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,34 @@ # Actions Pattern [![Latest Version on Packagist](https://img.shields.io/packagist/v/returnearly/actions-pattern.svg?style=flat-square)](https://packagist.org/packages/returnearly/actions-pattern) -[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/returnearly/actions-pattern/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/returnearly/actions-pattern/actions?query=workflow%3Arun-tests+branch%3Amain) -[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/returnearly/actions-pattern/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/returnearly/actions-pattern/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/returnearly/actions-pattern/run-tests.yml?branch=master&label=tests&style=flat-square)](https://github.com/returnearly/actions-pattern/actions?query=workflow%3Arun-tests+branch%3Amaster) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/returnearly/actions-pattern/fix-php-code-style-issues.yml?branch=master&label=code%20style&style=flat-square)](https://github.com/returnearly/actions-pattern/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amaster) [![Total Downloads](https://img.shields.io/packagist/dt/returnearly/actions-pattern.svg?style=flat-square)](https://packagist.org/packages/returnearly/actions-pattern) A minimal package for using the actions pattern in Laravel. +## What is the actions pattern? + +An **action** is a small, single-purpose class that encapsulates one unit of business logic — "process a refund", "register a user", "publish a post". Instead of scattering that logic across fat controllers, jobs, and listeners, you put it in one place and call it from anywhere. + +Every action follows the same shape: + +- It exposes **one** public entry point, `handle()` — the only method callers touch. +- Supporting logic lives in **private** methods, so the public surface stays small and the internals stay free to change. +- Its **dependencies arrive through the constructor** (repositories, services, even other actions). The action never reaches into the container itself, which keeps it trivial to test. +- Actions are **composed**, not wired into a pipeline: when one action needs another, it simply calls it. That is how you "chain" work together. + +The `handle()` signature is entirely up to you — accept whatever arguments make sense, return whatever the caller needs; the package does not constrain it. + +To turn a plain class into an action, implement `ActionsPatternInterface` and use the `ActionsPattern` trait. The trait adds a static `make()` factory that resolves the action from Laravel's container (so constructor dependencies are autowired). + +## Requirements + +- **PHP 8.3+** +- **Laravel 12+** + +The test suite runs against PHP 8.3, 8.4 and 8.5 and Laravel 12 and 13. + ## Installation You can install the package via composer: @@ -16,7 +38,9 @@ composer require returnearly/actions-pattern ``` ## Action Class - + +An action is any class that implements `ActionsPatternInterface` and uses the `ActionsPattern` trait: + ```php dependency->doSomething($amount); + $this->dependency->doSomething($amount + 10); } } - ``` ## Usage +Resolve and run an action in one call. `make()` pulls the instance from the container, so any constructor dependencies are autowired for you: + ```php -\App\Actions\MyCustomAction::make()->handle($item) +\App\Actions\MyCustomAction::make()->handle($item); ``` -or via dependency injection +Or inject it via the constructor and call `handle()` directly: ```php use App\Actions\MyCustomAction; -class MyController +final readonly class MyController { public function __construct( - private MyCustomAction $action - ){ + private MyCustomAction $action, + ) { } public function __invoke($item) @@ -64,16 +92,279 @@ class MyController $this->action->handle($item); } } +``` + +> Dependency injection is **constructor injection only**. Because `make()` resolves the action through the container, type-hinted constructor arguments are autowired. There is no method injection on `handle()` — pass its arguments explicitly. + +## A Complete Example + +Organize a feature into three clear layers: + +1. **Entrypoint** — the caller (controller, queued job, Artisan command, event listener). No business logic; it gathers input and invokes an action. +2. **Action** — the business logic. One public `handle()`, private helpers, dependencies injected through the constructor. +3. **Persistence** — where state lives: an Eloquent model and/or a repository the action depends on. +The example below processes a **payment refund** and reads as one story from top to bottom. + +### Persistence + +A repository wraps data access so the action depends on an abstraction rather than reaching for the database directly. It can use Eloquent models internally. + +```php +findOrFail($paymentId); + } + + public function create(Payment $payment, int $amount): Refund + { + return $payment->refunds()->create([ + 'amount' => $amount, + 'status' => 'pending', + ]); + } +} ``` -## Create An Action - -```bash +### Action + +`ProcessRefund` holds the business rules. It receives the repository, a payment gateway, and **another action** through its constructor; exposes a single public `handle()`; and breaks the work into private helper methods. + +```php +refunds->findPayment($paymentId); + + $this->guardAgainstOverRefund($payment, $amount); + + $refund = $this->refunds->create($payment, $amount); + + $this->gateway->refund($payment->charge_id, $amount); + + $refund->update(['status' => 'completed']); + + // Compose another action to handle the follow-up work. + $this->notifyCustomer->handle($refund); + + return $refund; + } + + private function guardAgainstOverRefund(Payment $payment, int $amount): void + { + if ($amount > $payment->refundableAmount()) { + throw new \DomainException('Refund exceeds the refundable amount.'); + } + } +} +``` + +### Composing actions ("chaining") + +There is no pipeline or `->chain()` helper — you compose actions by having one action call another. Above, `ProcessRefund` received `NotifyCustomerOfRefund` through its constructor and invoked `$this->notifyCustomer->handle($refund)`. + +Equivalently, an action can resolve the next one inline with `make()`: + +```php +payment->customer->email) + ->send(new RefundProcessed($refund)); + } +} +``` + +Use constructor injection when an action *always* needs its collaborator (it makes the dependency explicit and easy to fake in tests); reach for `OtherAction::make()->handle(...)` for a one-off call. + +### Entrypoints + +Entrypoints stay thin — they invoke the action and nothing more. The same `ProcessRefund` can be driven from any of them. + +**Controller** (constructor injection): + +```php +processRefund->handle( + $paymentId, + $request->integer('amount'), + ); + + return response()->json($refund, JsonResponse::HTTP_CREATED); + } +} +``` + +**Queued job** (resolve with `make()` inside `handle()`): + +```php +handle($this->paymentId, $this->amount); + } +} +``` + +**Event listener** (constructor injection): + +```php +processRefund->handle( + $event->order->payment_id, + $event->order->total, + ); + } +} +``` + +**Artisan command** (method injection on `handle()`): -php artisan make:action MyCustomAction --test +```php +handle( + (int) $this->argument('payment'), + (int) $this->argument('amount'), + ); + $this->info("Refund #{$refund->id} processed."); + + return self::SUCCESS; + } +} +``` + +The same `ProcessRefund` action now powers an HTTP endpoint, a background job, a domain event, and the CLI — with the business logic written exactly once. + +## Create An Action + +```bash +php artisan make:action ProcessRefund --test ``` + Running the above command will create a new action class in the `app/Actions` directory and the corresponding test in the `tests/Feature/Actions` directory. ## Testing