laravel-task maintained by andydefer
Laravel Task
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