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à 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 Immobilisé pour une commande qui ne partira jamais — ou perdu, et vous survendez.
send-receipt Ne s’exécute jamais. Le client n’entend absolument rien.

Vous l’apprenez par un ticket de support trois jours plus tard, et le seul retour possible est un script de réparation écrit à la main, en production.

Avec Durable — le même redéploiement
charge Revient du journal. Encaissé exactement une fois.
reserve-stock Revient du journal. Réservé exactement une fois.
send-receipt S’exécute une fois, après le redémarrage. L’e-mail part.

Rien ne lui est arrivé. Le paiement n’est pas rejoué, la réservation n’est pas perdue, et l’e-mail part quand même.

Le journal, reçus à l’appui
01
10:02:11
charge
Enregistré au journal avec son résultat.
02
10:02:13
reserve-stock
Enregistré. La réservation existe exactement une fois.
10:04:57 · interruption
deploy v412
Le worker s’arrête en pleine exécution. La méthode est rejouée sur le nouveau ; les deux étapes ci-dessus reviennent du journal au lieu d’être réexécutées.
03
10:07:11
send-receipt
S’exécute pour la première fois, cinq minutes après le paiement, exactement comme écrit.

Rien n’a été encaissé deux fois. Rien n’a été perdu. Aucun cron n’est intervenu.

Une méthode, de haut en bas

À 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.

Il n’y a pas de machine à états à déclarer ni de table de transitions — le processus is la méthode. L’environnement est injecté dans le constructeur et les activités sont des stubs typés : chaque appel que fait votre workflow est un appel que votre IDE et votre analyseur statique comprennent déjà.

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+ · taquinez 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 rouages que vous maintenez aujourd’hui, et la seule chose qui répond à chacun d’eux.

Ce que vous supprimez Ce qui y répond désormais
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é. Elle ne peut pas s’exécuter deux fois.
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.

Cinq fichiers et un runbook, remplacés par une seule méthode et le journal derrière elle.

Six choses dont vous aurez besoin ensuite

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, pas un résultat. all() en assemble deux en un seul ; c’est await() qui attend vraiment.

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, pas un null. ambigu. Attendre l’horloge seule, c’est sleep().

Échéances et délais →
une horloge sur l’awaitexpirée, c’est un échec, pas un null
try {
    $quote = $this->env->await(
        $this->orders->callProvider($orderId),
        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

Prendre les réponses qui suffisent, abandonner les autres

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), Duration::seconds(2));
cas d’usage 04

Décider une fois pour toutes 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é — pas un compteur, une table de messages morts et un runbook éparpillés 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

Laisser le monde extérieur interrompre un processus en cours

Un signal apporte une entrée à une exécution déjà en vol ; 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 cessent d’être une infrastructure à part.

Signaux, requêtes et mises à jour →
signal, requête, mise à jourfrapper à la porte d’une exécution vivante
#[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, 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, libérer le stock — puis s’arrêter pour de bon.

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 tout est 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, 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,
    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 vol — un message différé et un webhook — plus un moyen de savoir qui a gagné et d’annuler le perdant. Le piège est au bord : le fournisseur confirme à 47 h 59 et le message d’échéance part quand même. Qui décide ? C’est parce que l’échéance est un échec typé, et non une valeur, que la question devient exprimable.

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 simplement pas, et la ligne suivante l’attend.

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.

Soyons justes, quand même

Messenger et cron can faire tout cela. C’est précisément le point — vous les regardez le faire, avec des colonnes d’état, des verrous et des garde-fous contre le double traitement. La comparaison porte sur ce que vous devez écrire et maintenir, pas sur ce qui est possible.

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. C’est exactement le couple que le rejeu rend dangereux — et c’est le journal qui le résout.

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, 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 l’historique ment. L’appel au modèle est donc une activité, pas du code inline. Ce n’est pas un détail d’implémentation : c’est la condition pour qu’un agent soit reprenable.

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é. La même garantie que pour n’importe quelle activité, appliquée à ce que le modèle a décidé plutôt que 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. Ce que Durable borne, c’est le tour : une décision, ses effets, une approbation facultative, puis de nouveau.

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 bien sûr en HTTP, comme n'importe quel service — mais un appel HTTP n'est pas une opération : rien n'enregistre qu'il a eu lieu, rien n'en ramène l'échec typé, et rien ne l'annule 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 — quatre minutes ou six heures, cela ne change rien. Un échec revient typé, donc une compensation peut décider de ce qu'il signifie. 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
    { /* … */ }
}

Un contrat, écrit une fois, typé des deux côtés — aucun nom d'opération recopié en chaîne. Répondez tout de suite, ou laissez un workflow répondre deux heures plus tard : l'appelant attend de la même façon, et annuler l'appel annule le workflow qui le porte.

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

Changez de backend, gardez le 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. La commande change ; le code du workflow, non.

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 observe plutôt qu’il exécute : 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

La même histoire SQL, par la couche de base de données de Laravel. L’ajout au journal et l’écriture métier tiennent dans une seule transaction parce que c’est la même connexion. 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 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 et activités 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.

optional

Tout ce qu’exige un long processus

Y compris la part que personne ne budgète : les tests. 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.

Trouvez votre variante — 2 questions, puis la commande Symfony · Temporal
1 Ça tourne dans quoi ?
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 observe ; il n’exécute pas de workflows, donc n’importe quel backend ci-dessus convient.

3 Copiez, collez, c’est installé.
$ composer require gplanchat/durable-bundle gplanchat/durable-bridge-temporal

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.

Les cinq paquets, ce qu’apporte chacun, ce qu’il exige, et ces mêmes commandes rangées par situation →
Documentation Le code sur GitHub
ou ne le tapez pas vous-même

Installez plutôt la skill, et laissez l’agent écrire le workflow

Deux commandes dans 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 : elle réécrit ce qu’elle peut et refuse d’en deviner quatre.

$/plugin marketplace add gplanchat/durable-skill
$/plugin install durable@durable-skill
Quatre erreurs silencieuses qu’elle ne commet pas
  • 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é, introuvable 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 reçoit null sans un bruit
  • une exécution lancée en ligne dans une requête web, qui meurt avec elle