andydefer / laravel-actions
Action-oriented architecture for Laravel applications
Requires
- php: >=8.1
- andydefer/laravel-directive: ^1.0
- andydefer/laravel-logger: ^2.0
- andydefer/php-records: ^1.0
- inertiajs/inertia-laravel: ^2.0|^3.0
- laravel/framework: ^12.0|^13.0|^14.0|^15.0
- ramsey/uuid: ^4.7
Requires (Dev)
- laravel/pint: ^1.29
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
README
A lightweight, action-oriented architecture for Laravel applications with template method pattern and typed HTTP responses.
Introduction
Le problème
Les contrôleurs Laravel traditionnels souffrent de plusieurs défauts :
| Problème | Conséquence |
|---|---|
| God Controllers | Une classe gère trop de routes différentes |
| Type de retour vague | mixed ou array, pas de typage fort |
| Validation couplée | La requête est injectée directement |
| Testabilité réduite | Difficile de tester une action isolément |
| Réutilisabilité nulle | La logique est enfermée dans le contrôleur |
La solution : Laravel Actions
Laravel Actions est un package qui impose une architecture action-oriented où une action = une route = un type de retour.
// Une action = une route final class ShowUserAction extends AbstractAction { protected function handle(Recordable $request): JsonResponse { $user = User::find($request->id); return $this->json(UserData::fromModel($user)); } } // La route est simple et explicite Route::get('/users/{id}', function ($id, ShowUserRequest $request, ShowUserAction $action) { return $action->run($request->toRecord(id: (int) $id)); });
Installation
composer require andydefer/laravel-actions
Le package s'enregistre automatiquement via Laravel.
Prérequis
- PHP 8.1 ou supérieur
- Laravel 10.x, 11.x ou 12.x
- Dépendances automatiques :
andydefer/php-records(structures typées)andydefer/laravel-directive(CLI)inertiajs/inertia-laravel(optionnel pour les vues Inertia)
Publication de la configuration (optionnel)
php artisan vendor:publish --tag=actions-config
Configuration
// config/actions.php return [ 'namespace' => 'App\\Actions', 'request_namespace' => 'App\\Http\\Requests', 'data_namespace' => 'App\\Data', 'record_namespace' => 'App\\Records', ];
Concepts fondamentaux
L'Action
Une Action est une classe qui encapsule la logique d'une seule route HTTP.
┌─────────────────────────────────────────────────────────────────────┐
│ ACTION LIFECYCLE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ run(Recordable $request) │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ before() │ ← Hook optionnel (prétraitement, auth, logs) │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ handle() │ ← Logique métier (OBLIGATOIRE) │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ after() │ ← Hook optionnel (nettoyage, notifications) │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ Retourne une réponse HTTP (JsonResponse|InertiaResponse|etc.) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Une Action = Une Route
// ✅ BON - Action dédiée à une route final class ListUsersAction extends AbstractAction { ... } // GET /users final class ShowUserAction extends AbstractAction { ... } // GET /users/{id} final class CreateUserAction extends AbstractAction { ... } // POST /users // ❌ MAUVAIS - Action réutilisée pour plusieurs routes final class UserAction extends AbstractAction { public function list() { ... } // GET /users public function show() { ... } // GET /users/{id} }
Une Action = Un Type de Retour
// ✅ BON - Type de retour unique final class ApiAction extends AbstractAction { protected function handle(Recordable $request): JsonResponse { ... } } final class WebAction extends AbstractAction { protected function handle(Recordable $request): InertiaResponse { ... } } // ❌ MAUVAIS - Union type (interdit) final class FlexibleAction extends AbstractAction { protected function handle(Recordable $request): JsonResponse|InertiaResponse { ... } }
Le Record
Le Record est un DTO typé qui contient TOUTES les données de la requête :
- Paramètres d'URL
- Données du formulaire
- Paramètres query string
- Utilisateur authentifié
- Métadonnées
// Un Record est une classe simple avec des propriétés publiques typées final class ShowUserRecord extends AbstractRecord { public function __construct( public readonly int $id, public readonly bool $includePosts = false, public readonly ?string $search = null, ) {} }
La Data (DTO de réponse)
La Data est un DTO utilisé exclusivement pour les réponses JSON.
final class UserData extends AbstractData { public function __construct( public readonly string $id, public readonly string $name, public readonly string $email, public readonly ?string $avatar = null, ) {} }
Créer votre première Action
1. Créer le Record
<?php // app/Records/ShowUserRecord.php declare(strict_types=1); namespace App\Records; use AndyDefer\Records\AbstractRecord; final class ShowUserRecord extends AbstractRecord { public function __construct( public readonly int $id, public readonly bool $includePosts = false, ) {} }
2. Créer la Data (DTO de réponse)
<?php // app/Data/UserData.php declare(strict_types=1); namespace App\Data; use AndyDefer\Actions\Data\AbstractData; final class UserData extends AbstractData { public function __construct( public readonly string $id, public readonly string $name, public readonly string $email, ) {} public static function fromModel(User $user): self { return new self( id: (string) $user->id, name: $user->name, email: $user->email, ); } }
3. Créer la Request
<?php // app/Http/Requests/Api/Users/ShowUserRequest.php declare(strict_types=1); namespace App\Http\Requests\Api\Users; use AndyDefer\Actions\Http\Requests\AbstractRequest; use AndyDefer\Actions\Contracts\Recordable; use App\Records\ShowUserRecord; final class ShowUserRequest extends AbstractRequest { public function rules(): array { return [ 'include_posts' => ['sometimes', 'boolean'], ]; } public function toRecord(): Recordable { return new ShowUserRecord( id: (int) $this->route('userId'), includePosts: $this->boolean('include_posts'), ); } }
4. Créer l'Action
<?php // app/Actions/Api/Users/ShowUserAction.php declare(strict_types=1); namespace App\Actions\Api\Users; use AndyDefer\Actions\Actions\AbstractAction; use AndyDefer\Records\Recordable; use App\Data\UserData; use App\Records\ShowUserRecord; use App\Models\User; use Illuminate\Http\JsonResponse; final class ShowUserAction extends AbstractAction { protected function before(Recordable $request): void { /** @var ShowUserRecord $request */ if ($request->id === 0) { throw new \InvalidArgumentException('Invalid user ID'); } } protected function handle(Recordable $request): JsonResponse { /** @var ShowUserRecord $request */ $user = User::findOrFail($request->id); $userData = UserData::fromModel($user); return $this->json($userData); } protected function after(bool $success, ?Exception $error = null, Recordable $request = new EmptyRecord()): void { if ($success) { Log::info('User shown successfully'); } else { Log::error('Failed to show user', ['error' => $error?->getMessage()]); } } }
5. Définir la route
// routes/api.php use App\Actions\Api\Users\ShowUserAction; use App\Http\Requests\Api\Users\ShowUserRequest; Route::get('/users/{userId}', function ($userId, ShowUserRequest $request, ShowUserAction $action) { return $action->run($request->toRecord()); });
Le cycle de vie d'une Action
Template Method Pattern
AbstractAction utilise le pattern Template Method pour définir le cycle de vie :
run(Recordable $request)
├── before($request) ← Hook optionnel
├── handle($request) ← Logique métier (obligatoire)
└── after(true, null, $request) ← Hook optionnel
Hooks disponibles
final class MyAction extends AbstractAction { /** * Hook appelé AVANT l'exécution. * * Utilisation : * - Vérifications d'authentification * - Validation d'autorisation * - Pré-traitement des données * - Logging */ protected function before(Recordable $request): void { /** @var MyRecord $request */ if (!$this->hasLaravel()) { throw new \RuntimeException('Laravel not available'); } $user = $this->getLaravel()->make('auth')->user(); if (!$user->can('view', $request->resourceId)) { abort(403); } } /** * Logique métier de l'action (OBLIGATOIRE). * * Doit retourner un type unique (JsonResponse, InertiaResponse, RedirectResponse...) */ protected function handle(Recordable $request): JsonResponse { /** @var MyRecord $request */ $result = $this->service->execute($request); return $this->json(MyData::fromRecord($result)); } /** * Hook appelé APRÈS l'exécution. * * Utilisation : * - Nettoyage * - Post-traitement * - Notifications * - Métriques */ protected function after(bool $success, ?Exception $error = null, Recordable $request = new EmptyRecord()): void { if ($success) { Log::info('Action completed successfully'); } else { Log::error('Action failed', ['error' => $error?->getMessage()]); $this->notifyAdmin($error); } } }
Les types de réponses (SendsHttpResponses)
Le trait SendsHttpResponses fournit toutes les méthodes de réponse HTTP.
| Méthode | Description | Retour |
|---|---|---|
json(DataInterface $data, int $code = 200) |
Réponse JSON pour API | JsonResponse |
redirect(string $url, int $code = 302) |
Redirection HTTP | RedirectResponse |
redirectRoute(string $route, array $parameters = [], int $code = 302) |
Redirection vers route nommée | RedirectResponse |
redirectBack(int $code = 302) |
Redirection vers page précédente | RedirectResponse |
stream(callable $callback, string $contentType, int $code = 200) |
Streaming de données | StreamedResponse |
sse(callable $callback) |
Server-Sent Events | StreamedResponse |
noContent() |
Réponse 204 | Response |
inertia(string $component, array $props = []) |
Réponse Inertia.js | InertiaResponse |
html(string $html, int $code = 200) |
HTML brut | Response |
fileInline(string $filePath, ?string $fileName = null) |
Affichage de fichier | BinaryFileResponse |
fileDownload(string $filePath, ?string $fileName = null) |
Téléchargement | BinaryFileResponse |
text(string $content, int $code = 200) |
Texte brut | Response |
view(string $view, array $data = [], int $code = 200) |
Vue Blade | Response |
Exemples d'utilisation
// API - Réponse JSON final class ListUsersAction extends AbstractAction { protected function handle(Recordable $request): JsonResponse { $users = User::all(); $usersData = UserData::collect($users); return $this->json($usersData); } } // Web - Réponse Inertia final class ShowDashboardAction extends AbstractAction { protected function handle(Recordable $request): InertiaResponse { return $this->inertia('Dashboard/Index', [ 'user' => auth()->user(), ]); } } // Téléchargement de fichier final class DownloadReportAction extends AbstractAction { protected function handle(Recordable $request): BinaryFileResponse { $pdf = $this->generatePdf(); return $this->fileDownload($pdf, 'report.pdf'); } }
Le payload : passer des paramètres typés
Construction du Record dans la Request
final class CreateUserRequest extends AbstractRequest { public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:users'], 'role' => ['sometimes', 'string', 'in:admin,user'], ]; } public function toRecord(): Recordable { return new CreateUserRecord( name: $this->input('name'), email: $this->input('email'), role: $this->input('role', 'user'), ip: $this->ip(), userAgent: $this->userAgent(), ); } }
Le Record
final class CreateUserRecord extends AbstractRecord { public function __construct( public readonly string $name, public readonly string $email, public readonly string $role = 'user', public readonly ?string $ip = null, public readonly ?string $userAgent = null, ) {} }
Utilisation dans l'Action
final class CreateUserAction extends AbstractAction { protected function handle(Recordable $request): JsonResponse { /** @var CreateUserRecord $request */ $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'role' => $request->role, ]); Log::info("User created from IP: {$request->ip}"); return $this->json(UserData::fromModel($user), 201); } }
Enregistrer les routes
Syntaxe explicite (recommandée)
// routes/api.php use App\Actions\Api\Users\ListUsersAction; use App\Actions\Api\Users\ShowUserAction; use App\Actions\Api\Users\CreateUserAction; use App\Http\Requests\Api\Users\ListUsersRequest; use App\Http\Requests\Api\Users\ShowUserRequest; use App\Http\Requests\Api\Users\CreateUserRequest; // GET /api/users Route::get('/users', function (ListUsersRequest $request, ListUsersAction $action) { return $action->run($request->toRecord()); }); // GET /api/users/{userId} Route::get('/users/{userId}', function ($userId, ShowUserRequest $request, ShowUserAction $action) { return $action->run($request->toRecord()); }); // POST /api/users Route::post('/users', function (CreateUserRequest $request, CreateUserAction $action) { return $action->run($request->toRecord()); });
Avec paramètres d'URL multiples
Route::get('/users/{userId}/posts/{postId}', function ($userId, $postId, ShowUserPostRequest $request, ShowUserPostAction $action) { return $action->run($request->toRecord(userId: (int) $userId, postId: (int) $postId)); });
Routes web (Inertia)
// routes/web.php use App\Actions\Web\Dashboard\ShowDashboardAction; use App\Http\Requests\Web\Dashboard\ShowDashboardRequest; Route::get('/dashboard', function (ShowDashboardRequest $request, ShowDashboardAction $action) { return $action->run($request->toRecord()); });
Directive CLI pour générer les fichiers
Créer une Action
# Créer une Action API ./vendor/bin/directive make:action Users/ShowUserAction --type=api # Créer une Action Web ./vendor/bin/directive make:action Dashboard/ShowDashboardAction --type=web # Forcer l'écrasement ./vendor/bin/directive make:action Users/ShowUserAction --type=api --force
Ce que la directive génère
app/
├── Actions/
│ └── Users/
│ └── ShowUserAction.php
Stubs personnalisables
Les stubs se trouvent dans vendor/andydefer/laravel-actions/stubs/ :
| Stub | Utilisation |
|---|---|
action.api.stub |
Action API (JsonResponse) |
action.web.stub |
Action Web (InertiaResponse) |
Tests unitaires et d'intégration
Règle d'or
⚠️ Les Actions sont testées exclusivement via des tests d'intégration (Feature tests). Pas de tests unitaires pour les Actions.
<?php namespace Tests\Feature\Actions\Api\Users; use Tests\TestCase; use App\Models\User; final class ShowUserActionTest extends TestCase { public function test_show_user_returns_user_data(): void { $user = User::factory()->create([ 'name' => 'John Doe', 'email' => 'john@example.com', ]); $response = $this->actingAs($user) ->getJson("/api/users/{$user->id}"); $response->assertStatus(200); $response->assertJson([ 'id' => (string) $user->id, 'name' => 'John Doe', 'email' => 'john@example.com', ]); } public function test_show_user_returns_404_when_not_found(): void { $user = User::factory()->create(); $response = $this->actingAs($user) ->getJson('/api/users/99999'); $response->assertStatus(404); } }
Tester une Action avec des mocks
public function test_action_uses_service(): void { $service = $this->mock(UserService::class); $service->shouldReceive('getUser') ->once() ->with(123) ->andReturn($expectedUser); $response = $this->getJson('/api/users/123'); $response->assertStatus(200); }
Architecture technique
Diagramme d'architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ LARAVEL ACTIONS PACKAGE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ HTTP LAYER │ │
│ │ │ │
│ │ Route → Closure → Request → Record → Action → Response │ │
│ │ │ │
│ │ Route::get('/users/{id}', function ($id, Request $req, Action $act) │ │
│ │ return $act->run($req->toRecord()); │ │
│ │ }); │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ABSTRACTION LAYER │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │AbstractAction│ │AbstractRequest│ │ AbstractData │ │ │
│ │ │ - run() │ │ - toRecord() │ │ - toArray() │ │ │
│ │ │ - before() │ │ - rules() │ │ - collect() │ │ │
│ │ │ - handle() │ │ - authorize()│ │ │ │ │
│ │ │ - after() │ └─────────────┘ └─────────────┘ │ │
│ │ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ RESPONSE LAYER │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ SendsHttpResponses │ │ │
│ │ │ - json() - redirect() - inertia() │ │ │
│ │ │ - stream() - sse() - html() │ │ │
│ │ │ - fileInline() - fileDownload() - text() │ │ │
│ │ │ - view() - noContent() - redirectRoute() │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Composants
| Composant | Rôle |
|---|---|
AbstractAction |
Classe de base avec template method (before → handle → after) |
AbstractRequest |
Classe de base pour les Form Requests avec toRecord() |
SendsHttpResponses |
Trait avec toutes les méthodes de réponse HTTP |
AbstractData |
Base pour les DTO de réponse (JSON) |
MakeActionDirective |
Directive CLI pour générer les Actions |
API Reference
AbstractAction
| Méthode | Description |
|---|---|
final public run(Recordable $request): mixed |
Template method (à ne pas surcharger) |
protected before(Recordable $request): void |
Hook avant exécution |
abstract protected handle(Recordable $request): mixed |
Logique métier (obligatoire) |
protected after(bool $success, ?Exception $error, Recordable $request): void |
Hook après exécution |
public getRequest(): Recordable |
Récupère le Record de la requête |
AbstractRequest
| Méthode | Description |
|---|---|
abstract public toRecord(): Recordable |
Transforme la requête HTTP en Record |
public authorize(): bool |
Autorisation (défaut: true) |
public rules(): array |
Règles de validation |
SendsHttpResponses
| Méthode | Retour | Description |
|---|---|---|
json(DataInterface $data, int $code = 200) |
JsonResponse |
Réponse JSON pour API |
redirect(string $url, int $code = 302) |
RedirectResponse |
Redirection HTTP |
inertia(string $component, array $props = []) |
InertiaResponse |
Réponse Inertia.js |
html(string $html, int $code = 200) |
Response |
HTML brut |
view(string $view, array $data = [], int $code = 200) |
Response |
Vue Blade |
noContent() |
Response |
204 No Content |
text(string $content, int $code = 200) |
Response |
Texte brut |
fileDownload(string $filePath, ?string $fileName) |
BinaryFileResponse |
Téléchargement |
fileInline(string $filePath, ?string $fileName) |
BinaryFileResponse |
Affichage de fichier |
stream(callable $callback, string $contentType, int $code) |
StreamedResponse |
Streaming |
sse(callable $callback) |
StreamedResponse |
Server-Sent Events |
redirectRoute(string $route, array $params, int $code) |
RedirectResponse |
Redirection nommée |
redirectBack(int $code) |
RedirectResponse |
Retour page précédente |
MakeActionDirective (CLI)
| Option | Description | Défaut |
|---|---|---|
name |
Nom de l'Action (ex: Users/ShowUserAction) | Requis |
--type |
Type d'Action (api ou web) |
api |
--force |
Écrase les fichiers existants | false |
Bonnes pratiques
1. Une Action par route
// ✅ BON final class ListUsersAction extends AbstractAction { } final class ShowUserAction extends AbstractAction { } // ❌ MAUVAIS final class UserAction extends AbstractAction { public function list() { } public function show() { } }
2. Type de retour unique
// ✅ BON protected function handle(Recordable $request): JsonResponse { } // ❌ MAUVAIS protected function handle(Recordable $request): JsonResponse|InertiaResponse { }
3. Utiliser les hooks pour la maintenance
protected function before(Recordable $request): void { Log::info('Action started'); } protected function after(bool $success, ?Exception $error = null, Recordable $request = new EmptyRecord()): void { Log::info('Action finished', ['success' => $success]); }
4. Typer le Record dans l'Action
protected function handle(Recordable $request): JsonResponse { /** @var ShowUserRecord $request */ $user = User::find($request->id); return $this->json(UserData::fromModel($user)); }
5. Construire les Records complets dans la Request
public function toRecord(): Recordable { return new CreateUserRecord( name: $this->input('name'), email: $this->input('email'), ip: $this->ip(), // ← Métadonnées HTTP userAgent: $this->userAgent(), timestamp: now()->toIso8601ZuluString(), ); }
6. Tester via les requêtes HTTP
public function test_action_returns_correct_response(): void { $response = $this->getJson('/api/users/1'); $response->assertStatus(200); $response->assertJsonStructure(['id', 'name', 'email']); }
FAQ
Q: Pourquoi une Action par route ?
R: Pour respecter le principe de responsabilité unique (SRP). Chaque route a sa propre logique, modification sans impacter les autres.
Q: Pourquoi l'Action ne reçoit pas directement la Request ?
R: Pour découpler l'Action de Laravel. Une Action reçoit un Record (simple DTO), ce qui la rend :
- Testable sans Laravel
- Réutilisable dans d'autres contextes (console, jobs)
- Avec un contrat explicite
Q: Comment gérer l'authentification ?
R: Utilisez le hook before() :
protected function before(Recordable $request): void { if (!auth()->check()) { abort(401); } if (!auth()->user()->can('view', $request->id)) { abort(403); } }
Q: Comment gérer les erreurs de validation ?
R: Laravel gère automatiquement les erreurs de validation via le Form Request. Le client recevra une réponse 422.
Q: Peut-on utiliser ce package sans Inertia ?
R: Oui, Inertia est optionnel. Utilisez view() ou html() pour les réponses web classiques.
Q: Comment générer rapidement une Action ?
R: Utilisez la directive CLI :
./vendor/bin/directive make:action Users/ShowUserAction --type=api
Q: Les tests des Actions doivent être en unitaire ou intégration ?
R: Toujours en intégration (Feature tests) car les Actions retournent des réponses HTTP complètes.
Q: Où placer la logique métier complexe ?
R: Déléguez à des Services, Tasks ou Workers. L'Action ne doit que orchestrer.
protected function handle(Recordable $request): JsonResponse { $this->authorize($request); $result = $this->service->execute($request); $this->logger->info('Action completed'); return $this->json(ResultData::fromRecord($result)); }
Licence
MIT © Andy Defer