diff --git a/README.md b/README.md index fadcc7a..359ba80 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![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) +[![skills.sh](https://skills.sh/b/returnearly/actions-pattern)](https://skills.sh/returnearly/actions-pattern) A minimal package for using the actions pattern in Laravel. @@ -38,6 +39,14 @@ You can install the package via composer: composer require returnearly/actions-pattern ``` +### Agent skill + +This repository includes an [Agent Skill](https://agentskills.io) that teaches coding agents the actions/entrypoints pattern. Install it with: + +```bash +npx skills add returnearly/actions-pattern +``` + ## Action Class An action is any class that implements `ActionsPatternInterface` and uses the `ActionsPattern` trait: diff --git a/skills/actions-pattern/SKILL.md b/skills/actions-pattern/SKILL.md new file mode 100644 index 0000000..c6406b5 --- /dev/null +++ b/skills/actions-pattern/SKILL.md @@ -0,0 +1,152 @@ +--- +name: actions-pattern +description: >- + Use when creating or refactoring Laravel Actions, Controllers, Jobs, + Listeners, or Commands that should follow the actions/entrypoints pattern, + or when using returnearly/actions-pattern. +--- + +# Actions Pattern + +## Overview + +Separate business logic from entrypoints. An **Action** is a single-purpose class with one public `handle()`. **Entrypoints** (controllers, jobs, listeners, commands) adapt external input and call the action — they do not own business rules. + +## When to Use + +- Adding or changing business logic that may be reached from more than one entrypoint +- Thinning a fat controller, job, listener, or command +- Creating a new class with `php artisan make:action` +- Refactoring duplicated logic across HTTP, queue, CLI, or events + +## When Not to Use + +- Pure plumbing with no domain rules (simple redirects, health checks) +- One-liner wrappers that only call Eloquent with no decisions + +## Core Rules + +### Structure + +- **One public method:** only `handle()` is public +- **Private helpers** for supporting logic +- **Compose actions** by calling other actions (no pipeline/`->chain()` helper) + +### Invocation + +```php +// Static make() — resolves from the container +ProcessRefund::make()->handle($paymentId, $amount); + +// Constructor injection +public function __construct(private ProcessRefund $processRefund) {} +$this->processRefund->handle($paymentId, $amount); +``` + +### Typing and parameters + +- Explicit parameter and return types on `handle()` +- Prefer model instances over bare IDs when the caller already has the model +- Pass DTOs or primitives for request data — never the HTTP `Request` +- Actions must not know about HTTP requests, responses, or other entrypoint-specific I/O + +### Naming + +- Descriptive and specific: `ProcessRefund`, not `UpdatePayment` +- If naming is hard, the action is probably doing too much + +## Package Usage + +Install: `composer require returnearly/actions-pattern` + +Scaffold: `php artisan make:action ProcessRefund --test` + +Every action implements `ActionsPatternInterface` and uses the `ActionsPattern` trait (adds `make()`): + +```php +dependency->doSomething($amount + 10); + } +} +``` + +Constructor injection only for action dependencies. There is no method injection on `handle()` — pass its arguments explicitly. + +### Composing actions + +- **Constructor injection** when the collaborator is always needed (explicit, easy to fake in tests) +- **`OtherAction::make()->handle(...)`** for one-off calls + +## Entrypoint Checklist + +Entrypoints **do**: + +1. Receive input for that context +2. Validate (e.g. Form Requests) +3. Look up models from IDs +4. Call the action with typed arguments +5. Format the response for that context + +Entrypoints **do not**: + +1. Contain business logic +2. Make business decisions +3. Mutate models beyond loading them for the action + +| Concern | Belongs In | +|---------|------------| +| Business logic | Action | +| Validation | Entrypoint (Form Request) | +| Authorization | Entrypoint (Policy/Gate) | +| Model lookup | Entrypoint | +| Response formatting | Entrypoint | +| Logging business events | Action | +| Error handling (business) | Action | +| Error formatting (HTTP) | Entrypoint | + +## When to Create a New Action + +- A second public method would be needed on an existing action +- The same logic is duplicated across entrypoints +- Complex business logic is living on a model or a catch-all service class + +## Testing + +**Action tests:** full coverage of happy paths, failures, and edges; mock external APIs; test in isolation from entrypoints. + +**Entrypoint tests:** validation, authorization, model resolution; mock/spy the action; assert response/output shape. + +Adding a new entrypoint only needs: correct inputs prepared, action called with the right args, output formatted correctly. + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| Business rules in the controller | Move into an Action `handle()` | +| Passing `Request` into an action | Extract fields / DTO in the entrypoint | +| Multiple public methods on one action | Split into separate actions | +| Action builds JSON / HTTP responses | Return domain data; format in the entrypoint | +| Reaching into the container inside the action | Inject dependencies via the constructor | + +## Additional Resources + +- Full ProcessRefund walkthrough (persistence, composition, all entrypoints): [examples.md](examples.md) diff --git a/skills/actions-pattern/examples.md b/skills/actions-pattern/examples.md new file mode 100644 index 0000000..2caa29a --- /dev/null +++ b/skills/actions-pattern/examples.md @@ -0,0 +1,251 @@ +# ProcessRefund Walkthrough + +Organize a feature into three layers: + +1. **Entrypoint** — controller, queued job, Artisan command, or event listener. No business logic; gathers input and invokes an action. +2. **Action** — business logic. One public `handle()`, private helpers, dependencies via the constructor. +3. **Persistence** — Eloquent model and/or repository the action depends on. + +## Persistence + +```php +findOrFail($paymentId); + } + + public function create(Payment $payment, int $amount): Refund + { + return $payment->refunds()->create([ + 'amount' => $amount, + 'status' => 'pending', + ]); + } +} +``` + +## Action + +`ProcessRefund` holds the business rules. It receives the repository, a payment gateway, and another action through its constructor. + +```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']); + + $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 + +There is no pipeline helper — compose by calling another action. Constructor injection (above) when the collaborator is always needed; otherwise: + +```php +payment->customer->email) + ->send(new RefundProcessed($refund)); + } +} +``` + +## Entrypoints + +The same `ProcessRefund` powers HTTP, queue, events, and CLI. + +### Controller + +```php +processRefund->handle( + $paymentId, + $request->integer('amount'), + ); + + return response()->json($refund, JsonResponse::HTTP_CREATED); + } +} +``` + +### Queued job + +```php +handle($this->paymentId, $this->amount); + } +} +``` + +### Event listener + +```php +processRefund->handle( + $event->order->payment_id, + $event->order->total, + ); + } +} +``` + +### Artisan command + +```php +handle( + (int) $this->argument('payment'), + (int) $this->argument('amount'), + ); + + $this->info("Refund #{$refund->id} processed."); + + return self::SUCCESS; + } +} +```