Durable durable.rocks
Exécution durable pour PHP

Des traitements longs qui survivent
aux pannes, aux déploiements et aux redémarrages.

Débiter la carte, réserver le stock, envoyer le reçu, dans une seule méthode. Si le processus meurt en chemin, un autre reprend à l’étape où il s’est arrêté.

Commencer Installer maintenant Installer le skill IA

Un paiement est encaissé, puis le processus doit réserver le stock, puis prévenir le client par e-mail. Le worker est redéployé entre la deuxième et la troisième étape. Qu’est-il arrivé à la commande ?

Sans Durable, le même redéploiement
charge Le job est réessayé depuis le début. Le client est encaissé une deuxième fois.
reserve-stock Bloqué pour une commande qui ne partira jamais, ou perdu, et vous vendez un article que vous n’avez plus.
send-receipt Ne s’exécute jamais. Le client ne reçoit aucun e-mail.

Vous l’apprenez par un ticket de support trois jours plus tard. Pour corriger, vous écrivez un script de réparation à la main et vous le lancez en production.

Avec Durable, le même redéploiement
charge Revient du journal. La carte n’est pas débitée une seconde fois.
reserve-stock Revient du journal. Le stock n’est pas réservé une seconde fois.
send-receipt S’exécute une fois, après le redémarrage. L’e-mail part.

La commande va jusqu’au bout. Le paiement n’est pas rejoué et la réservation est conservée, puis l’e-mail part.

Le journal de cette commande
01
10:02:11
charge
Enregistré au journal avec son résultat.
02
10:02:13
reserve-stock
Enregistré. Un rejeu la relit au lieu de réserver à nouveau.
⟳
10:04:57 · interruption
deploy v412
Le worker s’arrête en pleine exécution. La méthode est rejouée sur le nouveau worker. Les étapes 01 et 02 reviennent du journal et ne s’exécutent pas une seconde fois.
03
10:07:11
send-receipt ✓
S’exécute pour la première fois, cinq minutes après le paiement, comme écrit.

Aucune étape terminée ne s’est exécutée deux fois et aucune n’a été perdue. Aucun cron n’est intervenu dans cette exécution.

Le workflow tient dans une méthode PHP

À chaque reprise, le code repart du début, mais chaque étape déjà enregistrée renvoie son résultat enregistré au lieu de s’exécuter à nouveau.

Vous ne déclarez ni machine à états ni table de transitions, car le processus est la méthode. L’environnement est injecté dans le constructeur et les activités sont des stubs typés, si bien que votre IDE et votre analyseur statique comprennent déjà chaque appel du workflow.

Un worker peut être redéployé en pleine exécution : l’exécution reprend là où elle s’est arrêtée.

src/Workflow/CheckoutWorkflow.php PHP 8.2+ · survolez une ligne
#[Workflow(name: 'checkout')]
final class CheckoutWorkflow
{
private ActivityStub $orders;
 
public function __construct(
private readonly WorkflowEnvironment $env,
) {
$this->orders = $env->activityStub(OrderActivities::class);
}
 
#[WorkflowMethod]
public function run(string $orderId): string
{
$payment = $this->env->await($this->orders->charge($orderId));
$this->env->await($this->orders->reserveStock($orderId));
$this->env->sleep(Duration::minutes(5));
$this->env->await($this->orders->sendReceipt($payment));
 
return $payment;
}
}

Ce que vous n’écrivez plus

Cinq pièces que vous maintenez aujourd’hui, et ce qui remplace chacune.

Ce que vous supprimez Ce qui le remplace
−Une colonne d’état, et la migration qui ajoute l’état suivant +La ligne où en est la méthode. Le journal tient la position.
−Un cron qui balaie les lignes de pending_* +L’instruction suivante. Les timers et les signaux réveillent l’exécution.
−Un compteur de réessais et la table des messages morts +RetryLimit::ofAttempts(3), une option sur le stub.
−Une protection contre le job qui s’est exécuté deux fois +Une étape enregistrée renvoie son résultat enregistré et ne s’exécute plus.
−Un moyen de savoir pourquoi la commande 4242 s’est arrêtée il y a trois jours +Rejouez son journal : chaque étape, chaque résultat, chaque tentative.

Pour cette commande, cinq fichiers et un runbook laissent la place à une méthode et son journal.

Six autres usages dans la même classe de workflow

Chaque extrait ci-dessous est la même classe que plus haut : l’environnement injecté dans le constructeur, les activités en stubs typés. Chacun a sa propre page.

cas d’usage 01

Lancer deux étapes à la fois, et garder les deux résultats

Appeler une activité renvoie un awaitable. all() en assemble deux en un seul, et l’exécution attend sur await().

Parallélisme →
deux étapes, une attenteles deux résultats, dans l’ordre
[$stock, $credit] = $this->env->await($this->env->all(
    $this->orders->reserveStock($orderId),
    $this->orders->checkCredit($orderId),
));
cas d’usage 02

Borner l’attente dans le temps, sans écrire la course

L’échéance est le second argument d’await(). Quand elle expire, vous obtenez un échec au lieu d’un null ambigu. Pour attendre l’horloge seule, appelez sleep().

Échéances et délais →
une horloge sur l’awaitune échéance expirée est un échec
try {
    $quote = $this->env->await(
        $this->orders->callProvider($orderId),
        deadline: Duration::seconds(30),
    );
} catch (DeadlineExceededException $e) {
    $quote = $this->fallbackQuote($orderId);
}

$this->env->sleep(Duration::minutes(5));   // juste attendre, l’await est implicite
cas d’usage 03

Cesser d’attendre dès que les réponses suffisent

Trois devis sur huit suffisent à décider ; les cinq autres ne coûtent que leur latence. Les résultats reviennent indexés par position de déclaration : chaque prix pointe encore le fournisseur qui l’a donné.

Quorum avec some() →
les trois premières réponses gagnentrésultats indexés par position de déclaration
$prices = $this->env->await($this->env->some(3, ...$providers), deadline: Duration::seconds(2));
cas d’usage 04

Déclarer une fois ce que signifie un échec

Limites de réessai, exceptions non rejouables et délais par tentative sont des options sur le stub d’activité. Vous n’éparpillez plus un compteur, une table de messages morts et un runbook dans votre code.

Réessais et politique d’échec →
des options sur le stubdéclarées une fois, dans le constructeur
$this->orders = $env->activityStub(
    OrderActivities::class,
    ActivityOptions::of(
        retryLimit: 3,
        timeouts: Duration::seconds(30),
        nonRetryableExceptions: [PaymentRefusedException::class],
    ),
);
cas d’usage 05

Envoyer une entrée à un processus en cours, et lire son état

Un signal apporte une entrée à une exécution déjà en cours ; une requête lit son état sans le perturber ; une mise à jour est validée et répond. Les étapes d’approbation et les mises en attente manuelles ne demandent plus d’infrastructure à part.

Signaux, requêtes et mises à jour →
signal, requête, mise à jourenvoyés à une exécution en cours
#[AsSignalMethod(OrderSignal::Approve)]
public function approve(string $approver): void { /* … */ }

#[AsQueryMethod('status')]
public function status(): string
{
    return $this->status;
}

#[AsUpdateMethod('changeAddress')]
public function changeAddress(string $address): string { /* … */ }

// dans la méthode du workflow : un gestionnaire mute l’état, une condition l’observe :
$this->env->await(fn(): bool => null !== $this->approver, deadline: Duration::hours(1));
cas d’usage 06

Défaire ce qui a déjà eu lieu quand un processus est annulé

L’annulation arrive dans le workflow sous la forme d’un échec rattrapable : le processus peut compenser, rembourser le paiement et libérer le stock, puis s’arrêter.

Annulation et compensation →
une annulation rattrapablecompenser, puis la laisser passer
try {
    return $this->env->await($this->orders->charge($orderId));
} catch (WorkflowCancelledFailure $e) {
    $this->env->await($this->orders->refund($orderId));

    throw $e;
}
en pratique 01

Un processus qui dure un mois, dans une seule méthode

Une commande part chez un fournisseur. Il a 48 heures pour confirmer, sinon cela remonte au support. Une fois confirmée, la livraison est attendue jusqu’à 30 jours, avec des rappels au 7ᵉ et au 21ᵉ jour. Si rien n’arrive, le paiement est remboursé. Le workflow complet figure ci-dessous.

une méthode · 48 heures, puis 30 joursaucune colonne d’état, aucun tick de cron
// #[AsSignalMethod(SupplierSignal::Confirmed)] pose $this->confirmed
try {
    $this->env->await(fn(): bool => $this->confirmed, deadline: Duration::hours(48));
} catch (DeadlineExceededException $e) {
    $this->env->await($this->desk->escalate($orderId));
}

$this->env->sleep(Duration::of(new \DateInterval('P7D')));
$this->env->await($this->mail->remind($orderId));

// #[AsSignalMethod(SupplierSignal::Delivered)] pose $this->delivery
$this->env->await(
    fn(): bool => null !== $this->delivery,
    deadline: Duration::of(new \DateInterval('P30D')),
);
01

Faire courir un humain contre une horloge

Avec Messenger, « confirmer sous 48 h ou escalader » exige deux choses en cours, un message différé et un webhook, plus un moyen de savoir qui a gagné et d’annuler le perdant. Le cas difficile se trouve à la limite. Le fournisseur confirme à 47 h 59, le message d’échéance part quand même, et il faut trancher lequel compte. Ici l’échéance est un échec typé et non une valeur, ce qui permet d’écrire cette décision dans la méthode.

02

Attendre ne coûte rien

Trente jours dans une file, c’est un message garé dans un broker qui a ses propres TTL et redéliveries. Trente jours en cron, c’est une ligne balayée à chaque tick pendant un mois. Ici l’exécution ne tourne pas du tout pendant l’attente, et la ligne suivante s’exécute quand l’attente se termine.

03

Les variables locales meurent avec le handler

Tout ce qui est calculé à l’étape un doit être persisté pour exister à l’étape quatre : le processus devient une colonne d’état plus une table de contexte, et chaque nouvelle étape est une migration. Dans une seule méthode, $delivery n’est qu’une variable.

04

« Pourquoi la commande 4242 est-elle bloquée depuis trois jours ? »

Avec un cron ou des handlers, vous reconstituez la réponse à partir des logs. Ici vous rejouez le journal : chaque étape, chaque résultat, chaque tentative, dans l’ordre.

Ce que Messenger et cron permettent déjà

Messenger et cron peuvent faire tout cela. Vous le construisez avec des colonnes d’état, des verrous et des garde-fous contre le double traitement, et vous les surveillez. Cette comparaison compte le code que vous écrivez et maintenez.

en pratique 02

Orchestrer un agent IA, avec Symfony AI

Un agent est une boucle de décisions non déterministes qui provoquent des effets irréversibles. Le rejeu rend cette combinaison dangereuse, et le journal règle le problème.

un outil, et le tour autour de luil’appel au modèle est une activité
#[AsTool('issue_refund', 'Refunds an order, in cents')]
final class IssueRefund
{
    public function __invoke(int $orderId, int $amount): string { /* … */ }
}

// Dans le workflow : l’appel au modèle est une activité, jamais du code inline :
// il répond différemment à chaque exécution, et le rejeu doit voir la même réponse.
$plan = $this->env->await($this->agent->decide($conversation));

if ($plan->needsApproval()) {
    $this->env->await(fn(): bool => $this->approved, deadline: Duration::hours(24));
}

$outcome = $this->env->await($this->tools->run($plan->tool(), $plan->arguments()));
01

Le modèle ne répond jamais deux fois la même chose

Au rejeu, le workflow doit voir la même réponse. Sinon, il part dans une autre branche et ne correspond plus à son historique. C’est pourquoi l’appel au modèle est une activité et ne s’exécute pas en ligne. Un agent ne peut reprendre qu’à cette condition.

02

Un appel d’outil est un effet de bord

issue_refund rembourse pour de vrai. Si le worker redémarre en pleine boucle, l’outil ne doit pas s’exécuter à nouveau : il renvoie son résultat enregistré. C’est la même garantie que pour n’importe quelle activité ; ici, c’est le modèle qui a choisi l’appel, et non le développeur.

03

L’humain dans la boucle

« Je vais rembourser 4 200 €. Vous confirmez ? » est une attente bornée qui survit aux déploiements. Dans un handler, vous ne pouvez pas attendre : vous stockez un état et laissez un webhook le retrouver.

Là où Durable s’arrête

Durable n’orchestre pas la boucle interne de Symfony AI ; Agent::call() fait ses propres allers-retours d’outils. Durable borne chaque tour : une décision, ses effets, une approbation facultative, puis le tour suivant.

Nexus · fonctionnel

Votre service PHP peut être aux deux bouts d'une opération Nexus

Nexus achemine l'appel d'un workflow vers une opération qui appartient à une autre équipe, un autre espace de noms, un autre cluster, sans qu'aucun des deux côtés connaisse les workflows de l'autre. Durable appelle ces opérations, et il en sert.

Temporal documente Nexus pour Go, Java, Python, TypeScript et .NET. Aucune documentation pour PHP. Un service PHP s'appelle en HTTP, comme n'importe quel service, mais un appel HTTP n'est pas une opération. Aucune trace n'indique qu'il a eu lieu, son échec ne revient pas typé, et il n'est pas annulé quand l'appelant renonce.

Sans lui, la frontière est un appel HTTP

Commande appelle Facturation et attend. La connexion abandonne au bout de trente secondes ; l'encaissement en prend quatre minutes. Commande réessaie donc, et Facturation encaisse deux fois. Les deux équipes écrivent alors les mêmes trois choses à la main, chacune de son côté : une clé d'idempotence, un travail de rapprochement, et une colonne d'état qui veut dire peut-être.

Avec lui, le même appel, rendu durable

L'appelant attend sans rien tenir d'ouvert, pendant quatre minutes comme pendant six heures. Un échec revient typé, donc vous pouvez écrire la compensation qui lui correspond. Annuler l'appelant annule le travail en face. Et aucune des deux équipes ne voit jamais les workflows de l'autre.

Appeler
$billing = $env->nexusStub(
    BillingContract::class,
    endpoint: 'payments',
);

$receipt = $env->await(
    $billing->charge($order, 1200),
);
Servir
#[AsNexusServiceHandler(
    contract: BillingServed::class,
)]
final class Billing implements BillingServed
{
    public function verify(Order $o): Verdict
    { /* … */ }
}

Les deux côtés partagent un contrat, écrit une fois et typé, et aucun nom d'opération n'est recopié en chaîne. Vous répondez tout de suite, ou vous laissez un workflow répondre plusieurs heures plus tard. L'appelant attend de la même façon dans les deux cas, et annuler l'appel annule le workflow qui le porte.

Appeler et servir des opérations Nexus, de bout en bout →

Changer de backend sans toucher au workflow

Un cœur qui n’exige que psr/cache, une intégration framework facultative, et des backends interchangeables : en mémoire pour les tests, Temporal ou SQL pour la production. Un autre backend ne change que la commande d’installation ; le code du workflow reste tel quel.

gplanchat/durable

La bibliothèque. Workflows, activités, timers, journal d’événements. Exactement une dépendance à l’exécution : psr/cache. Aucun framework.

requis gplanchat/durable-bundle

L’intégration Symfony. Autoconfigure workflows et activités, câble les transports Messenger, ajoute les commandes de worker et un panneau de profiler. Symfony 6.4, 7 ou 8.

optionnel gplanchat/durable-plugin

Le tableau de bord Sylius pour suivre les workflows durables. Ajoute une entrée au menu d’administration Sylius. Il affiche les workflows sans les exécuter ; il tire le bundle et vous laisse le choix du backend.

optionnel gplanchat/durable-bridge-temporal

Le driver Temporal. Parle gRPC directement à un cluster Temporal ; pas de SDK PHP officiel, pas de RoadRunner. Requiert ext-grpc.

un des trois ponts gplanchat/durable-bridge-dbal

Le backend SQL. Exécution durable sur une seule base, sans cluster d’orchestration. Doctrine DBAL 3 ou 4.

un des trois ponts gplanchat/durable-bridge-illuminate

Le backend SQL, par la couche de base de données de Laravel, sur une connexion dédiée. Illuminate 11, 12 ou 13.

un des trois ponts gplanchat/durable-laravel

L’intégration Laravel. Un service provider qui lie les quatre ports de stockage depuis un seul fichier de configuration publié, des workflows déclarés plutôt que scannés, et le travail qui voyage sur la file que l’application draine déjà.

optionnel gplanchat/durable-filament

Le tableau de bord Filament pour suivre les workflows durables. Un plugin de panneau, enregistré par ->plugin(DurableFilamentPlugin::make()), qui ajoute une entrée Exécutions Durable à la navigation. Il affiche les workflows sans les exécuter ; il tire l’intégration Laravel et vous laisse le choix du backend. Filament 3 ou 4. L’installer et l’enregistrer →

optionnel Intégration API Platform

Un state processor qui renvoie un handle de workflow et un 202 + Location, au lieu de tenir la requête ouverte pendant le travail ; une seule classe, valable sur Symfony comme sur Laravel.

bundle aujourd’hui · plugin prévu gplanchat/durable-magento

Le module Magento 2.4 / Mage-OS. Déclare workflows, activités et gestionnaires Nexus servis par le di.xml, livre les workers en commandes bin/magento, et ajoute un écran d’administration en lecture seule avec une frise par action.

optionnel

Ce qu’exige un processus de longue durée

Les workflows se testent unitairement sans aucun serveur, et le journal d’événements se rejoue et s’inspecte après coup.

activités avec limites de réessai délais timers effets de bord signaux requêtes mises à jour workflows enfants annulation rattrapable planifications cron attributs de recherche journal d’événements rejouable
La référence de ces paquets : ce qu’apporte chacun, ce qu’il exige, et les commandes par situation →

Arrêtez de maintenir la machine à états.

Open source, PHP 8.2+, aucun serveur pour écrire votre premier workflow.

Votre commande d’installation, en 2 questions Symfony · Temporal
1 Sur quoi tourne votre projet ?
Et d’autres…bientôt

Symfony 6.4, 7 ou 8. Autoconfiguration, commandes de worker, panneau de profiler.

↳ Sur quel socle ?

2 Où vit l’état ?

Le tableau de bord ne fait que lire les exécutions, donc n’importe quel backend ci-dessus convient.

3 Copiez la commande
$ composer require gplanchat/durable-bundle gplanchat/durable-bridge-temporal

Durable est en bêta. Lancez d’abord composer config minimum-stability beta et composer config prefer-stable true. Sans ces deux réglages, l’installation échoue, car la stabilité minimale par défaut de Composer est stable.

Tout ce dont il dépend vient par transitivité : la commande ne nomme qu’un seul paquet.

Aucune persistance entre les requêtes : c’est la seule option qui ne part pas en production.

Tous les paquets, ce qu’apporte chacun, ce qu’il exige, et ces mêmes commandes rangées par situation →
Documentation Le code sur GitHub
ou laissez un agent l’écrire

Installez la skill pour Claude Code, et laissez l’agent écrire le workflow

La skill s’installe avec deux commandes de Claude Code. Elle enseigne les trois formes (classe de workflow, contrat d’activité et son handler, contrat Nexus), la règle de déterminisme qu’un corps rejoué doit respecter, et une migration Rector pour le code déjà écrit. Cette migration réécrit ce qui a un équivalent dans Durable et signale les appels Workflow:: sans équivalent par un commentaire durable-rector:. Elle laisse certains cas inchangés sans les signaler, et le README du paquet les énumère. La skill vous aide ensuite à finir cinq cas, décrits dans sa référence sur les migrations.

$/plugin marketplace add gplanchat/durable-skill
$/plugin install durable@durable-skill
Quatre erreurs qui passent sans le moindre message, couvertes par la skill
  • un nom d’activité écrit en chaîne littérale, au lieu d’être lu sur l’attribut de son contrat
  • un timer sans résumé, que vous ne retrouvez pas parmi dix mille exécutions
  • un paramètre Nexus renommé d’un seul côté : la charge est indexée par nom, donc l’autre côté reçoit null sans aucun message d’erreur
  • une exécution lancée en ligne dans une requête web, qui s’arrête avec la requête