laravel-actions maintained by andydefer
Laravel Actions
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