andydefer / laravel-task
A lightweight, file-based task system for Laravel with async execution, recurring tasks, and JSONL storage
Requires
- php: >=8.1
- andydefer/laravel-logger: ^2.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, file-based task system for Laravel with async execution, recurring tasks, and JSONL storage.
Introduction
Le problème
Laravel propose des solutions pour les tâches asynchrones :
- Queues : Nécessitent Redis/Beanstalkd/Database, configuration lourde
- Task Scheduling : Exécution via cron, pas de gestion des échecs intégrée
- Jobs : Lourds, difficilement testables unitairement
La solution : Laravel Task
Laravel Task est un système de tâches asynchrones et récurrentes basé sur des fichiers JSONL.
| Problème | Solution Laravel Task |
|---|---|
| Dépendance à Redis/Beanstalkd | Stockage JSONL - pas de base de données |
| Configuration complexe | Zéro configuration, prêt à l'emploi |
| Tests difficiles | Testable unitairement (pas de queue mock) |
| Pas de récurrence native | delaySeconds pour les tâches récurrentes |
| Pas de gestion des échecs | Retry automatique avec maxAttempts |
| Logs non structurés | Logging via laravel-logger |
Installation
composer require andydefer/laravel-task
Le package s'enregistre automatiquement via Laravel.
Prérequis
- PHP 8.1 ou supérieur
- Laravel 12.x, 13.x, 14.x ou 15.x
- Dépendances automatiques :
andydefer/php-records(structures typées)andydefer/laravel-directive(CLI)andydefer/laravel-logger(logging structuré)ramsey/uuid(identifiants uniques)
Publication de la configuration (optionnel)
php artisan vendor:publish --tag=task-config
Configuration
// config/task.php return [ // Chemin de stockage des tâches 'storage_path' => env('TASKS_STORAGE_PATH', storage_path('tasks')), // Valeurs par défaut 'defaults' => [ 'max_attempts' => 3, // Nombre de tentatives avant échec 'delay_seconds' => 300, // 5 minutes par défaut ], // Configuration du poller 'poller' => [ 'default_duration' => 60, // Durée par défaut (secondes) 'graceful_timeout' => 30, // Attente max avant kill 'use_sequential_mode' => env('TASKS_USE_SEQUENTIAL_MODE', true), 'lock_path' => env('TASKS_LOCK_PATH', null), ], // Période de grâce 'grace_period' => [ 'enabled' => env('TASKS_GRACE_PERIOD_ENABLED', true), 'seconds' => env('TASKS_GRACE_PERIOD_SECONDS', 86400), // 24 heures ], ];
Variables d'environnement
TASKS_STORAGE_PATH=/custom/tasks/path TASKS_USE_SEQUENTIAL_MODE=true TASKS_LOCK_PATH=/tmp/custom-lock.lock TASKS_GRACE_PERIOD_ENABLED=true TASKS_GRACE_PERIOD_SECONDS=86400
Concepts fondamentaux
Une tâche = un fichier JSONL
storage/tasks/
├── pending/ # Tâches uniques en attente
│ └── {uuid}.json
├── recurring/ # Tâches récurrentes (une par signature)
│ └── clear-unconfirmed-orders.json
├── completed/ # Archive par date
│ └── 2026-05-24/
│ └── {uuid}.json
└── grace_period/ # Traces des exécutions tardives
└── {uuid}.json
| Dossier | Contenu | Cycle de vie |
|---|---|---|
| pending/ | Tâches uniques | Création → Exécution → Archivage |
| recurring/ | Tâches récurrentes | Création → Exécution → Mise à jour |
| completed/ | Archive historique | Conservation pour audit |
| grace_period/ | Traces exécutions tardives | Audit des périodes de grâce |
Structure d'une tâche
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"signature": "clear-unconfirmed-orders",
"class": "App\\Tasks\\ClearUnconfirmedOrdersTask",
"payload": {
"type": "clear_unconfirmed_orders",
"payload": ["minutes", 30]
},
"mode": "defer",
"status": "pending",
"created_at": "2026-05-24T10:00:00Z",
"start_at": "2026-05-24T10:00:00Z",
"end_at": "2030-01-01T00:00:00Z",
"delay_seconds": 300,
"attempts": 0,
"max_attempts": 3,
"last_error": null,
"enforce_exact_schedule": false
}
Champs clés
| Champ | Description |
|---|---|
id |
Identifiant unique (UUID) |
signature |
Identifiant lisible (ex: clear-unconfirmed-orders) |
class |
Classe PHP de la tâche |
payload |
Données typées de la tâche |
mode |
sync (immédiat) ou defer (asynchrone) |
status |
pending, running, success, failed |
start_at |
Date de début de validité |
end_at |
Date de fin (passée = tâche terminée) |
delay_seconds |
Délai entre deux exécutions (pour récurrence) |
attempts |
Nombre de tentatives effectuées |
max_attempts |
Nombre max de tentatives |
last_error |
Dernière erreur rencontrée |
enforce_exact_schedule |
true = exécution stricte (pas de période de grâce) |
Créer votre première tâche
1. Créer la classe de la tâche
<?php // app/Tasks/ClearUnconfirmedOrdersTask.php declare(strict_types=1); namespace App\Tasks; use AndyDefer\Task\AbstractTask; use AndyDefer\Task\Records\TaskConfigRecord; use App\Models\Order; final class ClearUnconfirmedOrdersTask extends AbstractTask { public function getConfig(): TaskConfigRecord { return new TaskConfigRecord( signature: 'clear-unconfirmed-orders', description: 'Clear orders not confirmed after 30 minutes', delaySeconds: 300, // Toutes les 5 minutes maxAttempts: 3, endAt: null, // null = récurrente jusqu'à suppression ); } protected function process(): void { // Récupérer les paramètres du payload $minutes = $this->payload->get('minutes') ?? 30; // Logique métier $deleted = Order::where('status', 'pending') ->where('created_at', '<', now()->subMinutes($minutes)) ->delete(); $this->info("Deleted {$deleted} unconfirmed orders"); } }
2. Enregistrer la tâche
<?php namespace App\Console\Commands; use AndyDefer\Task\Enums\TaskMode; use AndyDefer\Task\Records\TaskPayloadRecord; use AndyDefer\Task\Services\TaskRegistry; use AndyDefer\Logger\Collections\MixedPayloadCollection; use App\Tasks\ClearUnconfirmedOrdersTask; class ScheduleTaskCommand extends Command { public function __construct( private readonly TaskRegistry $registry, ) {} public function handle(): void { $payload = new TaskPayloadRecord( type: 'clear_unconfirmed_orders', payload: (new MixedPayloadCollection())->add('minutes', 30), ); $this->registry->register( taskClass: ClearUnconfirmedOrdersTask::class, mode: TaskMode::DEFER, payload: $payload, ); } }
3. Exécuter le poller
# Exécuter les tâches pendant 60 secondes
./vendor/bin/directive run-task --duration=60
Le cycle de vie d'une tâche
Template method
AbstractTask utilise le pattern Template Method pour définir le cycle de vie :
execute()
├── log('task_started')
├── before() ← Hook optionnel
├── process() ← Logique métier (obligatoire)
├── after(true) ← Hook optionnel
└── log('task_completed')
Hooks disponibles
use AndyDefer\Task\AbstractTask; final class MyTask extends AbstractTask { // Avant l'exécution - initialisation, vérifications protected function before(): void { if (!$this->hasLaravel()) { $this->error('Laravel is not available!'); throw new \RuntimeException('Laravel required'); } } // Logique métier (obligatoire) protected function process(): void { // Votre code ici } // Après l'exécution - nettoyage, notifications protected function after(bool $success, ?string $error = null): void { if ($success) { $this->info('Task completed successfully'); } else { $this->error("Task failed: {$error}"); } } }
Accès à Laravel
Les tâches peuvent accéder à Laravel via hasLaravel() et getLaravel() :
protected function before(): void { if (!$this->hasLaravel()) { $this->error('Laravel is not available!'); return; } $app = $this->getLaravel(); $version = $app->version(); $this->info("Running on Laravel {$version}"); }
Types de tâches
Tâche unique
S'exécute une seule fois, puis est archivée.
final class SendWelcomeEmailTask extends AbstractTask { public function getConfig(): TaskConfigRecord { return new TaskConfigRecord( signature: 'send-welcome-email', description: 'Send welcome email to new user', delaySeconds: 0, // Pas de récurrence endAt: date('c', strtotime('+1 hour')), // Expire dans 1 heure ); } }
Caractéristiques :
delaySeconds = 0endAtdans le futur- Exécutée une fois puis archivée dans
completed/
Tâche récurrente
S'exécute à intervalles réguliers.
final class CleanLogsTask extends AbstractTask { public function getConfig(): TaskConfigRecord { return new TaskConfigRecord( signature: 'clean-logs', description: 'Clean old log files', delaySeconds: 3600, // Toutes les heures endAt: null, // Jamais (récurrente à vie) ); } }
Caractéristiques :
delaySeconds > 0endAt = null- Une seule instance par signature
- Exécutée indéfiniment
Tâche avec date de fin
final class PromoTask extends AbstractTask { public function getConfig(): TaskConfigRecord { return new TaskConfigRecord( signature: 'promo-2024', description: 'Promo campaign', delaySeconds: 86400, // Une fois par jour endAt: '2026-12-31T23:59:59Z', // Arrêt à cette date ); } }
Période de grâce (Grace Period)
Qu'est-ce que c'est ?
La période de grâce permet d'exécuter une tâche unique même si elle a dépassé sa date de fin (endAt), dans une limite configurable (par défaut 24 heures).
Pourquoi ?
Dans certains cas, une tâche peut ne pas s'exécuter exactement à l'heure prévue :
- Le poller était arrêté
- Le serveur était en maintenance
- La charge système a retardé l'exécution
Sans période de grâce, ces tâches seraient définitivement perdues.
Comportement par défaut
| Type de tâche | Période de grâce |
|---|---|
Unique (delaySeconds = 0) |
✅ Activée (24h) |
Récurrente (delaySeconds > 0) |
❌ Désactivée |
Avec enforceExactSchedule = true |
❌ Désactivée |
Configuration
// config/task.php 'grace_period' => [ 'enabled' => env('TASKS_GRACE_PERIOD_ENABLED', true), 'seconds' => env('TASKS_GRACE_PERIOD_SECONDS', 86400), // 24h ],
Exemple d'utilisation
// Tâche unique avec période de grâce (par défaut) final class SendReportTask extends AbstractTask { public function getConfig(): TaskConfigRecord { return new TaskConfigRecord( signature: 'send-daily-report', description: 'Send daily report email', delaySeconds: 0, // Unique endAt: date('c', strtotime('2026-05-24 23:59:59')), ); } } // Tâche qui exige une exécution stricte (pas de grâce) $registry->register( taskClass: CriticalTask::class, mode: TaskMode::DEFER, payload: $payload, enforceExactSchedule: true, // ← Désactive la période de grâce );
Logs de période de grâce
{
"time": "2026-05-25T00:05:00Z",
"level": "warning",
"data": {
"type": "task",
"payload": [
"task_executed_during_grace_period",
"550e8400-e29b-41d4-a716-446655440000",
"send-daily-report",
300,
"seconds_late"
]
}
}
Fichiers de traçage
Les exécutions pendant la période de grâce sont également enregistrées dans :
storage/tasks/grace_period/
└── 550e8400-e29b-41d4-a716-446655440000.json
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"signature": "send-daily-report",
"originalEndAt": 1745510399,
"executedAt": 1745510700,
"delaySeconds": 301
}
Le payload : passer des paramètres typés
Qu'est-ce qu'un payload ?
Le payload est une structure typée qui transporte les paramètres de la tâche.
use AndyDefer\Task\Records\TaskPayloadRecord; use AndyDefer\Logger\Collections\MixedPayloadCollection; $payload = new TaskPayloadRecord( type: 'clear_unconfirmed_orders', payload: (new MixedPayloadCollection())->add('minutes', 30, 'force', true), );
Accéder aux paramètres dans la tâche
protected function process(): void { $minutes = $this->payload->get('minutes') ?? 30; $force = $this->payload->get('force') ?? false; $this->info("Clearing orders older than {$minutes} minutes"); }
Types supportés dans le payload
| Type | Exemple |
|---|---|
int |
->add('user_id', 123) |
float |
->add('price', 99.99) |
string |
->add('name', 'John') |
bool |
->add('force', true) |
null |
->add('optional', null) |
Record |
->add('user', $userRecord) |
TypedCollection |
->add('tags', $tags) |
Exemple avec Record personnalisé
use AndyDefer\Records\AbstractRecord; final class OrderFilterRecord extends AbstractRecord { public function __construct( public readonly ?string $status = null, public readonly ?int $minAmount = null, public readonly ?int $maxAmount = null, ) {} } // Création du payload $filter = new OrderFilterRecord(status: 'pending', minAmount: 100); $payload = new TaskPayloadRecord( type: 'process_orders', payload: (new MixedPayloadCollection())->add('filter', $filter), ); // Dans la tâche protected function process(): void { $filter = $this->payload->get('filter'); $orders = Order::where('status', $filter->status) ->where('amount', '>=', $filter->minAmount) ->get(); }
Enregistrer une tâche
Via le TaskRegistry
use AndyDefer\Task\Enums\TaskMode; use AndyDefer\Task\Records\TaskPayloadRecord; use AndyDefer\Task\Services\TaskRegistry; use AndyDefer\Logger\Collections\MixedPayloadCollection; class TaskScheduler { public function __construct( private readonly TaskRegistry $registry, ) {} public function schedule(): void { $payload = new TaskPayloadRecord( type: 'clear_orders', payload: (new MixedPayloadCollection())->add('minutes', 30), ); $taskId = $this->registry->register( taskClass: ClearUnconfirmedOrdersTask::class, mode: TaskMode::DEFER, payload: $payload, startAt: now()->toIso8601ZuluString(), endAt: null, delaySeconds: 300, enforceExactSchedule: false, // Optionnel, défaut = false ); echo "Task registered with ID: {$taskId}\n"; } }
Dans une commande Artisan
<?php namespace App\Console\Commands; use AndyDefer\Task\Enums\TaskMode; use AndyDefer\Task\Records\TaskPayloadRecord; use AndyDefer\Task\Services\TaskRegistry; use AndyDefer\Logger\Collections\MixedPayloadCollection; use App\Tasks\ClearUnconfirmedOrdersTask; final class RegisterTaskCommand extends Command { protected $signature = 'task:register {--minutes=30} {--exact : Enforce exact schedule (no grace period)}'; public function handle(TaskRegistry $registry): void { $minutes = (int) $this->option('minutes'); $exact = $this->option('exact'); $payload = new TaskPayloadRecord( type: 'clear_orders', payload: (new MixedPayloadCollection())->add('minutes', $minutes), ); $registry->register( taskClass: ClearUnconfirmedOrdersTask::class, mode: TaskMode::DEFER, payload: $payload, enforceExactSchedule: $exact, ); $this->info("Task registered with exact schedule: " . ($exact ? 'yes' : 'no')); } }
Dans un contrôleur
<?php namespace App\Http\Controllers\Admin; use AndyDefer\Task\Enums\TaskMode; use AndyDefer\Task\Records\TaskPayloadRecord; use AndyDefer\Task\Services\TaskRegistry; use App\Tasks\GenerateReportTask; final class ReportController extends Controller { public function generate(ReportRequest $request, TaskRegistry $registry): JsonResponse { $payload = new TaskPayloadRecord( type: 'generate_report', payload: (new MixedPayloadCollection())->add( 'format', $request->format, 'date_from', $request->date_from, 'date_to', $request->date_to, ), ); $taskId = $registry->register( taskClass: GenerateReportTask::class, mode: TaskMode::DEFER, payload: $payload, enforceExactSchedule: $request->boolean('exact_schedule', false), ); return response()->json([ 'message' => 'Report generation started', 'task_id' => $taskId, ]); } }
Exécuter les tâches (Poller)
Commande de base
# Exécuter pendant 60 secondes (valeur par défaut) ./vendor/bin/directive run-task # Exécuter pendant 120 secondes ./vendor/bin/directive run-task --duration=120 # Simulation (dry-run) - ne rien exécuter ./vendor/bin/directive run-task --dry-run # Désactiver le fork (mode séquentiel) ./vendor/bin/directive run-task --no-fork # Chemin personnalisé pour le fichier de lock ./vendor/bin/directive run-task --lock-path=/tmp/my-custom-lock.lock # Avec alias ./vendor/bin/directive task-run --duration=60
Options de la directive
| Option | Description | Défaut |
|---|---|---|
--duration |
Durée d'exécution (secondes) | 60 |
--dry-run |
Simulation (n'exécute rien) | false |
--no-fork |
Désactive le fork (mode séquentiel) | false |
--lock-path |
Chemin personnalisé pour le fichier de lock | storage/tasks/poller.lock |
Verrouillage et concurrence
⚠️ Un seul poller à la fois
Le système utilise un verrouillage par fichier pour empêcher l'exécution concurrente de plusieurs pollers.
# Premier poller - prend le lock ./vendor/bin/directive run-task --duration=60 # Second poller - sera bloqué (le lock est déjà pris) ./vendor/bin/directive run-task --duration=60
Fonctionnement du lock
┌─────────────────────────────────────────────────────────────────────┐
│ LOCK MECHANISM │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Poller 1 Poller 2 │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ acquire │───┐ │ acquire │───┐ │
│ │ lock │ │ │ lock │ │ │
│ └─────────┘ │ └─────────┘ │ │
│ │ │ │ │ │
│ ▼ │ ▼ │ │
│ ┌─────────┐ │ ┌─────────┐ │ │
│ │ LOCK │ │ │ LOCK │ │ │
│ │ ACQUIRED│ │ │ BUSY │◄──┘ │
│ └─────────┘ │ └─────────┘ │
│ │ │ │ │
│ ▼ │ ▼ │
│ ┌─────────┐ │ ┌─────────┐ │
│ │ execute │ │ │ exit │ │
│ │ tasks │ │ │ (skip) │ │
│ └─────────┘ │ └─────────┘ │
│ │ │ │
│ ▼ │ │
│ ┌─────────┐ │ │
│ │ release │───┘ │
│ │ lock │ │
│ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Logs de verrouillage
{"time":"2026-05-24T10:00:00Z","level":"info","data":{"type":"poller","payload":["lock_acquired"]}}
{"time":"2026-05-24T10:00:01Z","level":"info","data":{"type":"poller","payload":["lock_busy","Another poller is already running"]}}
{"time":"2026-05-24T10:00:30Z","level":"info","data":{"type":"poller","payload":["lock_released"]}}
Bonnes pratiques pour la production
# Avec Supervisor (recommandé) command=/usr/bin/php /var/www/html/vendor/bin/directive run-task --duration=60 # Avec cron (alternative) * * * * * cd /var/www/html && php vendor/bin/directive run-task --duration=55 # Avec lock personnalisé ./vendor/bin/directive run-task --lock-path=/tmp/my-app.lock --duration=60
Mode séquentiel (sans fork)
Pourquoi ?
En hébergement mutualisé, la fonction pcntl_fork() est souvent désactivée pour des raisons de sécurité.
Activation automatique
Le système détecte automatiquement si pcntl_fork() est disponible :
$useSequentialMode = $noFork || !function_exists('pcntl_fork'); if ($useSequentialMode && !$noFork) { $this->warn('pcntl_fork not available, falling back to sequential mode'); }
Utilisation manuelle
# Forcer le mode séquentiel
./vendor/bin/directive run-task --no-fork --duration=60
Configuration permanente
# .env TASKS_USE_SEQUENTIAL_MODE=true
// config/task.php 'poller' => [ 'use_sequential_mode' => env('TASKS_USE_SEQUENTIAL_MODE', true), ],
Comparaison des modes
| Mode | Avantages | Inconvénients |
|---|---|---|
| Fork | Parallélisme, performances | Nécessite pcntl_fork(), plus complexe |
| Séquentiel | Fonctionne partout, simple | Une tâche à la fois |
Exemple en hébergement mutualisé
# Configuration typique pour OVH, 1&1, Hostinger, etc. ./vendor/bin/directive run-task --no-fork --duration=55 # Ou via cron * * * * * cd /var/www/html && php vendor/bin/directive run-task --no-fork --duration=55
Traitement des erreurs et réessais
Configuration des tentatives
public function getConfig(): TaskConfigRecord { return new TaskConfigRecord( signature: 'my-task', description: 'My task', delaySeconds: 300, maxAttempts: 5, // 5 tentatives max ); }
Comportement en cas d'échec
Tentative 1 → Échec → attempts = 1
Tentative 2 → Échec → attempts = 2
Tentative 3 → Échec → attempts = 3
Tentative 4 → Échec → attempts = 4
Tentative 5 → Échec → ARCHIVE (FAILED)
Gestion personnalisée des erreurs
protected function process(): void { try { // Logique métier } catch (\Exception $e) { $this->logError($e); if ($this->shouldRetry()) { $this->warn("Will retry later..."); throw $e; // Déclenche le retry } $this->error("Giving up after {$this->attempts} attempts"); return; // Pas de retry } } private function shouldRetry(): bool { // Logique personnalisée return $this->attempts < 3; }
Expiration des tâches
protected function process(): void { if ($this->isExpired()) { $this->error("Task expired, skipping..."); return; } } private function isExpired(): bool { $endAt = strtotime($this->endAt ?? '+1 year'); return time() > $endAt; }
Logging structuré
Logs automatiques
Le package logue automatiquement :
task_started- Début de l'exécutiontask_completed- Exécution réussietask_failed- Exécution échouéetask_output- Messagesinfo()eterror()lock_acquired- Verrouillage prislock_released- Verrouillage libérélock_busy- Verrouillage déjà pristask_executed_during_grace_period- Exécution pendant période de grâce
Format des logs (JSONL)
{"time":"2026-05-24T10:05:00Z","level":"info","data":{"type":"task","payload":["task_started","550e8400-e29b-41d4-a716-446655440000","clear-unconfirmed-orders","defer"]}}
{"time":"2026-05-24T10:05:01Z","level":"info","data":{"type":"task_output","payload":["info","Deleted 42 unconfirmed orders"]}}
{"time":"2026-05-24T10:05:02Z","level":"info","data":{"type":"task","payload":["task_completed","550e8400-e29b-41d4-a716-446655440000","clear-unconfirmed-orders","success"]}}
Logs du poller
{"time":"2026-05-24T10:00:00Z","level":"info","data":{"type":"poller","payload":["poller_started",60,false]}}
{"time":"2026-05-24T10:00:01Z","level":"info","data":{"type":"poller","payload":["lock_acquired"]}}
{"time":"2026-05-24T10:00:05Z","level":"info","data":{"type":"poller","payload":["waiting_for_tasks",1]}}
{"time":"2026-05-24T10:00:30Z","level":"info","data":{"type":"poller","payload":["lock_released"]}}
{"time":"2026-05-24T10:00:30Z","level":"info","data":{"type":"poller","payload":["poller_finished",30]}}
Logs personnalisés
protected function process(): void { $this->info("Processing started"); $this->info("Step 1 complete"); $this->info("Step 2 complete"); if ($error) { $this->error("Something went wrong"); } }
Consulter les logs
# Afficher les logs d'exécution d'une tâche grep "clear-unconfirmed-orders" storage/logs/structured/2026-05-24/*.jsonl # Afficher les erreurs grep "task_failed" storage/logs/structured/2026-05-24/*.jsonl # Afficher les logs du poller grep "poller" storage/logs/structured/2026-05-24/*.jsonl # Afficher les logs de verrouillage grep "lock_" storage/logs/structured/2026-05-24/*.jsonl # Afficher les exécutions pendant période de grâce grep "grace_period" storage/logs/structured/2026-05-24/*.jsonl
Tests unitaires
Tester une tâche
<?php namespace Tests\Unit\Tasks; use AndyDefer\Logger\Collections\MixedPayloadCollection; use AndyDefer\Logger\Logger; use AndyDefer\Task\Enums\TaskMode; use AndyDefer\Task\Records\TaskPayloadRecord; use App\Tasks\ClearUnconfirmedOrdersTask; use Tests\UnitTestCase; use App\Models\Order; final class ClearUnconfirmedOrdersTaskTest extends UnitTestCase { private ClearUnconfirmedOrdersTask $task; protected function setUp(): void { parent::setUp(); $logger = $this->createMock(Logger::class); $this->task = new ClearUnconfirmedOrdersTask(); $this->task->setLogger($logger); $this->task->setTaskId('test-123'); $this->task->setSignature('clear-unconfirmed-orders'); } public function test_execute_deletes_unconfirmed_orders(): void { // Arrange Order::create([ 'status' => 'pending', 'created_at' => now()->subMinutes(40), ]); $payload = new TaskPayloadRecord( type: 'clear_orders', payload: (new MixedPayloadCollection())->add('minutes', 30), ); // Act $this->task->execute(TaskMode::SYNC, $payload); // Assert $this->assertDatabaseCount('orders', 0); } }
Tester une tâche avec mocks
public function test_execute_logs_success(): void { $logger = $this->createMock(Logger::class); $logger->expects($this->once()) ->method('info') ->with($this->callback(fn($record) => $record->type === 'task_output' && $record->payload->contains('info') )); $this->task->setLogger($logger); $this->task->execute(TaskMode::SYNC, $payload); }
Tester le TaskRegistry
use AndyDefer\Task\Services\TaskRegistry; public function test_register_creates_task(): void { $payload = new TaskPayloadRecord( type: 'test', payload: new MixedPayloadCollection(), ); $taskId = $this->registry->register( taskClass: TestTask::class, mode: TaskMode::DEFER, payload: $payload, enforceExactSchedule: true, ); $this->assertIsString($taskId); $this->assertTrue(Uuid::isValid($taskId)); }
Tester le verrouillage
public function test_lock_prevents_concurrent_execution(): void { $manager1 = new ProcessManager(...); $manager2 = new ProcessManager(...); // Premier manager prend le lock $manager1->run(1, false); // Second manager ne peut pas prendre le lock $manager2->run(1, false); $this->assertFileExists($lockPath); }
Architecture technique
Diagramme d'architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ LARAVEL TASK PACKAGE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ STORAGE LAYER │ │
│ │ │ │
│ │ storage/tasks/ │ │
│ │ ├── pending/ ← TaskStorage │ │
│ │ ├── recurring/ ← TaskStorage │ │
│ │ ├── completed/ ← TaskStorage │ │
│ │ ├── grace_period/ ← TaskStorage │ │
│ │ └── poller.lock ← Lock file │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ BUSINESS LAYER │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ TaskRegistry│ │ TaskRunner │ │TaskValidator│ │ │
│ │ │(enregistrer)│ │ (exécuter) │ │ (valider) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ EXECUTION LAYER │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ ProcessManager │ │ │
│ │ │ - Lock acquisition/release │ │ │
│ │ │ - Fork() ou mode séquentiel │ │ │
│ │ │ - Gestion des timeouts │ │ │
│ │ │ - Gestion des signaux (SIGTERM, SIGINT) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ DOMAIN LAYER │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ AbstractTask │ │ │
│ │ │ - Template method: execute() │ │ │
│ │ │ - Hooks: before(), after() │ │ │
│ │ │ - Logging automatique │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Your Tasks │ │ │
│ │ │ - process() (votre logique) │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CLI LAYER │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ RunTaskDirective │ │ │
│ │ │ - Signature: run-task {--duration} {--dry-run} │ │ │
│ │ │ - Options: --no-fork, --lock-path │ │ │
│ │ │ - Alias: task-run, tasks-run │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Composants
| Composant | Rôle |
|---|---|
AbstractTask |
Classe de base avec template method et hooks |
TaskStorage |
Stockage JSONL (pending/, recurring/, completed/, grace_period/) |
TaskRunner |
Exécution des tâches et gestion des tentatives |
TaskValidator |
Validation des tâches (dates, statuts, classes, période de grâce) |
TaskRegistry |
Enregistrement des nouvelles tâches |
ProcessManager |
Gestion du polling (lock, fork, signaux, timeouts) |
RunTaskDirective |
Directive CLI pour l'exécution |
TaskCollection |
Collection typée pour les tâches |
ProcessInfoCollection |
Collection pour les processus enfants |
API Reference
AbstractTask
| Méthode | Retour | Description |
|---|---|---|
getConfig(): TaskConfigRecord |
TaskConfigRecord |
Configuration de la tâche (obligatoire) |
execute(TaskMode $mode, TaskPayloadRecord $payload): void |
void |
Template method (à ne pas surcharger) |
before(): void |
void |
Hook avant exécution (optionnel) |
process(): void |
void |
Logique métier (obligatoire) |
after(bool $success, ?string $error): void |
void |
Hook après exécution (optionnel) |
info(string $message): void |
void |
Log de niveau INFO |
error(string $message): void |
void |
Log de niveau ERROR |
hasLaravel(): bool |
bool |
Laravel disponible ? |
getLaravel(): ?object |
?object |
Instance Laravel |
setLogger(Logger $logger): self |
self |
Injecte le logger |
setTaskId(string $id): self |
self |
Injecte l'ID de la tâche |
setSignature(string $signature): self |
self |
Injecte la signature |
TaskConfigRecord
| Propriété | Type | Description | Défaut |
|---|---|---|---|
signature |
string |
Identifiant lisible | Obligatoire |
description |
string |
Description | Obligatoire |
delaySeconds |
int |
Délai entre exécutions | 300 |
maxAttempts |
int |
Nombre max de tentatives | 3 |
startAt |
?string |
Date de début (ISO 8601) | null (maintenant) |
endAt |
?string |
Date de fin (ISO 8601) | null (jamais) |
TaskMode (enum)
| Valeur | Constante | Description |
|---|---|---|
sync |
TaskMode::SYNC |
Exécution immédiate (synchrone) |
defer |
TaskMode::DEFER |
Exécution asynchrone via poller |
TaskStatus (enum)
| Valeur | Constante | Description |
|---|---|---|
pending |
TaskStatus::PENDING |
En attente d'exécution |
running |
TaskStatus::RUNNING |
En cours d'exécution |
success |
TaskStatus::SUCCESS |
Terminée avec succès |
failed |
TaskStatus::FAILED |
Échec après max tentatives |
TaskRegistry
| Méthode | Description |
|---|---|
register(string $taskClass, TaskMode $mode, TaskPayloadRecord $payload, ?string $startAt, ?string $endAt, ?int $delaySeconds, bool $enforceExactSchedule): string |
Enregistre une nouvelle tâche, retourne l'ID/signature |
unregisterRecurring(string $signature): void |
Supprime une tâche récurrente |
RunTaskDirective (CLI)
| Option | Description | Défaut |
|---|---|---|
--duration |
Durée d'exécution (secondes) | 60 |
--dry-run |
Simulation (n'exécute rien) | false |
--no-fork |
Désactive le fork (mode séquentiel) | false |
--lock-path |
Chemin personnalisé pour le fichier de lock | storage/tasks/poller.lock |
Bonnes pratiques
1. Une signature unique et explicite
// ✅ BON signature: 'clear-unconfirmed-orders' // ❌ MAUVAIS signature: 'task1'
2. Définir une date de fin pour les tâches temporaires
// ✅ BON public function getConfig(): TaskConfigRecord { return new TaskConfigRecord( signature: 'promo-2024', description: 'Promo campaign', delaySeconds: 86400, endAt: '2026-12-31T23:59:59Z', ); }
3. Utiliser les hooks pour la maintenance
protected function before(): void { $this->info("Starting at " . now()); } protected function after(bool $success, ?string $error = null): void { $this->info("Finished at " . now()); if (!$success) { $this->notifyAdmin($error); } }
4. Gérer les erreurs proprement
protected function process(): void { $minutes = $this->payload->get('minutes'); if (!is_int($minutes) || $minutes <= 0) { $this->error("Invalid minutes: {$minutes}"); return; // Échec silencieux (pas de retry) } try { // Logique } catch (\Exception $e) { $this->error($e->getMessage()); throw $e; // Déclenche le retry } }
5. Utiliser le payload pour les paramètres variables
// ✅ BON - Paramètres externalisés $payload = new TaskPayloadRecord( type: 'send_email', payload: (new MixedPayloadCollection())->add('user_id', 123, 'template', 'welcome'), ); // ❌ MAUVAIS - Paramètres codés en dur protected function process(): void { $userId = 123; // Impossible à modifier }
6. Tester unitairement vos tâches
public function test_execute_deletes_old_orders(): void { // Arrange Order::factory()->create(['created_at' => now()->subHours(2)]); // Act $this->task->execute(TaskMode::SYNC, $payload); // Assert $this->assertDatabaseCount('orders', 0); }
7. Monitorer l'exécution des tâches
protected function after(bool $success, ?string $error = null): void { // Envoyer une notification en cas d'échec if (!$success) { Notification::route('slack', config('services.slack.webhook')) ->notify(new TaskFailedNotification($this->signature, $error)); } }
8. Utiliser les collections typées pour les payloads complexes
$tags = new TypedCollection('string'); $tags->add('urgent', 'important'); $payload = new TaskPayloadRecord( type: 'process_orders', payload: (new MixedPayloadCollection())->add('tags', $tags), ); // Dans la tâche protected function process(): void { $tags = $this->payload->get('tags'); foreach ($tags as $tag) { $this->info("Processing tag: {$tag}"); } }
9. Utiliser un lock personnalisé en production
# Éviter les conflits avec d'autres applications ./vendor/bin/directive run-task --lock-path=/var/lock/my-app.lock # Ou via configuration TASKS_LOCK_PATH=/var/lock/my-app.lock
10. Utiliser enforceExactSchedule pour les tâches critiques
// Pour les tâches qui doivent s'exécuter exactement à l'heure $registry->register( taskClass: CriticalTask::class, mode: TaskMode::DEFER, payload: $payload, enforceExactSchedule: true, // Désactive la période de grâce );
FAQ
Q: Quelle est la différence entre une tâche unique et récurrente ?
R: Une tâche unique a delaySeconds = 0 et s'exécute une seule fois. Une tâche récurrente a delaySeconds > 0 et s'exécute indéfiniment (ou jusqu'à endAt).
Q: Comment arrêter une tâche récurrente ?
R: Définissez endAt dans le passé ou supprimez le fichier dans recurring/ :
rm storage/tasks/recurring/clear-unconfirmed-orders.json
Q: Que se passe-t-il si une tâche échoue ?
R: La tâche est réessayée jusqu'à maxAttempts fois, avec le même délai delaySeconds entre chaque tentative. Après le dernier échec, la tâche est archivée avec le statut failed.
Q: Peut-on exécuter une tâche immédiatement (sans poller) ?
R: Oui, utilisez TaskMode::SYNC :
$task->execute(TaskMode::SYNC, $payload);
Q: Comment exécuter le poller en continu ?
R: Utilisez Supervisor ou cron :
# Supervisor command=/usr/bin/php vendor/bin/directive run-task --duration=60 # Cron (toutes les minutes) * * * * * cd /var/www/html && php vendor/bin/directive run-task --duration=55
Q: Les tâches sont-elles persistantes après redémarrage ?
R: Oui, toutes les tâches sont stockées dans des fichiers JSONL. Après un redémarrage, le poller reprend là où il s'était arrêté.
Q: Peut-on avoir plusieurs pollers en parallèle ?
R: Non, le système utilise un verrouillage par fichier pour empêcher l'exécution concurrente. Un seul poller peut s'exécuter à la fois.
Q: Que faire si pcntl_fork() n'est pas disponible ?
R: Utilisez le mode séquentiel avec --no-fork :
./vendor/bin/directive run-task --no-fork --duration=60
Q: Comment visualiser les tâches en attente ?
R: Utilisez ls ou consultez le dossier pending/ :
ls -la storage/tasks/pending/
Q: Peut-on modifier une tâche en attente ?
R: Oui, modifiez directement le fichier JSON dans pending/ ou recurring/. Le poller lira les modifications au prochain cycle.
Q: Comment forcer l'exécution d'une tâche immédiatement ?
R: Copiez la tâche dans pending/ avec la date start_at dans le passé et exécutez le poller.
Q: Où sont stockés les logs de verrouillage ?
R: Les logs sont dans les fichiers JSONL du logger, filtrés par type: 'poller'.
Q: Le lock est-il supprimé après exécution ?
R: Oui, le fichier de lock est automatiquement supprimé après chaque exécution, même en cas d'erreur.
Q: Une tâche unique expirée peut-elle encore s'exécuter ?
R: Oui, grâce à la période de grâce (24h par défaut). Une tâche unique peut s'exécuter jusqu'à 24h après sa date end_at. Pour désactiver ce comportement, utilisez enforceExactSchedule = true.
Q: Comment désactiver la période de grâce pour une tâche spécifique ?
R: Passez enforceExactSchedule: true lors de l'enregistrement :
$registry->register( taskClass: MyTask::class, mode: TaskMode::DEFER, payload: $payload, enforceExactSchedule: true, );
Q: Comment configurer la durée de la période de grâce ?
R: Modifiez la configuration ou la variable d'environnement :
TASKS_GRACE_PERIOD_SECONDS=43200 # 12 heures
Licence
MIT © Andy Defer