diff --git a/.idea/nulib-base.iml b/.idea/nulib-base.iml
index 55df4ce..419ada4 100644
--- a/.idea/nulib-base.iml
+++ b/.idea/nulib-base.iml
@@ -4,7 +4,7 @@
     
       
       
-      
+      
       
     
     
diff --git a/.idea/php-docker-settings.xml b/.idea/php-docker-settings.xml
index bd786be..9e9123b 100644
--- a/.idea/php-docker-settings.xml
+++ b/.idea/php-docker-settings.xml
@@ -17,6 +17,36 @@
             
           
         
+        
+          
+            
+              
+              
+            
+          
+        
+        
+          
+            
+              
+              
+                
+                  
+                    
+                    
+                  
+                
+              
+            
+          
+        
       
     
   
diff --git a/.idea/php.xml b/.idea/php.xml
index 7e6be21..06ee8b4 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -2,7 +2,7 @@
 
   
     
-      
+      
     
   
   
@@ -17,44 +17,61 @@
   
   
     
-      
+      
     
   
   
     
-      
-      
-      
-      
-      
-      
-      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
       
-      
-      
-      
-      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
       
+      
       
+      
+      
+      
+      
+      
       
       
-      
-      
-      
-      
-      
-      
-      
-      
-      
-      
-      
-      
-      
-      
-      
-      
-      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
+      
     
   
   
diff --git a/.pman.conf b/.pman.conf
index 1686b67..184adfa 100644
--- a/.pman.conf
+++ b/.pman.conf
@@ -4,7 +4,7 @@ UPSTREAM=dev74
 DEVELOP=dev82
 FEATURE=wip82/
 RELEASE=rel82-
-MAIN=dist82
+MAIN=main82
 TAG_SUFFIX=p82
 HOTFIX=hotf82-
 DIST=
diff --git a/TODO.md b/TODO.md
index 8eea214..4531d55 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,3 +1,7 @@
+# nulib
+
+* [wip](wip/TODO.md)
+
 # nulib/bash
 
 * [nulib/bash](bash/TODO.md)
diff --git a/bash/src/_output_color.sh b/bash/src/_output_color.sh
index afe6428..2cc5cc8 100644
--- a/bash/src/_output_color.sh
+++ b/bash/src/_output_color.sh
@@ -6,7 +6,8 @@ function __esection() {
     local length="${COLUMNS:-80}"
     setx lsep=__complete "$prefix" "$length" -
 
-    recho "$COULEUR_BLEUE$lsep$COULEUR_NORMALE"
+    recho "
+$COULEUR_BLEUE$lsep$COULEUR_NORMALE"
     [ -n "$*" ] || return 0
     length=$((length - 1))
     setx -a lines=echo "$1"
diff --git a/bash/src/_output_vanilla.sh b/bash/src/_output_vanilla.sh
index cbd466f..165f1d7 100644
--- a/bash/src/_output_vanilla.sh
+++ b/bash/src/_output_vanilla.sh
@@ -6,7 +6,8 @@ function __esection() {
     local length="${COLUMNS:-80}"
     setx lsep=__complete "$prefix" "$length" -
 
-    recho "$lsep"
+    recho "
+$lsep"
     [ -n "$*" ] || return 0
     length=$((length - 1))
     setx -a lines=echo "$1"
diff --git a/bash/src/base.args.sh b/bash/src/base.args.sh
index 43e63ae..c70cfa8 100644
--- a/bash/src/base.args.sh
+++ b/bash/src/base.args.sh
@@ -184,7 +184,7 @@ function __nulib_args_parse_args() {
         *) die "Invalid arg definition: expected option, got '$1'" || return;;
         esac
         # est-ce que l'option prend un argument?
-        local __def __longdef __witharg __valdesc
+        local __def __longdef __witharg __valdesc __defvaldesc
         __witharg=
         for __def in "${__defs[@]}"; do
             if [ "${__def%::*}" != "$__def" ]; then
@@ -346,16 +346,19 @@ $prefix$usage"
             fi
             # est-ce que l'option prend un argument?
             __witharg=
-            __valdesc=value
+            __valdesc=
+            __defvaldesc=value
             for __def in "${__defs[@]}"; do
                 if [ "${__def%::*}" != "$__def" ]; then
                     [ "$__witharg" != : ] && __witharg=::
                     [ -n "${__def#*::}" ] && __valdesc="[${__def#*::}]"
+                    __defvaldesc="[value]"
                 elif [ "${__def%:*}" != "$__def" ]; then
                     __witharg=:
                     [ -n "${__def#*:}" ] && __valdesc="${__def#*:}"
                 fi
             done
+            [ -n "$__valdesc" ] || __valdesc="$__defvaldesc"
             # description de l'option
             local first=1 thelp tdesc
             for __def in "${__defs[@]}"; do
diff --git a/bash/src/base.init.sh b/bash/src/base.init.sh
index de5ae8c..671bb33 100644
--- a/bash/src/base.init.sh
+++ b/bash/src/base.init.sh
@@ -21,6 +21,13 @@ if [ -z "$NULIB_NO_INIT_ENV" ]; then
     fi
     [ -n "$NULIBDIR" ] || NULIBDIR="$MYDIR"
 
+    # Si le script courant est un lien, calculer le répertoire destination
+    if [ -n "$MYREALSELF" -a -n "$MYSELF" ]; then
+        MYREALSELF="$(readlink -f "$MYSELF")"
+        MYREALDIR="$(dirname -- "$MYREALSELF")"
+        MYREALNAME="$(basename -- "$MYREALSELF")"
+    fi
+
     # Repertoire temporaire
     [ -z "$TMPDIR" -a -d "$HOME/tmp" ] && TMPDIR="$HOME/tmp"
     [ -z "$TMPDIR" ] && TMPDIR="${TMP:-${TEMP:-/tmp}}"
diff --git a/bash/src/install.sh b/bash/src/install.sh
new file mode 100644
index 0000000..fc4dc51
--- /dev/null
+++ b/bash/src/install.sh
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
+##@cooked nocomments
+module: install "Outils de haut niveau pour installer des fichiers de configuration"
+require: base
+
+if [ -z "$NULIB_INSTALL_CONFIGURED" ]; then
+    # Faut-il afficher le nom des fichiers copié/créés?
+    export NULIB_INSTALL_VERBOSE=1
+    # Faut-il afficher les destinations avec ppath?
+    export NULIB_INSTALL_USES_PPATH=
+fi
+export NULIB_INSTALL_CONFIGURED=1
+
+function ensure_exists() {
+    # Créer le fichier vide "$1" s'il n'existe pas déjà, avec les permissions
+    # $2(=644). retourner vrai si le fichier a été créé sans erreur
+    [ -f "$1" ] || {
+        if [ -n "$NULIB_INSTALL_VERBOSE" ]; then
+            if [ -n "$NULIB_INSTALL_USES_PPATH" ]; then
+                estep "$(ppath "$1")"
+            else
+                estep "$1"
+            fi
+        fi
+        mkdirof "$1"
+        local r=0
+        touch "$1" && chmod "${2:-644}" "$1" || r=$?
+        return $r
+    }
+    return 1
+}
+
+function __nulib_install_show_args() {
+    if [ -z "$NULIB_INSTALL_VERBOSE" ]; then
+        :
+    elif [ -n "$NULIB_INSTALL_USES_PPATH" ]; then
+        estep "$1 --> $(ppath "$2")${3:+/}"
+    else
+        estep "$1 --> $2${3:+/}"
+    fi
+}
+
+function copy_replace() {
+    # Copier de façon inconditionnelle le fichier $1 vers le fichier $2, en
+    # réinitialisation les permissions à la valeur $3
+    local src="$1" dest="$2"
+    local srcname="$(basename -- "$src")"
+
+    [ -d "$dest" ] && dest="$dest/$srcname"
+    mkdirof "$dest" || return 1
+
+    if [ -n "$NULIB_INSTALL_VERBOSE" ]; then
+        local destarg destname slash
+        destarg="$(dirname -- "$dest")"
+        destname="$(basename -- "$dest")"
+        if [ "$srcname" == "$destname" ]; then
+            slash=1
+        else
+            destarg="$destarg/$destname"
+        fi
+        __nulib_install_show_args "$srcname" "$destarg" "$slash"
+    fi
+    local r=0
+    if cp "$src" "$dest"; then
+        if [ -n "$3" ]; then
+            chmod "$3" "$dest" || r=$?
+        fi
+    fi
+    return $r
+}
+
+function copy_new() {
+    # Copier le fichier "$1" vers le fichier "$2", avec les permissions $3(=644)
+    # Ne pas écraser le fichier destination s'il existe déjà
+    # Retourner vrai si le fichier a été copié sans erreur
+    local src="$1" dest="$2"
+
+    [ -d "$dest" ] && dest="$dest/$(basename -- "$src")"
+    mkdirof "$dest" || return 1
+
+    if [ ! -e "$dest" ]; then
+        copy_replace "$src" "$dest" "$3"
+    else
+        return 1
+    fi
+}
+
+function copy_update() {
+    # Copier le fichier "$1" vers le fichier "$2", si $2 n'existe pas, ou si $1
+    # a été modifié par rapport à $2. Réinitialiser le cas échéant les
+    # permissions à la valeur $3
+    # Retourner vrai si le fichier a été copié sans erreur.
+    local src="$1" dest="$2"
+
+    [ -d "$dest" ] && dest="$dest/$(basename -- "$src")"
+    mkdirof "$dest" || return 1
+
+    if [ ! -e "$dest" ]; then
+        copy_replace "$src" "$dest" "$3"
+    elif testdiff "$src" "$dest"; then
+        copy_replace "$src" "$dest" "$3"
+    else
+        return 1
+    fi
+}
+
+COPY_UPDATE_ASK_DEFAULT=
+function copy_update_ask() {
+    # Copier ou mettre à jour le fichier $1 vers le fichier $2.
+    # Si le fichier existe déjà, la différence est affichée, et une confirmation
+    # est demandée pour l'écrasement du fichier.
+    # si $1 commence par '-' alors on s'en sert comme option pour configurer le
+    # niveau d'interaction pour demander la confirmation. les paramètres sont
+    # alors décalés
+    # Retourner vrai si le fichier a été copié sans erreur.
+    local interopt=-c
+    if [[ "$1" == -* ]]; then
+        interopt="$1"
+        shift
+    fi
+    local src="$1" dest="$2"
+
+    [ -d "$dest" ] && dest="$dest/$(basename -- "$src")"
+    mkdirof "$dest" || return 1
+
+    [ -f "$dest" ] || copy_replace "$src" "$dest"
+    if testdiff "$src" "$dest"; then
+        check_interaction "$interopt" && diff -u "$dest" "$src"
+        if ask_yesno "$interopt" "Voulez-vous remplacer $(ppath "$dest") par la nouvelle version?" "${COPY_UPDATE_ASK_DEFAULT:-C}"; then
+            copy_replace "$src" "$dest" "$3"
+            return $?
+        elif ! check_interaction "$interopt"; then
+            ewarn "Les mises à jours suivantes sont disponibles:"
+            diff -u "$dest" "$src"
+            ewarn "Le fichier $(ppath "$dest") n'a pas été mis à jour"
+        fi
+    fi
+    return 1
+}
+
+function link_new() {
+    # Si $2 n'existe pas, créer le lien symbolique $2 pointant vers $1
+    [ -e "$2" ] && return 0
+
+    __nulib_install_show_args "$(basename -- "$2")" "$(dirname -- "$1")"
+    ln -s "$1" "$2"
+}
diff --git a/bash/src/pman.conf.sh b/bash/src/pman.conf.sh
index c33874c..f5a4ace 100644
--- a/bash/src/pman.conf.sh
+++ b/bash/src/pman.conf.sh
@@ -1,7 +1,5 @@
 # -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
 
-## configuration par défaut
-
 UPSTREAM=
 DEVELOP=develop
 FEATURE=wip/
diff --git a/bash/src/pman.sh b/bash/src/pman.sh
index 094b269..41e2da9 100644
--- a/bash/src/pman.sh
+++ b/bash/src/pman.sh
@@ -25,9 +25,146 @@ DIST=
 NOAUTO=
 
 CONFIG_VARS=(
-    UPSTREAM DEVELOP FEATURE RELEASE MAIN TAG_PREFIX TAG_SUFFIX HOTFIX DIST NOAUTO
+    UPSTREAM DEVELOP FEATURE RELEASE MAIN HOTFIX DIST
+    TAG_PREFIX TAG_SUFFIX NOAUTO
 )
 
+################################################################################
+
+PMAN_BRANCHES=(UPSTREAM DEVELOP FEATURE MAIN DIST)
+PMAN_TOOL_PDEV=DEVELOP
+PMAN_TOOL_PWIP=FEATURE
+PMAN_TOOL_PMAIN=MAIN
+PMAN_TOOL_PDIST=DIST
+UPSTREAM_BASE=DEVELOP ; UPSTREAM_MERGE_FROM=       ; UPSTREAM_MERGE_TO=DEVELOP ; UPSTREAM_PREL=    ; UPSTREAM_DELETE=
+DEVELOP_BASE=MAIN     ; DEVELOP_MERGE_FROM=FEATURE ; DEVELOP_MERGE_TO=MAIN     ; DEVELOP_PREL=from ; DEVELOP_DELETE=to
+MAIN_BASE=DEVELOP     ; MAIN_MERGE_FROM=DEVELOP    ; MAIN_MERGE_TO=DIST        ; MAIN_PREL=to      ; MAIN_DELETE=
+DIST_BASE=MAIN        ; DIST_MERGE_FROM=MAIN       ; DIST_MERGE_TO=            ; DIST_PREL=        ; DIST_DELETE=
+FEATURE_BASE=DEVELOP  ; FEATURE_MERGE_FROM=        ; FEATURE_MERGE_TO=DEVELOP  ; FEATURE_PREL=     ; FEATURE_DELETE=from
+
+UPSTREAM_CREATE_FUNCTION=_create_upstream_action
+
+function get_base_branch() {
+    # afficher la branche depuis laquelle créer la branche $1
+    # retourner 1 en cas d'erreur (pas de branche source)
+    local branch="$1" infos
+    [ -n "$branch" ] || return 1
+    infos="${branch^^}_BASE"; branch="${!infos}"
+    [ -n "$branch" ] && echo "$branch" || return 1
+}
+
+function get_merge_from_branch() {
+    # afficher la branche depuis laquelle la branche $1 doit merger
+    # retourner 1 en cas d'erreur (pas de branche source)
+    local branch="$1" infos
+    [ -n "$branch" ] || return 1
+    infos="${branch^^}_MERGE_FROM"; branch="${!infos}"
+    [ -n "$branch" ] && echo "$branch" || return 1
+}
+
+function get_merge_to_branch() {
+    # afficher la branche dans laquelle la branche $1 doit merger
+    # retourner 1 en cas d'erreur (pas de branche destination)
+    local branch="$1" infos
+    [ -n "$branch" ] || return 1
+    infos="${branch^^}_MERGE_TO"; branch="${!infos}"
+    [ -n "$branch" ] && echo "$branch" || return 1
+}
+
+function should_prel_merge() {
+    # tester si la branche $1 doit être mergée avec prel dans la direction
+    # $2(=to)
+    local branch="$1" merge_dir="${2:-to}" infos
+    [ -n "$branch" ] || return 1
+    infos="${branch^^}_PREL"
+    [ "${!infos}" == "$merge_dir" ]
+}
+
+function should_delete_merged() {
+    # tester si la branche $1 doit être supprimée après avoir été mergée dans la
+    # direction $2(=to)
+    local branch="$1" merge_dir="${2:-to}" infos
+    [ -n "$branch" ] || return 1
+    infos="${branch^^}_DELETE"
+    [ "${!infos}" == "$merge_dir" ]
+}
+
+: "
+# description des variables #
+
+* REF_BRANCH -- code de la branche de référence basé sur le nom de l'outil
+* RefBranch -- nom effectif de la branche si elle est définie dans
+  .pman.conf, vide sinon
+* IfRefBranch -- nom effectif de la branche *si elle existe*, vide sinon
+
+* REF_UNIQUE -- si la branche de référence est unique. est vide pour les
+  codes de branches multiples, telle que FEATURE
+
+* BASE_BRANCH -- branche de base à partir de laquelle créer la branche
+  de référence
+* BaseBranch -- nom effectif de la branche de base si elle est définie
+  dans .pman.conf, vide sinon
+* IfBaseBranch -- nom effectif de la branche de base *si elle existe*, vide
+  sinon
+
+* MERGE_FROM -- code de la branche source à partir de laquelle la fusion
+  est faite dans REF_BRANCH. vide si la branche n'a pas de source
+* MERGE_TO -- code de la branche destination dans laquelle la fusion est
+  faite depuis REF_BRANCH. vide si la branche n'a pas de destination
+* MERGE_DIR -- direction de la fusion:
+  'from' si on fait REF_BRANCH --> MERGE_TO
+  'to' si on fait MERGE_FROM --> REF_BRANCH
+* PREL_MERGE -- si la fusion devrait se faire avec prel
+* DELETE_MERGED -- s'il faut supprimer la branche source après la fusion
+
+* MERGE_SRC -- code de la branche source pour la fusion, ou vide si la
+  fusion n'est pas possible
+* MergeSrc -- nom effectif de la branche source si elle est définie
+  dans .pman.conf
+* IfMergeSrc -- nom effectif de la branche source *si elle existe*, vide
+  sinon
+
+* MERGE_DEST -- code de la branche destination pour la fusion, ou vide si
+  la fusion n'est pas possible
+* MergeDest -- nom effectif de la branche destination si elle est
+  définie dans .pman.conf
+* IfMergeDest -- nom effectif de la branche source *si elle existe*, vide
+  sinon"
+
+function set_pman_vars() {
+    RefBranch="${!REF_BRANCH}"
+    case "$REF_BRANCH" in
+    FEATURE|RELEASE|HOTFIX) REF_UNIQUE=;;
+    *) REF_UNIQUE=1;;
+    esac
+
+    BASE_BRANCH=$(get_base_branch "$REF_BRANCH")
+    [ -n "$BASE_BRANCH" ] && BaseBranch="${!BASE_BRANCH}" || BaseBranch=
+
+    MERGE_FROM=$(get_merge_from_branch "$REF_BRANCH")
+    MERGE_TO=$(get_merge_to_branch "$REF_BRANCH")
+    if [ -n "$1" ]; then MERGE_DIR="$1"
+    else MERGE_DIR=from
+    fi
+    PREL_MERGE=$(should_prel_merge "$REF_BRANCH" "$MERGE_DIR" && echo 1)
+    DELETE_MERGED=$(should_delete_merged "$REF_BRANCH" "$MERGE_DIR" && echo 1)
+    case "$MERGE_DIR" in
+    from)
+        MERGE_SRC="$REF_BRANCH"
+        MERGE_DEST="$MERGE_TO"
+        ;;
+    to)
+        MERGE_SRC="$MERGE_FROM"
+        MERGE_DEST="$REF_BRANCH"
+        ;;
+    esac
+
+    [ -n "$MERGE_SRC" ] && MergeSrc="${!MERGE_SRC}" || MergeSrc=
+    [ -n "$MERGE_DEST" ] && MergeDest="${!MERGE_DEST}" || MergeDest=
+}
+
+################################################################################
+
 function _init_changelog() {
     setx date=date +%d/%m/%Y-%H:%M
     ac_set_tmpfile changelog
@@ -77,7 +214,7 @@ $1 == "|" {
 }
 
 function _list_commits() {
-    local source="${1:-$SrcBranch}" dest="${2:-$DestBranch}" mergebase
+    local source="${1:-$MergeSrc}" dest="${2:-$MergeDest}" mergebase
     setx mergebase=git merge-base "$dest" "$source"
     git log --oneline --graph --no-decorate "$mergebase..$source" |
         grep -vF '|\' | grep -vF '|/' | sed -r 's/^(\| )+\* +/| /; s/^\* +/+ /' |
@@ -85,7 +222,7 @@ function _list_commits() {
 }
 
 function _show_diff() {
-    local source="${1:-$SrcBranch}" dest="${2:-$DestBranch}" mergebase
+    local source="${1:-$MergeSrc}" dest="${2:-$MergeDest}" mergebase
     setx mergebase=git merge-base "$dest" "$source"
     git diff ${_sd_COLOR:+--color=$_sd_COLOR} "$mergebase..$source"
 }
@@ -147,22 +284,27 @@ EOF
 ################################################################################
 # Config
 
-function ensure_gitdir() {
+function check_gitdir() {
     # commencer dans le répertoire indiqué
     local chdir="$1"
     if [ -n "$chdir" ]; then
-        cd "$chdir" || die || return
+        cd "$chdir" || return 1
     fi
 
     # se mettre à la racine du dépôt git
     local gitdir
-    git_ensure_gitvcs
+    git_check_gitvcs || return 1
     setx gitdir=git_get_toplevel
-    cd "$gitdir" || die || return
+    cd "$gitdir" || return 1
+}
+
+function ensure_gitdir() {
+    # commencer dans le répertoire indiqué
+    check_gitdir "$@" || die || return 1
 }
 
 function load_branches() {
-    local what="${1:-all}"; shift
+    local branch what="${1:-all}"; shift
     case "$what" in
     all)
         [ -n "$Origin" ] || Origin=origin
@@ -172,30 +314,6 @@ function load_branches() {
         setx -a AllBranches=git_list_pbranches "$Origin"
         ;;
     current)
-        SrcBranch="$1"
-        [ -n "$SrcBranch" ] || SrcBranch="$CurrentBranch"
-        case "$SrcBranch" in
-        "$UPSTREAM") SrcType=upstream; DestBranch="$DEVELOP";;
-        "$FEATURE"*) SrcType=feature; DestBranch="$DEVELOP";;
-        "$DEVELOP") SrcType=develop; DestBranch="$MAIN";;
-        "$RELEASE"*) SrcType=release; DestBranch="$MAIN";;
-        "$HOTFIX"*) SrcType=hotfix; DestBranch="$MAIN";;
-        "$MAIN") SrcType=main; DestBranch="$DIST";;
-        "$DIST") SrcType=dist; DestBranch=;;
-        *) SrcType=; DestBranch=;;
-        esac
-        case "$DestBranch" in
-        "$UPSTREAM") DestType=upstream;;
-        "$FEATURE"*) DestType=feature;;
-        "$DEVELOP") DestType=develop;;
-        "$RELEASE"*) DestType=release;;
-        "$HOTFIX"*) DestType=hotfix;;
-        "$MAIN") DestType=main;;
-        "$DIST") DestType=dist;;
-        *) DestType=;;
-        esac
-
-        local branch
         UpstreamBranch=
         FeatureBranches=()
         DevelopBranch=
@@ -203,23 +321,32 @@ function load_branches() {
         HotfixBranch=
         MainBranch=
         DistBranch=
+        IfRefBranch=
+        IfBaseBranch=
+        IfMergeSrc=
+        IfMergeDest=
         for branch in "${LocalBranches[@]}"; do
             if [ "$branch" == "$UPSTREAM" ]; then
                 UpstreamBranch="$branch"
-            elif [[ "$branch" == "$FEATURE"* ]]; then
+            elif [ -n "$FEATURE" ] && [[ "$branch" == "$FEATURE"* ]]; then
                 FeatureBranches+=("$branch")
-            elif [ "$branch" == "$DEVELOP" ]; then
+            elif [ -n "$DEVELOP" -a "$branch" == "$DEVELOP" ]; then
                 DevelopBranch="$branch"
-            elif [[ "$branch" == "$RELEASE"* ]]; then
+            elif [ -n "$RELEASE" ] && [[ "$branch" == "$RELEASE"* ]]; then
                 ReleaseBranch="$branch"
-            elif [[ "$branch" == "$HOTFIX"* ]]; then
+            elif [ -n "$HOTFIX" ] && [[ "$branch" == "$HOTFIX"* ]]; then
                 HotfixBranch="$branch"
-            elif [ "$branch" == "$MAIN" ]; then
+            elif [ -n "$MAIN" -a "$branch" == "$MAIN" ]; then
                 MainBranch="$branch"
-            elif [ "$branch" == "$DIST" ]; then
+            elif [ -n "$DIST" -a "$branch" == "$DIST" ]; then
                 DistBranch="$branch"
             fi
+            [ -n "$RefBranch" -a "$branch" == "$RefBranch" ] && IfRefBranch="$branch"
+            [ -n "$BaseBranch" -a "$branch" == "$BaseBranch" ] && IfBaseBranch="$branch"
+            [ -n "$MergeSrc" -a "$branch" == "$MergeSrc" ] && IfMergeSrc="$branch"
+            [ -n "$MergeDest" -a "$branch" == "$MergeDest" ] && IfMergeDest="$branch"
         done
+        [ -n "$IfMergeSrc" -a "$IfMergeDest" ] && IfCanMerge=1 || IfCanMerge=
         ;;
     esac
 }
@@ -244,9 +371,6 @@ function load_config() {
     elif [ -f .pman.conf ]; then
         ConfigFile="$(pwd)/.pman.conf"
         source "$ConfigFile"
-    elif [ -n "$1" -a -n "${MYNAME#$1}" ]; then
-        ConfigFile="$NULIBDIR/bash/src/pman${MYNAME#$1}.conf.sh"
-        source "$ConfigFile"
     else
         ConfigFile="$NULIBDIR/bash/src/pman.conf.sh"
     fi
@@ -319,10 +443,8 @@ function _mscript_start() {
 #!/bin/bash
 $(qvals source "$NULIBDIR/load.sh") || exit 1
 
-$(echo_setv SrcBranch="$SrcBranch")
-$(echo_setv SrcType="$SrcType")
-$(echo_setv DestBranch="$DestBranch")
-$(echo_setv DestType="$DestType")
+$(echo_setv MergeSrc="$MergeSrc")
+$(echo_setv MergeDest="$MergeDest")
 
 merge=
 delete=
@@ -342,32 +464,32 @@ function _mscript_merge_branch() {
     local msg
 
     # basculer sur la branche
-    _scripta "switch to branch $DestBranch" <>"$changelog"
-    if [ -s CHANGES.md ]; then
-        echo >>"$changelog"
-        cat CHANGES.md >>"$changelog"
-    fi
     "${EDITOR:-nano}" +7 "$changelog"
     [ -s "$changelog" ] || exit_with ewarn "Création de la release annulée"
 
     # créer la branche de release et basculer dessus
     _scripta "create branch $ReleaseBranch" <CHANGES.md
+')") >"\$tmpchanges"
+if [ -s CHANGES.md ]; then
+  echo >>"\$tmpchanges"
+  cat CHANGES.md >>"\$tmpchanges"
+fi
+cat "\$tmpchanges" >CHANGES.md
+rm -f "\$tmpchanges"
 git add CHANGES.md
 EOF
 
@@ -471,3 +594,176 @@ function _rscript_delete_release_branch() {
 $comment$(qvals git branch -D "$ReleaseBranch")$or_die
 EOF
 }
+
+################################################################################
+# Outils
+
+function dump_action() {
+    enote "Valeurs des variables:
+REF_BRANCH=$REF_BRANCH${RefBranch:+ RefBranch=$RefBranch IfRefBranch=$IfRefBranch}
+BASE_BRANCH=$BASE_BRANCH${BaseBranch:+ BaseBranch=$BaseBranch IfBaseBranch=$IfBaseBranch}
+MERGE_FROM=$MERGE_FROM
+MERGE_TO=$MERGE_TO
+MERGE_DIR=$MERGE_DIR
+PREL_MERGE=$PREL_MERGE
+DELETE_MERGED=$DELETE_MERGED
+MERGE_SRC=$MERGE_SRC${MergeSrc:+ MergeSrc=$MergeSrc IfMergeSrc=$IfMergeSrc}
+MERGE_DEST=$MERGE_DEST${MergeDest:+ MergeDest=$MergeDest IfMergeDest=$IfMergeDest}
+
+CurrentBranch=$CurrentBranch
+LocalBranches=${LocalBranches[*]}
+RemoteBranches=${RemoteBranches[*]}
+AllBranches=${AllBranches[*]}
+
+UpstreamBranch=$UpstreamBranch
+FeatureBranches=${FeatureBranches[*]}
+DevelopBranch=$DevelopBranch
+ReleaseBranch=$ReleaseBranch
+HotfixBranch=$HotfixBranch
+MainBranch=$MainBranch
+DistBranch=$DistBranch
+"
+}
+
+function resolve_unique_branch() {
+    if [ "$REF_BRANCH" == FEATURE ]; then
+        if [ $# -gt 0 ]; then
+            RefBranch="$FEATURE${1#$FEATURE}"
+        elif [[ "$CurrentBranch" == "$FEATURE"* ]]; then
+            RefBranch="$CurrentBranch"
+        elif [ ${#FeatureBranches[*]} -eq 0 ]; then
+            die "Vous devez spécifier la branche de feature"
+        elif [ ${#FeatureBranches[*]} -eq 1 ]; then
+            RefBranch="${FeatureBranches[0]}"
+        else
+            simple_menu \
+                RefBranch FeatureBranches \
+                -t "Branches de feature" \
+                -m "Veuillez choisir la branche de feature" \
+                -d "${FeatureBranches[0]}"
+        fi
+    else
+        die "resolve_unique_branch: $REF_BRANCH: non implémenté"
+    fi
+    if [ "$MERGE_DIR" == from ]; then
+        MergeSrc="$RefBranch"
+    elif [ "$MERGE_DIR" == to ]; then
+        MergeDest="$RefBranch"
+    fi
+}
+
+function _ensure_ref_branch() {
+    [ -n "$RefBranch" ] || die "\
+La branche $REF_BRANCH n'a pas été définie.
+Veuillez éditer le fichier .pman.conf"
+    [ "$1" == init -o -n "$IfRefBranch" ] || die "$RefBranch: cette branche n'existe pas (le dépôt a-t-il été initialisé?)"
+}
+
+function _ensure_base_branch() {
+    [ -n "$BaseBranch" ] || die "\
+La branche $BASE_BRANCH n'a pas été définie.
+Veuillez éditer le fichier .pman.conf"
+    [ "$1" == init -o -n "$IfBaseBranch" ] || die "$BaseBranch: cette branche n'existe pas (le dépôt a-t-il été initialisé?)"
+}
+
+function _create_default_action() {
+    enote "Vous allez créer la branche ${COULEUR_BLEUE}$RefBranch${COULEUR_NORMALE} <-- ${COULEUR_ROUGE}$BaseBranch${COULEUR_NORMALE}"
+    ask_yesno "Voulez-vous continuer?" O || die
+
+    einfo "Création de la branche $RefBranch"
+    git checkout -b "$RefBranch" "$BaseBranch" || die
+    push_branches+=("$RefBranch")
+}
+
+function _create_upstream_action() {
+    enote "Vous allez créer la branche ${COULEUR_BLEUE}$RefBranch${COULEUR_NORMALE}"
+    ask_yesno "Voulez-vous continuer?" O || die
+
+    # faire une copie de la configuration actuelle
+    local config; ac_set_tmpfile config
+    set -x; ls -l "$ConfigFile" #XXX
+    cp "$ConfigFile" "$config"
+    set +x #XXX
+
+    einfo "Création de la branche $RefBranch"
+    git checkout --orphan "$RefBranch" || die
+    git rm -rf .
+    cp "$config" .pman.conf
+    git add .pman.conf
+    git commit -m "commit initial"
+    push_branches+=("$RefBranch")
+
+    einfo "Fusion dans $DevelopBranch"
+    git checkout "$DevelopBranch"
+    git merge \
+        --no-ff -m "Intégration initiale de la branche $RefBranch" \
+        -srecursive -Xours --allow-unrelated-histories \
+        "$RefBranch"
+    push_branches+=("$DevelopBranch")
+}
+
+function checkout_action() {
+    local -a push_branches
+
+    [ -n "$REF_UNIQUE" ] || resolve_unique_branch "$@"
+    _ensure_ref_branch init
+
+    if array_contains LocalBranches "$RefBranch"; then
+        git checkout "$RefBranch"
+    elif array_contains AllBranches "$RefBranch"; then
+        enote "$RefBranch: une branche du même nom existe dans l'origine"
+        ask_yesno "Voulez-vous basculer sur cette branche?" O || die
+        git checkout "$RefBranch"
+    else
+        _ensure_base_branch
+        resolve_should_push
+
+        local create_function
+        create_function="${REF_BRANCH}_CREATE_FUNCTION"; create_function="${!create_function}"
+        [ -n "$create_function" ] || create_function=_create_default_action
+        "$create_function"
+
+        _push_branches
+    fi
+}
+
+function ensure_merge_branches() {
+    [ -n "$MergeSrc" ] || die "\
+$RefBranch: configuration de fusion non trouvée: la branche $MERGE_SRC n'a pas été définie.
+Veuillez éditer le fichier .pman.conf"
+    [ -n "$MergeDest" ] || die "\
+$RefBranch: configuration de fusion non trouvée: la branche $MERGE_DEST n'a pas été définie.
+Veuillez éditer le fichier .pman.conf"
+
+    local branches
+    [ "$1" == -a ] && branches=AllBranches || branches=LocalBranches
+    array_contains "$branches" "$MergeSrc" || die "$MergeSrc: branche source introuvable"
+    array_contains "$branches" "$MergeDest" || die "$MergeDest: branche destination introuvable"
+}
+
+function _show_action() {
+    local commits
+    setx commits=_list_commits "$MergeSrc" "$MergeDest"
+    if [ -n "$commits" ]; then
+        if [ $ShowLevel -ge 2 ]; then
+            {
+                echo "\
+# Commits à fusionner $MergeSrc --> $MergeDest
+
+$commits
+"
+                _sd_COLOR=always _show_diff
+            } | less -eRF
+        else
+            einfo "Commits à fusionner $MergeSrc --> $MergeDest"
+            eecho "$commits"
+        fi
+    fi
+}
+
+function show_action() {
+    git_check_cleancheckout || ewarn "$git_cleancheckout_DIRTY"
+    [ -n "$REF_UNIQUE" ] || resolve_unique_branch "$@"
+    ensure_merge_branches
+    _show_action "$@"
+}
diff --git a/bash/src/pman74.conf.sh b/bash/src/pman74.conf.sh
index f179165..ab4453f 100644
--- a/bash/src/pman74.conf.sh
+++ b/bash/src/pman74.conf.sh
@@ -1,14 +1,10 @@
 # -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
 
-## configuration de la branche 7.4 d'un projet PHP multiversion
-# il s'agit d'un projet avec deux branches parallèles: 7.4 et 8.2, les
-# modifications de la 7.4 étant incluses dans la branche 8.2
-
 UPSTREAM=
 DEVELOP=dev74
 FEATURE=wip74/
 RELEASE=rel74-
-MAIN=dist74
+MAIN=main74
 TAG_PREFIX=
 TAG_SUFFIX=p74
 HOTFIX=hotf74-
diff --git a/bash/src/pman82.conf.sh b/bash/src/pman82.conf.sh
index 85262bc..b6393be 100644
--- a/bash/src/pman82.conf.sh
+++ b/bash/src/pman82.conf.sh
@@ -1,14 +1,10 @@
 # -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
 
-## configuration de la branche 8.2 d'un projet PHP multiversion
-# il s'agit d'un projet avec deux branches parallèles: 7.4 et 8.2, les
-# modifications de la 7.4 étant incluses dans la branche 8.2
-
 UPSTREAM=dev74
 DEVELOP=dev82
 FEATURE=wip82/
 RELEASE=rel82-
-MAIN=dist82
+MAIN=main82
 TAG_PREFIX=
 TAG_SUFFIX=p82
 HOTFIX=hotf82-
diff --git a/bash/src/pman84.conf.sh b/bash/src/pman84.conf.sh
new file mode 100644
index 0000000..63590c4
--- /dev/null
+++ b/bash/src/pman84.conf.sh
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
+
+UPSTREAM=dev74
+DEVELOP=dev84
+FEATURE=wip84/
+RELEASE=rel84-
+MAIN=main84
+TAG_PREFIX=
+TAG_SUFFIX=p84
+HOTFIX=hotf84-
+DIST=
+NOAUTO=
diff --git a/bash/src/template.sh b/bash/src/template.sh
index c3d3376..f97506c 100644
--- a/bash/src/template.sh
+++ b/bash/src/template.sh
@@ -22,7 +22,12 @@ et \$2 vaudra alors 'file'
 si un fichier \${2#.}.local existe (e.g 'file.ext.local'), prendre ce fichier à
 la place comme source
 
-Ajouter file au tableau userfiles"
+Ajouter file au tableau userfiles
+
+retourner:
+- 0 en cas de copie avec succès
+- 2 si la source n'existe pas
+- 3 si une erreur I/O s'est produite lors de la copie"
 function template_copy_replace() {
     local src="$1" dest="$2"
     local srcdir srcname lsrcname
@@ -37,8 +42,28 @@ function template_copy_replace() {
     lsrcname="${srcname#.}.local"
     [ -e "$srcdir/$lsrcname" ] && src="$srcdir/$lsrcname"
 
+    [ -e "$src" ] || return 2
+
     userfiles+=("$dest")
-    cp -P "$src" "$dest"
+    local have_backup
+    if [ -e "$dest" ]; then
+        # copie de sauvegarde avant
+        if ! cp -P --preserve=all "$dest" "$dest.bck.$$"; then
+            rm "$dest.bck.$$"
+            return 3
+        fi
+        have_backup=1
+    fi
+    if ! cp -P "$src" "$dest"; then
+        rm "$dest"
+        if [ -n "$have_backup" ]; then
+            # restaurer la sauvegarde en cas d'erreur
+            cp -P --preserve=all "$dest.bck.$$" "$dest" &&
+                rm "$dest.bck.$$"
+        fi
+        return 3
+    fi
+    [ -n "$have_backup" ] && rm "$dest.bck.$$"
     return 0
 }
 
@@ -51,7 +76,13 @@ et \$2 vaudra alors 'file'
 si un fichier \${1#.}.local existe (e.g 'file.ext.local'), prendre ce fichier à
 la place comme source
 
-Ajouter file au tableau userfiles"
+Ajouter file au tableau userfiles
+
+retourner:
+- 0 en cas de copie avec succès
+- 1 si le fichier existait déjà
+- 2 si la source n'existe pas
+- 3 si une erreur I/O s'est produite lors de la copie"
 function template_copy_missing() {
     local src="$1" dest="$2"
     local srcdir srcname lsrcname
@@ -63,15 +94,33 @@ function template_copy_missing() {
         dest="$srcdir/$dest"
     fi
 
-    userfiles+=("$dest")
-    if [ ! -e "$dest" ]; then
-        lsrcname="${srcname#.}.local"
-        [ -e "$srcdir/$lsrcname" ] && src="$srcdir/$lsrcname"
+    lsrcname="${srcname#.}.local"
+    [ -e "$srcdir/$lsrcname" ] && src="$srcdir/$lsrcname"
 
-        cp -P "$src" "$dest"
-        return 0
+    [ -e "$src" ] || return 2
+
+    userfiles+=("$dest")
+    [ -e "$dest" ] && return 1
+
+    if ! cp -P "$src" "$dest"; then
+        # ne pas garder le fichier en cas d'erreur de copie
+        rm "$dest"
+        return 3
     fi
-    return 1
+    return 0
+}
+
+function: template_ioerror "\
+tester si une erreur de copie s'est produite lors de l'appel à
+template_copy_missing() ou template_copy_replace(), par exemple en cas de
+dépassement de capacité du disque ou si le fichier source n'existe pas
+
+il faut appeler cette fonction avec la valeur de retour de ces fonctions, e.g
+    template_copy_missing file
+    template_ioerror $? && die"
+function template_ioerror() {
+    local r="${1:-$?}"
+    [ $r -ge 2 ]
 }
 
 function: template_dump_vars "\
@@ -219,8 +268,13 @@ function _template_can_process() {
     esac
 }
 
+function: template_process_userfiles "\
+retourner:
+- 0 en cas de traitement avec succès des fichiers
+- 3 si une erreur I/O s'est produite lors du traitement d'un des fichiers"
 function template_process_userfiles() {
     local awkscript sedscript workfile userfile
+    local have_backup
     ac_set_tmpfile awkscript
     ac_set_tmpfile sedscript
     template_generate_scripts "$awkscript" "$sedscript" "$@"
@@ -231,10 +285,28 @@ function template_process_userfiles() {
         if cat "$userfile" | awk -f "$awkscript" | sed -rf "$sedscript" >"$workfile"; then
             if testdiff "$workfile" "$userfile"; then
                 # n'écrire le fichier que s'il a changé
-                cat "$workfile" >"$userfile"
+                if [ -e "$userfile" ]; then
+                    # copie de sauvegarde avant
+                    if ! cp -P --preserve=all "$userfile" "$userfile.bck.$$"; then
+                        rm "$userfile.bck.$$"
+                        return 3
+                    fi
+                    have_backup=1
+                fi
+                if ! cat "$workfile" >"$userfile"; then
+                    rm "$userfile"
+                    if [ -n "$have_backup" ]; then
+                        # restaurer la sauvegarde en cas d'erreur
+                        cp -P --preserve=all "$userfile.bck.$$" "$userfile" &&
+                            rm "$userfile.bck.$$"
+                    fi
+                    return 3
+                fi
+                [ -n "$have_backup" ] && rm "$userfile.bck.$$"
             fi
         fi
     done
 
     ac_clean "$awkscript" "$sedscript" "$workfile"
+    return 0
 }
diff --git a/bin/.cachectl.php b/bin/.cachectl.php
new file mode 120000
index 0000000..c9604f8
--- /dev/null
+++ b/bin/.cachectl.php
@@ -0,0 +1 @@
+../php/bin/cachectl.php
\ No newline at end of file
diff --git a/bin/.dumpser.php b/bin/.dumpser.php
new file mode 120000
index 0000000..46cfbdc
--- /dev/null
+++ b/bin/.dumpser.php
@@ -0,0 +1 @@
+../php/bin/dumpser.php
\ No newline at end of file
diff --git a/bin/.json2yml.php b/bin/.json2yml.php
new file mode 120000
index 0000000..ff6141b
--- /dev/null
+++ b/bin/.json2yml.php
@@ -0,0 +1 @@
+../php/bin/json2yml.php
\ No newline at end of file
diff --git a/bin/.mysql.capacitor.php b/bin/.mysql.capacitor.php
new file mode 120000
index 0000000..ab7aba5
--- /dev/null
+++ b/bin/.mysql.capacitor.php
@@ -0,0 +1 @@
+../php/bin/mysql.capacitor.php
\ No newline at end of file
diff --git a/bin/._pman-composer_local_deps.php b/bin/.pcomp-local_deps.php
similarity index 66%
rename from bin/._pman-composer_local_deps.php
rename to bin/.pcomp-local_deps.php
index 92aeda8..c99d615 100755
--- a/bin/._pman-composer_local_deps.php
+++ b/bin/.pcomp-local_deps.php
@@ -2,9 +2,7 @@
 .gitignore "\
+.~lock*#
+.*.swp"
+        git add .gitignore
+    fi
+    return 0
+}
+
+function init_repo_action() {
+    local -a push_branches; local config
+
+    [ ${#LocalBranches[*]} -eq 0 ] || die "Ce dépôt a déjà été initialisé"
+
+    _init_config || exit_with ewarn "Initialisation du dépôt annulée"
+
+    einfo "Création de la branche $MAIN"
+    git symbolic-ref HEAD "refs/heads/$MAIN"
+    git commit -m "commit initial"
+    push_branches+=("$MAIN")
+
+    einfo "Création de la branche $DEVELOP"
+    git checkout -b "$DEVELOP"
+    push_branches+=("$DEVELOP")
+
+    _push_branches
+}
+
+function init_config_action() {
+    local -a push_branches; local config
+
+    [ -f .pman.conf -a -z "$ForceCreate" ] && die "La configuration pman a déjà été initialisée"
+
+    resolve_should_push
+
+    _init_config || exit_with ewarn "Initialisation de la configuration annulée"
+    git commit -m "configuration pman"
+    push_branches+=("$CurrentBranch")
+
+    _push_branches
+}
+
+function _init_composer() {
+    if [ ! -f .composer.pman.yml -o -n "$ForceCreate" ]; then
+        ac_set_tmpfile config
+        cat >"$config" < $DestBranch
-
-$commits
-"
-                _sd_COLOR=always _show_diff
-            } | less -eRF
-        else
-            einfo "Commits à fusionner $SrcBranch --> $DestBranch"
-            eecho "$commits"
-        fi
-    fi
-}
-
-function ensure_branches() {
-   [ -n "$SrcBranch" -a -n "$DestBranch" ] ||
-        die "$SrcBranch: Aucune configuration de fusion trouvée pour cette branche"
-
-   array_contains LocalBranches "$SrcBranch" || die "$SrcBranch: branche source introuvable"
-   array_contains LocalBranches "$DestBranch" || die "$DestBranch: branche destination introuvable"
-
+function ensure_rel_infos() {
    Tag="$TAG_PREFIX$Version$TAG_SUFFIX"
    local -a tags
    setx -a tags=git tag -l "${TAG_PREFIX}*${TAG_SUFFIX}"
@@ -71,14 +45,14 @@ L'option --no-push a été forcée puisque ce dépôt n'a pas d'origine"
     if [ -n "$Merge" ]; then
         enote "\
 Ce script va:
-- créer la branche de release ${COULEUR_VERTE}$ReleaseBranch${COULEUR_NORMALE} <-- ${COULEUR_BLEUE}$SrcBranch${COULEUR_NORMALE}
+- créer la branche de release ${COULEUR_VERTE}$ReleaseBranch${COULEUR_NORMALE} <-- ${COULEUR_BLEUE}$MergeSrc${COULEUR_NORMALE}
 - la provisionner avec une description des changements
-- la fusionner dans la branche destination ${COULEUR_ROUGE}$DestBranch${COULEUR_NORMALE}${Push:+
+- la fusionner dans la branche destination ${COULEUR_ROUGE}$MergeDest${COULEUR_NORMALE}${Push:+
 - pousser les branches modifiées}"
     else
         enote "\
 Ce script va:
-- créer la branche de release ${COULEUR_VERTE}$ReleaseBranch${COULEUR_NORMALE} <-- ${COULEUR_BLEUE}$SrcBranch${COULEUR_NORMALE}
+- créer la branche de release ${COULEUR_VERTE}$ReleaseBranch${COULEUR_NORMALE} <-- ${COULEUR_BLEUE}$MergeSrc${COULEUR_NORMALE}
 - la provisionner avec une description des changements
 Vous devrez:
 - mettre à jour les informations de release puis relancer ce script"
@@ -123,8 +97,8 @@ EOF
 $BEFORE_MERGE_RELEASE
 )$or_die
 EOF
-    _rscript_merge_release_branch "$DestBranch" "$Tag"
-    _rscript_merge_release_branch "$SrcBranch"
+    _rscript_merge_release_branch "$MergeDest" "$Tag"
+    _rscript_merge_release_branch "$MergeSrc"
     _rscript_delete_release_branch
     [ -n "$AFTER_MERGE_RELEASE" ] && _scripta < DEST. DEST est calculé en fonction de REF
+    -t, --merge-to REF
+        spécifier la branche de référence et indiquer que la fusion se fait dans
+        le sens SRC --> REF. SRC est calculé en fonction de REF"
+    fi
+
+    ref="$1"; shift
+    merge_dir=to
+    [ -n "$ref" ] || die "vous spécifier la branche de référence"
+
+    case "$ref" in
+    -f|--merge-from)
+        ref="$1"; shift
+        merge_dir=from
+        ;;
+    -f*)
+        ref="${ref#-f}"
+        merge_dir=from
+        ;;
+    -t|--merge-to)
+        ref="$1"; shift
+        merge_dir=to
+        ;;
+    -t*)
+        ref="${ref#-t}"
+        merge_dir=to
+        ;;
+    esac
+    REF_BRANCH="${ref^^}"
+    array_contains PMAN_BRANCHES "$REF_BRANCH" || die "$ref: invalid branch"
+
+else
+    REF_BRANCH="PMAN_TOOL_${MYNAME^^}"; REF_BRANCH="${!REF_BRANCH}"
+fi
+
+if check_gitdir; then
+    load_branches all
+    load_config
+    set_pman_vars "$merge_dir"
+    load_branches current
+    loaded_config=1
+else
+    set_pman_vars "$merge_dir"
+fi
+
+RefDesc=
+MergeSrcDesc=
+MergeDestDesc=
+if [ -n "$REF_BRANCH" ]; then
+    RefDesc="${COULEUR_BLANCHE}<$REF_BRANCH>"
+    [ -n "$RefBranch" -a -n "$REF_UNIQUE" ] && RefDesc="$RefDesc ($RefBranch)"
+    RefDesc="$RefDesc${COULEUR_NORMALE}"
+fi
+if [ -n "$MERGE_SRC" ]; then
+    MergeSrcDesc="${COULEUR_BLEUE}<$MERGE_SRC>"
+    [ -n "$MergeSrc" -a -n "$REF_UNIQUE" ] && MergeSrcDesc="$MergeSrcDesc ($MergeSrc)"
+    MergeSrcDesc="$MergeSrcDesc${COULEUR_NORMALE}"
+fi
+if [ -n "$MERGE_DEST" ]; then
+    MergeDestDesc="${COULEUR_ROUGE}<$MERGE_DEST>"
+    [ -n "$MergeDest" -a -n "$REF_UNIQUE" ] && MergeDestDesc="$MergeDestDesc ($MergeDest)"
+    MergeDestDesc="$MergeDestDesc${COULEUR_NORMALE}"
+fi
+
+if [ -n "$REF_UNIQUE" ]
+then purpose="gérer la branche $RefDesc"
+else purpose="gérer les branches $RefDesc"
+fi
+usage="--checkout"
+variables=
+
+chdir_def=(chdir= "répertoire dans lequel se placer avant de lancer les opérations")
+origin_def=(Origin= "++origine à partir de laquelle les branches distantes sont considérées")
+config_branch_def=(ConfigBranch= "++branche à partir de laquelle charger la configuration")
+config_file_def=(ConfigFile= "++\
+fichier de configuration des branches. le fichier .pman.conf dans le répertoire
+du dépôt est utilisé par défaut s'il existe. cette option est prioritaire sur
+--config-branch")
+fake_def=(_Fake=1 "++option non documentée")
+keep_script_def=(_KeepScript=1 "++option non documentée")
+dump_action_def=(action=dump "++afficher les noms des branches")
+checkout_action_def=('$:' "++non applicable")
+show_action_def=('$:' "++non applicable")
+rebase_action_def=('$:' "++non applicable")
+merge_action_def=('$:' "++non applicable")
+tech_merge_def=('$:' "++non applicable")
+squash_def=('$:' "++non applicable")
+force_merge_def=('$:' "++non applicable")
+no_push_def=('$:' "++non applicable")
+push_def=('$:' "++non applicable")
+no_delete_def=('$:' "++non applicable")
+delete_def=('$:' "++non applicable")
+after_merge_def=('$:' "++non applicable")
+
+if [ -n "$RefBranch" -a -n "$REF_UNIQUE" ]; then
+    checkout_action_def=(action=checkout "++\
+créer le cas échéant la branche $RefDesc et basculer vers elle.
+c'est l'option par défaut")
+elif [ -z "$REF_UNIQUE" ]; then
+    checkout_action_def=(action=checkout "\
+créer le cas échéant la branche $RefDesc et basculer vers elle.
+c'est l'option par défaut")
+else
+    checkout_action_def=(action=checkout "\
+créer la branche $MergeDestDesc et basculer vers elle.
+c'est l'option par défaut")
+fi
+
+if [ -n "$MERGE_SRC" -a -n "$MERGE_DEST" ]; then
+    if [ -n "$REF_UNIQUE" ]
+    then usage="${usage}|--show|--merge"
+    else usage="${usage} $REF_BRANCH
+--show|--merge"
+    fi
+    if [ "$REF_BRANCH" != "$MERGE_SRC" ]
+    then bewareDir="
+NB: la fusion se fait dans le sens inverse"
+    else bewareDir=
+    fi
+    variables="Les variables supplémentaires suivantes peuvent être définies:
+    BEFORE_MERGE_${MERGE_SRC}
+    AFTER_MERGE_${MERGE_SRC}"
+
+    show_action_def=('$action=show; inc@ ShowLevel' "\
+lister ce qui serait fusionné dans la branche $MergeDestDesc")
+    rebase_action_def=('$:' "++non implémenté")
+#    rebase_action_def=(action=rebase "\
+#lancer git rebase -i sur la branche $MergeSrcDesc. cela permet de réordonner
+#les commits pour nettoyer l'historique avant la fusion")
+    merge_action_def=(action=merge "\
+fusionner la branche $MergeSrcDesc dans la branche $MergeDestDesc$bewareDir")
+    tech_merge_def=('$action=merge; TechMerge=1' "++option non documentée")
+    squash_def=('$action=merge; res@ SquashMsg' "fusionner les modifications de la branche comme un seul commit")
+    [ -n "$PREL_MERGE" ] && force_merge_def=(ForceMerge=1 "++\
+forcer la fusion pour une branche qui devrait être traitée par prel")
+    no_push_def=(Push= "ne pas pousser les branches vers leur origine après la fusion")
+    push_def=(Push=1 "++\
+pousser les branches vers leur origine après la fusion.
+c'est l'option par défaut")
+
+    if [ -n "$DELETE_MERGED" ]; then
+        variables="${variables}
+    AFTER_DELETE_${MERGE_SRC}"
+        no_delete_def=(Delete= "\
+ne pas supprimer la branche $MergeSrcDesc après la fusion dans la branche
+$MergeDestDesc. cette option ne devrait pas être utilisée avec --squash")
+        delete_def=(Delete=1 "++\
+supprimer la branche $MergeSrcDesc après la fusion dans la branche
+$MergeDestDesc.
+c'est l'option par défaut")
+    fi
+
+    [ -n "$MERGE_DEST" ] && variables="${variables}
+    BEFORE_PUSH_${MERGE_DEST}
+    AFTER_PUSH_${MERGE_DEST}"
+
+    after_merge_def=(AfterMerge= "évaluer le script spécifié après une fusion *réussie*")
+fi
+
+args=(
+    "$purpose"
+    "\
+ $usage
+
+CONFIGURATION
+
+Le fichier .pman.conf contient la configuration des branches.
+$variables"
+    -d:,--chdir:BASEDIR "${chdir_def[@]}"
+    -O:,--origin "${origin_def[@]}"
+    -B:,--config-branch "${config_branch_def[@]}"
+    -c:,--config-file:CONFIG  "${config_file_def[@]}"
+    --fake "${fake_def[@]}"
+    --keep-script "${keep_script_def[@]}"
+    --dump "${dump_action_def[@]}"
+    --checkout "${checkout_action_def[@]}"
+    -w,--show "${show_action_def[@]}"
+    -b,--rebase "${rebase_action_def[@]}"
+    -m,--merge "${merge_action_def[@]}"
+    --tech-merge "${tech_merge_def[@]}"
+    -s:,--squash:COMMIT_MSG "${squash_def[@]}"
+    -f,--force-merge "${force_merge_def[@]}"
+    -n,--no-push "${no_push_def[@]}"
+    --push "${push_def[@]}"
+    -k,--no-delete "${no_delete_def[@]}"
+    --delete "${delete_def[@]}"
+    -a:,--after-merge "${after_merge_def[@]}"
+)
+parse_args "$@"; set -- "${args[@]}"
+
+if [ -z "$loaded_config" -o -n "$chdir" -o -n "$ConfigFile" -o -n "$ConfigBranch" ]; then
+    # charger la configuration
+    ensure_gitdir "$chdir"
+    load_branches all
+    load_config
+    set_pman_vars "$merge_dir"
+    load_branches current
+fi
+resolve_should_push quiet
+
+"${action}_action" "$@"
diff --git a/bin/pwip b/bin/pwip
deleted file mode 100755
index 787676b..0000000
--- a/bin/pwip
+++ /dev/null
@@ -1,60 +0,0 @@
-#!/bin/bash
-# -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
-source "$(dirname -- "$0")/../load.sh" || exit 1
-require: git pman pman.conf
-
-git_cleancheckout_DIRTY="\
-Vous avez des modifications locales.
-Enregistrez ces modifications avant de créer une nouvelle branche"
-
-chdir=
-Origin=
-ConfigBranch=
-ConfigFile=
-[ -z "$PMAN_NO_PUSH" ] && Push=1 || Push=
-args=(
-    "créer une branche de feature"
-    ""
-    -d:,--chdir:BASEDIR chdir= "répertoire dans lequel se placer avant de lancer les opérations"
-    -O:,--origin Origin= "++\
-origine à partir de laquelle les branches distantes sont considérées"
-    -B:,--config-branch ConfigBranch= "++\
-branche à partir de laquelle charger la configuration"
-    -c:,--config-file:CONFIG ConfigFile= "++\
-fichier de configuration des branches. cette option est prioritaire sur --config-branch
-par défaut, utiliser le fichier .pman.conf dans le répertoire du dépôt s'il existe"
-    -n,--no-push Push= "\
-ne pas pousser les branches vers leur origine après la fusion"
-    --push Push=1 "++\
-pousser les branches vers leur origine après la fusion.
-c'est l'option par défaut"
-)
-parse_args "$@"; set -- "${args[@]}"
-
-# charger la configuration
-ensure_gitdir "$chdir"
-load_branches all
-load_config "$MYNAME"
-load_branches current
-
-branch="$1"
-if [ -z "$branch" -a ${#FeatureBranches[*]} -eq 1 ]; then
-    branch="${FeatureBranches[0]}"
-fi
-[ -n "$branch" ] || die "Vous devez spécifier la branche à créer"
-branch="$FEATURE${branch#$FEATURE}"
-
-resolve_should_push
-git_ensure_cleancheckout
-
-if array_contains AllBranches "$branch"; then
-    git checkout -q "$branch"
-else
-    # si la branche source n'existe pas, la créer
-    args=(--origin "$Origin")
-    if [ -n "$ConfigFile" ]; then args+=(--config-file "$ConfigFile")
-    elif [ -n "$ConfigBranch" ]; then args+=(--config-branch "$ConfigBranch")
-    fi
-    [ -z "$Push" ] && args+=(--no-push)
-    exec "$MYDIR/pman" "${args[@]}" "$branch"
-fi
diff --git a/bin/pwip b/bin/pwip
new file mode 120000
index 0000000..22100e6
--- /dev/null
+++ b/bin/pwip
@@ -0,0 +1 @@
+ptool
\ No newline at end of file
diff --git a/bin/runphp b/bin/runphp
index 3e59b61..f15db8a 100755
--- a/bin/runphp
+++ b/bin/runphp
@@ -19,6 +19,7 @@ while true; do
     fi
     cd ..
 done
+cd "$owd"
 
 export RUNPHP_MOUNT=
 if [ "$MYNAME" == composer ]; then
diff --git a/bin/sqlite.capacitor.php b/bin/sqlite.capacitor.php
new file mode 120000
index 0000000..42fbf67
--- /dev/null
+++ b/bin/sqlite.capacitor.php
@@ -0,0 +1 @@
+runphp
\ No newline at end of file
diff --git a/bin/yml2json.php b/bin/yml2json.php
new file mode 120000
index 0000000..42fbf67
--- /dev/null
+++ b/bin/yml2json.php
@@ -0,0 +1 @@
+runphp
\ No newline at end of file
diff --git a/composer.json b/composer.json
index b0d3eba..f9a109a 100644
--- a/composer.json
+++ b/composer.json
@@ -18,7 +18,10 @@
 		"nulib/php": "*"
 	},
 	"require": {
-		"symfony/yaml": "^7.1",
+		"symfony/yaml": "^7.3",
+		"symfony/expression-language": "^7.3",
+		"phpmailer/phpmailer": "^6.8",
+		"league/commonmark": "^2.7",
 		"ext-json": "*",
 		"php": "^8.2"
 	},
@@ -35,7 +38,8 @@
 	},
 	"autoload": {
 		"psr-4": {
-			"nulib\\": "php/src"
+			"nulib\\": "php/src",
+      "cli\\": "php/cli"
 		}
 	},
 	"autoload-dev": {
@@ -43,6 +47,15 @@
 			"nulib\\": "php/tests"
 		}
 	},
+	"bin": [
+		"php/bin/cachectl.php",
+		"php/bin/dumpser.php",
+		"php/bin/json2yml.php",
+		"php/bin/yml2json.php",
+		"php/bin/sqlite.capacitor.php",
+		"php/bin/mysql.capacitor.php",
+		"php/bin/pgsql.capacitor.php"
+	],
 	"config": {
 		"vendor-dir": "php/vendor"
 	},
diff --git a/composer.lock b/composer.lock
index eb7f2b1..330ab8b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,875 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "71744d15224f445d1aeefe16ec7d1099",
+    "content-hash": "424dc194faea590269d136c8ffaf2505",
     "packages": [
+        {
+            "name": "dflydev/dot-access-data",
+            "version": "v3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/dflydev/dflydev-dot-access-data.git",
+                "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
+                "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^0.12.42",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
+                "scrutinizer/ocular": "1.6.0",
+                "squizlabs/php_codesniffer": "^3.5",
+                "vimeo/psalm": "^4.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Dflydev\\DotAccessData\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Dragonfly Development Inc.",
+                    "email": "info@dflydev.com",
+                    "homepage": "http://dflydev.com"
+                },
+                {
+                    "name": "Beau Simensen",
+                    "email": "beau@dflydev.com",
+                    "homepage": "http://beausimensen.com"
+                },
+                {
+                    "name": "Carlos Frutos",
+                    "email": "carlos@kiwing.it",
+                    "homepage": "https://github.com/cfrutos"
+                },
+                {
+                    "name": "Colin O'Dell",
+                    "email": "colinodell@gmail.com",
+                    "homepage": "https://www.colinodell.com"
+                }
+            ],
+            "description": "Given a deep data structure, access data by dot notation.",
+            "homepage": "https://github.com/dflydev/dflydev-dot-access-data",
+            "keywords": [
+                "access",
+                "data",
+                "dot",
+                "notation"
+            ],
+            "support": {
+                "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
+                "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
+            },
+            "time": "2024-07-08T12:26:09+00:00"
+        },
+        {
+            "name": "league/commonmark",
+            "version": "2.7.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/commonmark.git",
+                "reference": "10732241927d3971d28e7ea7b5712721fa2296ca"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca",
+                "reference": "10732241927d3971d28e7ea7b5712721fa2296ca",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "league/config": "^1.1.1",
+                "php": "^7.4 || ^8.0",
+                "psr/event-dispatcher": "^1.0",
+                "symfony/deprecation-contracts": "^2.1 || ^3.0",
+                "symfony/polyfill-php80": "^1.16"
+            },
+            "require-dev": {
+                "cebe/markdown": "^1.0",
+                "commonmark/cmark": "0.31.1",
+                "commonmark/commonmark.js": "0.31.1",
+                "composer/package-versions-deprecated": "^1.8",
+                "embed/embed": "^4.4",
+                "erusev/parsedown": "^1.0",
+                "ext-json": "*",
+                "github/gfm": "0.29.0",
+                "michelf/php-markdown": "^1.4 || ^2.0",
+                "nyholm/psr7": "^1.5",
+                "phpstan/phpstan": "^1.8.2",
+                "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
+                "scrutinizer/ocular": "^1.8.1",
+                "symfony/finder": "^5.3 | ^6.0 | ^7.0",
+                "symfony/process": "^5.4 | ^6.0 | ^7.0",
+                "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
+                "unleashedtech/php-coding-standard": "^3.1.1",
+                "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
+            },
+            "suggest": {
+                "symfony/yaml": "v2.3+ required if using the Front Matter extension"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "2.8-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "League\\CommonMark\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Colin O'Dell",
+                    "email": "colinodell@gmail.com",
+                    "homepage": "https://www.colinodell.com",
+                    "role": "Lead Developer"
+                }
+            ],
+            "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
+            "homepage": "https://commonmark.thephpleague.com",
+            "keywords": [
+                "commonmark",
+                "flavored",
+                "gfm",
+                "github",
+                "github-flavored",
+                "markdown",
+                "md",
+                "parser"
+            ],
+            "support": {
+                "docs": "https://commonmark.thephpleague.com/",
+                "forum": "https://github.com/thephpleague/commonmark/discussions",
+                "issues": "https://github.com/thephpleague/commonmark/issues",
+                "rss": "https://github.com/thephpleague/commonmark/releases.atom",
+                "source": "https://github.com/thephpleague/commonmark"
+            },
+            "funding": [
+                {
+                    "url": "https://www.colinodell.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.paypal.me/colinpodell/10.00",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/colinodell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/league/commonmark",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-07-20T12:47:49+00:00"
+        },
+        {
+            "name": "league/config",
+            "version": "v1.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/config.git",
+                "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
+                "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
+                "shasum": ""
+            },
+            "require": {
+                "dflydev/dot-access-data": "^3.0.1",
+                "nette/schema": "^1.2",
+                "php": "^7.4 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.8.2",
+                "phpunit/phpunit": "^9.5.5",
+                "scrutinizer/ocular": "^1.8.1",
+                "unleashedtech/php-coding-standard": "^3.1",
+                "vimeo/psalm": "^4.7.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.2-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "League\\Config\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Colin O'Dell",
+                    "email": "colinodell@gmail.com",
+                    "homepage": "https://www.colinodell.com",
+                    "role": "Lead Developer"
+                }
+            ],
+            "description": "Define configuration arrays with strict schemas and access values with dot notation",
+            "homepage": "https://config.thephpleague.com",
+            "keywords": [
+                "array",
+                "config",
+                "configuration",
+                "dot",
+                "dot-access",
+                "nested",
+                "schema"
+            ],
+            "support": {
+                "docs": "https://config.thephpleague.com/",
+                "issues": "https://github.com/thephpleague/config/issues",
+                "rss": "https://github.com/thephpleague/config/releases.atom",
+                "source": "https://github.com/thephpleague/config"
+            },
+            "funding": [
+                {
+                    "url": "https://www.colinodell.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.paypal.me/colinpodell/10.00",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/colinodell",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-12-11T20:36:23+00:00"
+        },
+        {
+            "name": "nette/schema",
+            "version": "v1.2.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nette/schema.git",
+                "reference": "0462f0166e823aad657c9224d0f849ecac1ba10a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nette/schema/zipball/0462f0166e823aad657c9224d0f849ecac1ba10a",
+                "reference": "0462f0166e823aad657c9224d0f849ecac1ba10a",
+                "shasum": ""
+            },
+            "require": {
+                "nette/utils": "^2.5.7 || ^3.1.5 ||  ^4.0",
+                "php": "7.1 - 8.3"
+            },
+            "require-dev": {
+                "nette/tester": "^2.3 || ^2.4",
+                "phpstan/phpstan-nette": "^1.0",
+                "tracy/tracy": "^2.7"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause",
+                "GPL-2.0-only",
+                "GPL-3.0-only"
+            ],
+            "authors": [
+                {
+                    "name": "David Grudl",
+                    "homepage": "https://davidgrudl.com"
+                },
+                {
+                    "name": "Nette Community",
+                    "homepage": "https://nette.org/contributors"
+                }
+            ],
+            "description": "📐 Nette Schema: validating data structures against a given Schema.",
+            "homepage": "https://nette.org",
+            "keywords": [
+                "config",
+                "nette"
+            ],
+            "support": {
+                "issues": "https://github.com/nette/schema/issues",
+                "source": "https://github.com/nette/schema/tree/v1.2.5"
+            },
+            "time": "2023-10-05T20:37:59+00:00"
+        },
+        {
+            "name": "nette/utils",
+            "version": "v3.2.10",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nette/utils.git",
+                "reference": "a4175c62652f2300c8017fb7e640f9ccb11648d2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nette/utils/zipball/a4175c62652f2300c8017fb7e640f9ccb11648d2",
+                "reference": "a4175c62652f2300c8017fb7e640f9ccb11648d2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2 <8.4"
+            },
+            "conflict": {
+                "nette/di": "<3.0.6"
+            },
+            "require-dev": {
+                "jetbrains/phpstorm-attributes": "dev-master",
+                "nette/tester": "~2.0",
+                "phpstan/phpstan": "^1.0",
+                "tracy/tracy": "^2.3"
+            },
+            "suggest": {
+                "ext-gd": "to use Image",
+                "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
+                "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
+                "ext-json": "to use Nette\\Utils\\Json",
+                "ext-mbstring": "to use Strings::lower() etc...",
+                "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()",
+                "ext-xml": "to use Strings::length() etc. when mbstring is not available"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause",
+                "GPL-2.0-only",
+                "GPL-3.0-only"
+            ],
+            "authors": [
+                {
+                    "name": "David Grudl",
+                    "homepage": "https://davidgrudl.com"
+                },
+                {
+                    "name": "Nette Community",
+                    "homepage": "https://nette.org/contributors"
+                }
+            ],
+            "description": "🛠  Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
+            "homepage": "https://nette.org",
+            "keywords": [
+                "array",
+                "core",
+                "datetime",
+                "images",
+                "json",
+                "nette",
+                "paginator",
+                "password",
+                "slugify",
+                "string",
+                "unicode",
+                "utf-8",
+                "utility",
+                "validation"
+            ],
+            "support": {
+                "issues": "https://github.com/nette/utils/issues",
+                "source": "https://github.com/nette/utils/tree/v3.2.10"
+            },
+            "time": "2023-07-30T15:38:18+00:00"
+        },
+        {
+            "name": "phpmailer/phpmailer",
+            "version": "v6.11.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHPMailer/PHPMailer.git",
+                "reference": "d9e3b36b47f04b497a0164c5a20f92acb4593284"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d9e3b36b47f04b497a0164c5a20f92acb4593284",
+                "reference": "d9e3b36b47f04b497a0164c5a20f92acb4593284",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-filter": "*",
+                "ext-hash": "*",
+                "php": ">=5.5.0"
+            },
+            "require-dev": {
+                "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+                "doctrine/annotations": "^1.2.6 || ^1.13.3",
+                "php-parallel-lint/php-console-highlighter": "^1.0.0",
+                "php-parallel-lint/php-parallel-lint": "^1.3.2",
+                "phpcompatibility/php-compatibility": "^9.3.5",
+                "roave/security-advisories": "dev-latest",
+                "squizlabs/php_codesniffer": "^3.7.2",
+                "yoast/phpunit-polyfills": "^1.0.4"
+            },
+            "suggest": {
+                "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
+                "ext-imap": "Needed to support advanced email address parsing according to RFC822",
+                "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
+                "ext-openssl": "Needed for secure SMTP sending and DKIM signing",
+                "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
+                "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
+                "league/oauth2-google": "Needed for Google XOAUTH2 authentication",
+                "psr/log": "For optional PSR-3 debug logging",
+                "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
+                "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "PHPMailer\\PHPMailer\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "LGPL-2.1-only"
+            ],
+            "authors": [
+                {
+                    "name": "Marcus Bointon",
+                    "email": "phpmailer@synchromedia.co.uk"
+                },
+                {
+                    "name": "Jim Jagielski",
+                    "email": "jimjag@gmail.com"
+                },
+                {
+                    "name": "Andy Prevost",
+                    "email": "codeworxtech@users.sourceforge.net"
+                },
+                {
+                    "name": "Brent R. Matzelle"
+                }
+            ],
+            "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
+            "support": {
+                "issues": "https://github.com/PHPMailer/PHPMailer/issues",
+                "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.11.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/Synchro",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-09-30T11:54:53+00:00"
+        },
+        {
+            "name": "psr/cache",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/cache.git",
+                "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8",
+                "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for caching libraries",
+            "keywords": [
+                "cache",
+                "psr",
+                "psr-6"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/cache/tree/master"
+            },
+            "time": "2016-08-06T20:24:11+00:00"
+        },
+        {
+            "name": "psr/container",
+            "version": "1.1.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/container.git",
+                "reference": "513e0666f7216c7459170d56df27dfcefe1689ea"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea",
+                "reference": "513e0666f7216c7459170d56df27dfcefe1689ea",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.4.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common Container Interface (PHP FIG PSR-11)",
+            "homepage": "https://github.com/php-fig/container",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interface",
+                "container-interop",
+                "psr"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/container/issues",
+                "source": "https://github.com/php-fig/container/tree/1.1.2"
+            },
+            "time": "2021-11-05T16:50:12+00:00"
+        },
+        {
+            "name": "psr/event-dispatcher",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/event-dispatcher.git",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\EventDispatcher\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Standard interfaces for event handling.",
+            "keywords": [
+                "events",
+                "psr",
+                "psr-14"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/event-dispatcher/issues",
+                "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+            },
+            "time": "2019-01-08T18:20:26+00:00"
+        },
+        {
+            "name": "psr/log",
+            "version": "1.1.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/log.git",
+                "reference": "d49695b909c3b7628b6289db5479a1c204601f11"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11",
+                "reference": "d49695b909c3b7628b6289db5479a1c204601f11",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Log\\": "Psr/Log/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for logging libraries",
+            "homepage": "https://github.com/php-fig/log",
+            "keywords": [
+                "log",
+                "psr",
+                "psr-3"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/log/tree/1.1.4"
+            },
+            "time": "2021-05-03T11:20:27+00:00"
+        },
+        {
+            "name": "symfony/cache",
+            "version": "v5.4.46",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/cache.git",
+                "reference": "0fe08ee32cec2748fbfea10c52d3ee02049e0f6b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/cache/zipball/0fe08ee32cec2748fbfea10c52d3ee02049e0f6b",
+                "reference": "0fe08ee32cec2748fbfea10c52d3ee02049e0f6b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "psr/cache": "^1.0|^2.0",
+                "psr/log": "^1.1|^2|^3",
+                "symfony/cache-contracts": "^1.1.7|^2",
+                "symfony/deprecation-contracts": "^2.1|^3",
+                "symfony/polyfill-php73": "^1.9",
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/service-contracts": "^1.1|^2|^3",
+                "symfony/var-exporter": "^4.4|^5.0|^6.0"
+            },
+            "conflict": {
+                "doctrine/dbal": "<2.13.1",
+                "symfony/dependency-injection": "<4.4",
+                "symfony/http-kernel": "<4.4",
+                "symfony/var-dumper": "<4.4"
+            },
+            "provide": {
+                "psr/cache-implementation": "1.0|2.0",
+                "psr/simple-cache-implementation": "1.0|2.0",
+                "symfony/cache-implementation": "1.0|2.0"
+            },
+            "require-dev": {
+                "cache/integration-tests": "dev-master",
+                "doctrine/cache": "^1.6|^2.0",
+                "doctrine/dbal": "^2.13.1|^3|^4",
+                "predis/predis": "^1.1|^2.0",
+                "psr/simple-cache": "^1.0|^2.0",
+                "symfony/config": "^4.4|^5.0|^6.0",
+                "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+                "symfony/filesystem": "^4.4|^5.0|^6.0",
+                "symfony/http-kernel": "^4.4|^5.0|^6.0",
+                "symfony/messenger": "^4.4|^5.0|^6.0",
+                "symfony/var-dumper": "^4.4|^5.0|^6.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Cache\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides extended PSR-6, PSR-16 (and tags) implementations",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "caching",
+                "psr6"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/cache/tree/v5.4.46"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-11-04T11:43:55+00:00"
+        },
+        {
+            "name": "symfony/cache-contracts",
+            "version": "v2.5.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/cache-contracts.git",
+                "reference": "517c3a3619dadfa6952c4651767fcadffb4df65e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/517c3a3619dadfa6952c4651767fcadffb4df65e",
+                "reference": "517c3a3619dadfa6952c4651767fcadffb4df65e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "psr/cache": "^1.0|^2.0|^3.0"
+            },
+            "suggest": {
+                "symfony/cache-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "2.5-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Cache\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to caching",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/cache-contracts/tree/v2.5.4"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:11:13+00:00"
+        },
         {
             "name": "symfony/deprecation-contracts",
             "version": "v2.5.4",
@@ -73,9 +940,72 @@
             ],
             "time": "2024-09-25T14:11:13+00:00"
         },
+        {
+            "name": "symfony/expression-language",
+            "version": "v5.4.45",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/expression-language.git",
+                "reference": "a784b66edc4c151eb05076d04707906ee2c209a9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/expression-language/zipball/a784b66edc4c151eb05076d04707906ee2c209a9",
+                "reference": "a784b66edc4c151eb05076d04707906ee2c209a9",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/cache": "^4.4|^5.0|^6.0",
+                "symfony/service-contracts": "^1.1|^2|^3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\ExpressionLanguage\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an engine that can compile and evaluate expressions",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/expression-language/tree/v5.4.45"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-10-04T14:55:40+00:00"
+        },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.32.0",
+            "version": "v1.33.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
@@ -134,7 +1064,258 @@
                 "portable"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0"
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-09T11:45:10+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php73",
+            "version": "v1.33.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php73.git",
+                "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb",
+                "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php73\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-09T11:45:10+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php80",
+            "version": "v1.33.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php80.git",
+                "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+                "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php80\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ion Bazan",
+                    "email": "ion.bazan@gmail.com"
+                },
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-01-02T08:10:11+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v2.5.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "f37b419f7aea2e9abf10abd261832cace12e3300"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f37b419f7aea2e9abf10abd261832cace12e3300",
+                "reference": "f37b419f7aea2e9abf10abd261832cace12e3300",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "psr/container": "^1.1",
+                "symfony/deprecation-contracts": "^2.1|^3"
+            },
+            "conflict": {
+                "ext-psr": "<1.1|>=2"
+            },
+            "suggest": {
+                "symfony/service-implementation": ""
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "2.5-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Service\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to writing services",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/service-contracts/tree/v2.5.4"
             },
             "funding": [
                 {
@@ -150,7 +1331,80 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-09-09T11:45:10+00:00"
+            "time": "2024-09-25T14:11:13+00:00"
+        },
+        {
+            "name": "symfony/var-exporter",
+            "version": "v5.4.45",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/var-exporter.git",
+                "reference": "862700068db0ddfd8c5b850671e029a90246ec75"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/var-exporter/zipball/862700068db0ddfd8c5b850671e029a90246ec75",
+                "reference": "862700068db0ddfd8c5b850671e029a90246ec75",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/polyfill-php80": "^1.16"
+            },
+            "require-dev": {
+                "symfony/var-dumper": "^4.4.9|^5.0.9|^6.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\VarExporter\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Allows exporting any serializable PHP data structure to plain PHP code",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "clone",
+                "construct",
+                "export",
+                "hydrate",
+                "instantiate",
+                "serialize"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/var-exporter/tree/v5.4.45"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:11:13+00:00"
         },
         {
             "name": "symfony/yaml",
@@ -301,16 +1555,16 @@
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.13.3",
+            "version": "1.13.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "faed855a7b5f4d4637717c2b3863e277116beb36"
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36",
-                "reference": "faed855a7b5f4d4637717c2b3863e277116beb36",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
                 "shasum": ""
             },
             "require": {
@@ -349,7 +1603,7 @@
             ],
             "support": {
                 "issues": "https://github.com/myclabs/DeepCopy/issues",
-                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3"
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
             },
             "funding": [
                 {
@@ -357,20 +1611,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-07-05T12:25:42+00:00"
+            "time": "2025-08-01T08:46:24+00:00"
         },
         {
             "name": "nikic/php-parser",
-            "version": "v5.5.0",
+            "version": "v5.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "ae59794362fe85e051a58ad36b289443f57be7a9"
+                "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9",
-                "reference": "ae59794362fe85e051a58ad36b289443f57be7a9",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2",
+                "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2",
                 "shasum": ""
             },
             "require": {
@@ -389,7 +1643,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "5.0-dev"
+                    "dev-master": "5.x-dev"
                 }
             },
             "autoload": {
@@ -413,9 +1667,9 @@
             ],
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1"
             },
-            "time": "2025-05-31T08:24:38+00:00"
+            "time": "2025-08-13T20:13:15+00:00"
         },
         {
             "name": "nulib/tests",
@@ -894,16 +2148,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "9.6.23",
+            "version": "9.6.29",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95"
+                "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/43d2cb18d0675c38bd44982a5d1d88f6d53d8d95",
-                "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
+                "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
                 "shasum": ""
             },
             "require": {
@@ -914,7 +2168,7 @@
                 "ext-mbstring": "*",
                 "ext-xml": "*",
                 "ext-xmlwriter": "*",
-                "myclabs/deep-copy": "^1.13.1",
+                "myclabs/deep-copy": "^1.13.4",
                 "phar-io/manifest": "^2.0.4",
                 "phar-io/version": "^3.2.1",
                 "php": ">=7.3",
@@ -925,11 +2179,11 @@
                 "phpunit/php-timer": "^5.0.3",
                 "sebastian/cli-parser": "^1.0.2",
                 "sebastian/code-unit": "^1.0.8",
-                "sebastian/comparator": "^4.0.8",
+                "sebastian/comparator": "^4.0.9",
                 "sebastian/diff": "^4.0.6",
                 "sebastian/environment": "^5.1.5",
-                "sebastian/exporter": "^4.0.6",
-                "sebastian/global-state": "^5.0.7",
+                "sebastian/exporter": "^4.0.8",
+                "sebastian/global-state": "^5.0.8",
                 "sebastian/object-enumerator": "^4.0.4",
                 "sebastian/resource-operations": "^3.0.4",
                 "sebastian/type": "^3.2.1",
@@ -977,7 +2231,7 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.23"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29"
             },
             "funding": [
                 {
@@ -1001,7 +2255,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-05-02T06:40:34+00:00"
+            "time": "2025-09-24T06:29:11+00:00"
         },
         {
             "name": "sebastian/cli-parser",
@@ -1172,16 +2426,16 @@
         },
         {
             "name": "sebastian/comparator",
-            "version": "4.0.8",
+            "version": "4.0.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "fa0f136dd2334583309d32b62544682ee972b51a"
+                "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
-                "reference": "fa0f136dd2334583309d32b62544682ee972b51a",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5",
+                "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5",
                 "shasum": ""
             },
             "require": {
@@ -1234,15 +2488,27 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/comparator/issues",
-                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8"
+                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2022-09-14T12:41:17+00:00"
+            "time": "2025-08-10T06:51:50+00:00"
         },
         {
             "name": "sebastian/complexity",
@@ -1432,16 +2698,16 @@
         },
         {
             "name": "sebastian/exporter",
-            "version": "4.0.6",
+            "version": "4.0.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72"
+                "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72",
-                "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+                "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
                 "shasum": ""
             },
             "require": {
@@ -1497,28 +2763,40 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/exporter/issues",
-                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6"
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2024-03-02T06:33:00+00:00"
+            "time": "2025-09-24T06:03:27+00:00"
         },
         {
             "name": "sebastian/global-state",
-            "version": "5.0.7",
+            "version": "5.0.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/global-state.git",
-                "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9"
+                "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9",
-                "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+                "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
                 "shasum": ""
             },
             "require": {
@@ -1561,15 +2839,27 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/global-state/issues",
-                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7"
+                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2024-03-02T06:35:11+00:00"
+            "time": "2025-08-10T07:10:35+00:00"
         },
         {
             "name": "sebastian/lines-of-code",
@@ -1742,16 +3032,16 @@
         },
         {
             "name": "sebastian/recursion-context",
-            "version": "4.0.5",
+            "version": "4.0.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/recursion-context.git",
-                "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1"
+                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
-                "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
+                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
                 "shasum": ""
             },
             "require": {
@@ -1793,15 +3083,27 @@
             "homepage": "https://github.com/sebastianbergmann/recursion-context",
             "support": {
                 "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
-                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5"
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
             },
             "funding": [
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2023-02-03T06:07:39+00:00"
+            "time": "2025-08-10T06:57:39+00:00"
         },
         {
             "name": "sebastian/resource-operations",
diff --git a/php/bin/cachectl.php b/php/bin/cachectl.php
new file mode 100755
index 0000000..48de2bc
--- /dev/null
+++ b/php/bin/cachectl.php
@@ -0,0 +1,7 @@
+#!/usr/bin/php
+|<|>|<=|>=|(?:is\s+)?null|(?:is\s+)?not\s+null)\s*(.*)$/', $arg, $ms);
+  }
+
+  protected function storageCtl(CapacitorStorage $storage): void {
+    $args = $this->args;
+
+    $channelClass = $this->channelClass;
+    $tableName = $this->tableName;
+    if ($channelClass === null && $tableName === null) {
+      $name = A::shift($args);
+      if ($name !== null) {
+        if (!$storage->channelExists($name, $row)) {
+          self::die("$name: nom de canal de données introuvable");
+        }
+        if ($row["class_name"] !== "class@anonymous") $channelClass = $row["class_name"];
+        else $tableName = $row["table_name"];
+      }
+    }
+    if ($channelClass !== null) {
+      $channelClass = str_replace("/", "\\", $channelClass);
+      $channel = new $channelClass;
+    } elseif ($tableName !== null) {
+      $channel = new class($tableName) extends CapacitorChannel {
+        function __construct(?string $name=null) {
+          parent::__construct($name);
+          $this->tableName = $name;
+        }
+      };
+    } else {
+      $found = false;
+      foreach ($storage->getChannels() as $row) {
+        msg::print($row["name"]);
+        $found = true;
+      }
+      if ($found) self::exit();
+      self::die("Vous devez spécifier le canal de données");
+    }
+    $capacitor = new Capacitor($storage, $channel);
+
+    switch ($this->action) {
+    case self::ACTION_RESET:
+      $capacitor->reset($this->recreate);
+      break;
+    case self::ACTION_QUERY:
+      if (!$args) {
+        # lister les id
+        $out = new Stream(STDOUT);
+        $primaryKeys = $storage->getPrimaryKeys($channel);
+        $rows = $storage->db()->all([
+          "select",
+          "cols" => $primaryKeys,
+          "from" => $channel->getTableName(),
+        ]);
+        $out->fputcsv($primaryKeys);
+        foreach ($rows as $row) {
+          $rowIds = $storage->getRowIds($channel, $row);
+          $out->fputcsv($rowIds);
+        }
+      } else {
+        # afficher les lignes correspondantes
+        if (count($args) == 1 && !self::isa_cond($args[0])) {
+          $filter = $args[0];
+        } else {
+          $filter = [];
+          $ms = null;
+          foreach ($args as $arg) {
+            if (self::isa_cond($arg, $ms)) {
+              $filter[$ms[1]] = [$ms[2], $ms[3]];
+            } else {
+              $filter[$arg] = ["not null"];
+            }
+          }
+        }
+        $first = true;
+        $capacitor->each($filter, function ($row) use (&$first) {
+          if ($first) $first = false;
+          else echo "---\n";
+          yaml::dump($row);
+        });
+      }
+      break;
+    case self::ACTION_SQL:
+      echo $capacitor->getCreateSql()."\n";
+      break;
+    }
+  }
+}
diff --git a/php/cli/BgLauncherApp.php b/php/cli/BgLauncherApp.php
new file mode 100644
index 0000000..3f965fd
--- /dev/null
+++ b/php/cli/BgLauncherApp.php
@@ -0,0 +1,122 @@
+ "lancer un script en tâche de fond",
+    "usage" => "ApplicationClass args...",
+
+    "sections" => [
+      parent::VERBOSITY_SECTION,
+    ],
+
+    ["-i", "--infos", "name" => "action", "value" => self::ACTION_INFOS,
+      "help" => "Afficher des informations sur la tâche",
+    ],
+    ["-s", "--start", "name" => "action", "value" => self::ACTION_START,
+      "help" => "Démarrer la tâche",
+    ],
+    ["-k", "--stop", "name" => "action", "value" => self::ACTION_STOP,
+      "help" => "Arrêter la tâche",
+    ],
+  ];
+
+  protected int $action = self::ACTION_START;
+
+  static function show_infos(RunFile $runfile, ?int $level=null): void {
+    msg::print($runfile->getDesc(), $level);
+    msg::print(yaml::with(["data" => $runfile->read()]), ($level ?? 0) - 1);
+  }
+
+  function main() {
+    $args = $this->args;
+
+    $appClass = $args[0] ?? null;
+    if ($appClass === null) {
+      self::die("Vous devez spécifier la classe de l'application");
+    }
+    $appClass = $args[0] = str_replace("/", "\\", $appClass);
+    if (!class_exists($appClass)) {
+      self::die("$appClass: classe non trouvée");
+    }
+
+    $useRunfile = constant("$appClass::USE_RUNFILE");
+    if (!$useRunfile) {
+      self::die("Cette application ne supporte le lancement en tâche de fond");
+    }
+
+    $runfile = app::with($appClass)->getRunfile();
+    switch ($this->action) {
+    case self::ACTION_START:
+      $argc = count($args);
+      $appClass::_manage_runfile($argc, $args, $runfile);
+      if ($runfile->warnIfLocked()) self::exit(app::EC_LOCKED);
+      array_splice($args, 0, 0, [
+        PHP_BINARY,
+        path::abspath(NULIB_APP_app_launcher),
+      ]);
+      app::params_putenv();
+      self::_start($args, $runfile);
+      break;
+    case self::ACTION_STOP:
+      self::_stop($runfile);
+      self::show_infos($runfile, -1);
+      break;
+    case self::ACTION_INFOS:
+      self::show_infos($runfile);
+      break;
+    }
+  }
+
+  public static function _start(array $args, Runfile $runfile): void {
+    $pid = pcntl_fork();
+    if ($pid == -1) {
+      # parent, impossible de forker
+      throw new ExitError(app::EC_FORK_PARENT, "Unable to fork");
+    } elseif (!$pid) {
+      # child, fork ok
+      $runfile->wfPrepare($pid);
+      $outfile = $runfile->getOutfile() ?? "/tmp/NULIB_APP_app_console.out";
+      $exitcode = app::EC_FORK_CHILD;
+      try {
+        # rediriger STDIN, STDOUT et STDERR
+        fclose(fopen($outfile, "wb")); // vider le fichier
+        fclose(STDIN); $in = fopen("/dev/null", "rb");
+        fclose(STDOUT); $out = fopen($outfile, "ab");
+        fclose(STDERR); $err = fopen($outfile, "ab");
+        # puis lancer la commande
+        $cmd = new Cmd($args);
+        $cmd->addSource("/g/init.env");
+        $cmd->addRedir("both", $outfile, true);
+        $cmd->fork_exec($exitcode, false);
+        sh::_waitpid(-$pid, $exitcode);
+      } finally {
+        $runfile->wfReaped($exitcode);
+      }
+    }
+  }
+
+  public static function _stop(Runfile $runfile): bool {
+    $data = $runfile->read();
+    $pid = $runfile->_getCid($data);
+    msg::action("stop $pid");
+    if ($runfile->wfKill($reason)) {
+      msg::asuccess();
+      return true;
+    } else {
+      msg::afailure($reason);
+      return false;
+    }
+  }
+}
diff --git a/php/cli/CachectlApp.php b/php/cli/CachectlApp.php
new file mode 100644
index 0000000..91f8636
--- /dev/null
+++ b/php/cli/CachectlApp.php
@@ -0,0 +1,132 @@
+ parent::ARGS,
+    "purpose" => "gestion de fichiers cache",
+    ["-r", "--read", "name" => "action", "value" => self::ACTION_READ,
+      "help" => "Afficher le contenu d'un fichier cache",
+    ],
+    ["-d::", "--data",
+      "help" => "Identifiant de la donnée à afficher",
+    ],
+    ["-i", "--infos", "name" => "action", "value" => self::ACTION_INFOS,
+      "help" => "Afficher des informations sur le fichier cache",
+    ],
+    ["-k", "--clean", "name" => "action", "value" => self::ACTION_CLEAN,
+      "help" => "Supprimer le fichier cache s'il a expiré",
+    ],
+    ["-a", "--add-duration", "args" => 1,
+      "action" => [null, "->setActionUpdate", self::ACTION_UPDATE_ADD],
+      "help" => "Ajouter le nombre de secondes spécifié à la durée du cache",
+    ],
+    ["-b", "--sub-duration", "args" => 1,
+      "action" => [null, "->setActionUpdate", self::ACTION_UPDATE_SUB],
+      "help" => "Enlever le nombre de secondes spécifié à la durée du cache",
+    ],
+    #XXX pas encore implémenté
+    //["-s", "--set-duration", "args" => 1,
+    //  "action" => [null, "->setActionUpdate", self::ACTION_UPDATE_SET],
+    //  "help" => "Mettre à jour la durée du cache à la valeur spécifiée",
+    //],
+  ];
+
+  protected $action = self::ACTION_READ;
+
+  protected $updateAction, $updateDuration;
+
+  protected $data = null;
+
+  function setActionUpdate(int $action, $updateDuration): void {
+    $this->action = self::ACTION_UPDATE;
+    switch ($action) {
+    case self::ACTION_UPDATE_SUB:
+      $this->updateAction = CacheFile::UPDATE_SUB;
+      break;
+    case self::ACTION_UPDATE_SET:
+      $this->updateAction = CacheFile::UPDATE_SET;
+      break;
+    case self::ACTION_UPDATE_ADD:
+      $this->updateAction = CacheFile::UPDATE_ADD;
+      break;
+    }
+    $this->updateDuration = $updateDuration;
+  }
+
+  protected function findCaches(string $dir, ?array &$files): void {
+    foreach (glob("$dir/*") as $file) {
+      if (is_dir($file)) {
+        $this->findCaches($file, $files);
+      } elseif (is_file($file) && fnmatch("*.cache", $file)) {
+        $files[] = $file;
+      }
+    }
+  }
+
+  function main() {
+    $files = [];
+    foreach ($this->args as $arg) {
+      if (is_dir($arg)) {
+        $this->findCaches($arg, $files);
+      } elseif (is_file($arg)) {
+        $files[] = $arg;
+      } else {
+        msg::warning("$arg: fichier introuvable");
+      }
+    }
+    $showSection = count($files) > 1;
+    foreach ($files as $file) {
+      switch ($this->action) {
+      case self::ACTION_READ:
+        if ($showSection) msg::section($file);
+        $cache = new CacheFile($file, null, [
+          "readonly" => true,
+          "duration" => "INF",
+          "override_duration" => true,
+        ]);
+        yaml::dump($cache->get($this->data));
+        break;
+      case self::ACTION_INFOS:
+        if ($showSection) msg::section($file);
+        $cache = new CacheFile($file, null, [
+          "readonly" => true,
+        ]);
+        yaml::dump($cache->getInfos());
+        break;
+      case self::ACTION_CLEAN:
+        msg::action(path::ppath($file));
+        $cache = new CacheFile($file);
+        try {
+          if ($cache->deleteExpired()) msg::asuccess("fichier supprimé");
+          else msg::adone("fichier non expiré");
+        } catch (Exception $e) {
+          msg::afailure($e);
+        }
+        break;
+      case self::ACTION_UPDATE:
+        msg::action(path::ppath($file));
+        $cache = new CacheFile($file);
+        try {
+          $cache->updateDuration($this->updateDuration, $this->updateAction);
+          msg::asuccess("fichier mis à jour");
+        } catch (Exception $e) {
+          msg::afailure($e);
+        }
+        break;
+      default:
+        self::die("$this->action: action non implémentée");
+      }
+    }
+  }
+}
diff --git a/php/cli/DumpserApp.php b/php/cli/DumpserApp.php
new file mode 100644
index 0000000..61c4aa7
--- /dev/null
+++ b/php/cli/DumpserApp.php
@@ -0,0 +1,31 @@
+ parent::ARGS,
+    "purpose" => "afficher des données sérialisées",
+  ];
+
+  function main() {
+    $files = [];
+    foreach ($this->args as $arg) {
+      if (is_file($arg)) {
+        $files[] = $arg;
+      } else {
+        msg::warning("$arg: fichier invalide ou introuvable");
+      }
+    }
+    $showSection = count($files) > 1;
+    foreach ($files as $file) {
+      if ($showSection) msg::section($file);
+      $sfile = new SharedFile($file);
+      yaml::dump($sfile->unserialize());
+    }
+  }
+}
diff --git a/php/cli/Json2yamlApp.php b/php/cli/Json2yamlApp.php
new file mode 100644
index 0000000..138f184
--- /dev/null
+++ b/php/cli/Json2yamlApp.php
@@ -0,0 +1,21 @@
+args[0] ?? null;
+    if ($input === null || $input === "-") {
+      $output = null;
+    } else {
+      $output = path::ensure_ext($input, ".yml", ".json");
+    }
+
+    $data = json::load($input);
+    yaml::dump($data, $output);
+  }
+}
\ No newline at end of file
diff --git a/php/cli/MysqlCapacitorApp.php b/php/cli/MysqlCapacitorApp.php
new file mode 100644
index 0000000..2587d93
--- /dev/null
+++ b/php/cli/MysqlCapacitorApp.php
@@ -0,0 +1,45 @@
+ parent::ARGS,
+    "purpose" => "gestion d'un capacitor mysql",
+    "usage" => [
+      "DBCONN [channelName | -t table | -c ChannelClass] [--query] key=value...",
+      "DBCONN [channelName | -t table | -c ChannelClass] --sql-create",
+    ],
+    ["-t:table", "--table-name",
+      "help" => "nom de la table porteuse du canal de données",
+    ],
+    ["-c:class", "--channel-class",
+      "help" => "nom de la classe dérivée de CapacitorChannel",
+    ],
+    ["-z", "--reset", "name" => "action", "value" => self::ACTION_RESET,
+      "help" => "réinitialiser le canal",
+    ],
+    ["-n", "--no-recreate", "name" => "recreate", "value" => false,
+      "help" => "ne pas recréer la table correspondant au canal"
+    ],
+    ["--query", "name" => "action", "value" => self::ACTION_QUERY,
+      "help" => "lister les lignes correspondant aux valeurs spécifiées. c'est l'action par défaut",
+    ],
+    ["-s", "--sql-create", "name" => "action", "value" => self::ACTION_SQL,
+      "help" => "afficher la requête pour créer la table",
+    ],
+  ];
+
+  function main() {
+    $dbconn = A::shift($this->args);
+    if ($dbconn === null) self::die("Vous devez spécifier la base de données");
+    $tmp = config::db($dbconn);
+    if ($tmp === null) self::die("$dbconn: base de données invalide");
+    $storage = new MysqlStorage($tmp);
+
+    $this->storageCtl($storage);
+  }
+}
diff --git a/php/cli/PgsqlCapacitorApp.php b/php/cli/PgsqlCapacitorApp.php
new file mode 100644
index 0000000..73e10a2
--- /dev/null
+++ b/php/cli/PgsqlCapacitorApp.php
@@ -0,0 +1,45 @@
+ parent::ARGS,
+    "purpose" => "gestion d'un capacitor pgsql",
+    "usage" => [
+      "DBCONN [channelName | -t table | -c ChannelClass] [--query] key=value...",
+      "DBCONN [channelName | -t table | -c ChannelClass] --sql-create",
+    ],
+    ["-t:table", "--table-name",
+      "help" => "nom de la table porteuse du canal de données",
+    ],
+    ["-c:class", "--channel-class",
+      "help" => "nom de la classe dérivée de CapacitorChannel",
+    ],
+    ["-z", "--reset", "name" => "action", "value" => self::ACTION_RESET,
+      "help" => "réinitialiser le canal",
+    ],
+    ["-n", "--no-recreate", "name" => "recreate", "value" => false,
+      "help" => "ne pas recréer la table correspondant au canal"
+    ],
+    ["--query", "name" => "action", "value" => self::ACTION_QUERY,
+      "help" => "lister les lignes correspondant aux valeurs spécifiées. c'est l'action par défaut",
+    ],
+    ["-s", "--sql-create", "name" => "action", "value" => self::ACTION_SQL,
+      "help" => "afficher la requête pour créer la table",
+    ],
+  ];
+
+  function main() {
+    $dbconn = A::shift($this->args);
+    if ($dbconn === null) self::die("Vous devez spécifier la base de données");
+    $tmp = config::db($dbconn);
+    if ($tmp === null) self::die("$dbconn: base de données invalide");
+    $storage = new PgsqlStorage($tmp);
+
+    $this->storageCtl($storage);
+  }
+}
diff --git a/php/cli/SqliteCapacitorApp.php b/php/cli/SqliteCapacitorApp.php
new file mode 100644
index 0000000..5f9985a
--- /dev/null
+++ b/php/cli/SqliteCapacitorApp.php
@@ -0,0 +1,43 @@
+ parent::ARGS,
+    "purpose" => "gestion d'un capacitor sqlite",
+    "usage" => [
+      "DBFILE [channelName | -t table | -c ChannelClass] [--query] key=value...",
+      "DBFILE [channelName | -t table | -c ChannelClass] --sql-create",
+    ],
+    ["-t:table", "--table-name",
+      "help" => "nom de la table porteuse du canal de données",
+    ],
+    ["-c:class", "--channel-class",
+      "help" => "nom de la classe dérivée de CapacitorChannel",
+    ],
+    ["-z", "--reset", "name" => "action", "value" => self::ACTION_RESET,
+      "help" => "réinitialiser le canal",
+    ],
+    ["-n", "--no-recreate", "name" => "recreate", "value" => false,
+      "help" => "ne pas recréer la table correspondant au canal"
+    ],
+    ["--query", "name" => "action", "value" => self::ACTION_QUERY,
+      "help" => "lister les lignes correspondant aux valeurs spécifiées. c'est l'action par défaut",
+    ],
+    ["-s", "--sql-create", "name" => "action", "value" => self::ACTION_SQL,
+      "help" => "afficher la requête pour créer la table",
+    ],
+  ];
+
+  function main() {
+    $dbfile = A::shift($this->args);
+    if ($dbfile === null) self::die("Vous devez spécifier la base de données");
+    if (!file_exists($dbfile)) self::die("$dbfile: fichier introuvable");
+    $storage = new SqliteStorage($dbfile);
+
+    $this->storageCtl($storage);
+  }
+}
diff --git a/php/cli/Yaml2jsonApp.php b/php/cli/Yaml2jsonApp.php
new file mode 100644
index 0000000..fb3f96f
--- /dev/null
+++ b/php/cli/Yaml2jsonApp.php
@@ -0,0 +1,21 @@
+args[0] ?? null;
+    if ($input === null || $input === "-") {
+      $output = null;
+    } else {
+      $output = path::ensure_ext($input, ".json", [".yml", ".yaml"]);
+    }
+
+    $data = yaml::load($input);
+    json::dump($data, $output);
+  }
+}
\ No newline at end of file
diff --git a/php/cli/_SteamTrainApp.php b/php/cli/_SteamTrainApp.php
new file mode 100644
index 0000000..9ccf13d
--- /dev/null
+++ b/php/cli/_SteamTrainApp.php
@@ -0,0 +1,53 @@
+ self::TITLE,
+    "description" => << "spécifier le nombre d'étapes",
+    ],
+    ["-f", "--force-enabled", "value" => true,
+      "help" => "lancer la commande même si les tâches planifiées sont désactivées",
+    ],
+    ["-n", "--no-install-signal-handler", "value" => false,
+      "help" => "ne pas installer le gestionnaire de signaux",
+    ],
+  ];
+
+  protected $count = 100;
+
+  protected bool $forceEnabled = false;
+
+  protected bool $installSignalHandler = true;
+
+  function main() {
+    app::check_bgapplication_enabled($this->forceEnabled);
+    if ($this->installSignalHandler) app::install_signal_handler();
+    $count = intval($this->count);
+    msg::info("Starting train for ".words::q($count, "step#s"));
+    app::action("Running train...", $count);
+    for ($i = 1; $i <= $count; $i++) {
+      msg::print("Tchou-tchou! x $i");
+      app::step();
+      sleep(1);
+    }
+    msg::info("Stopping train at ".new DateTime());
+  }
+}
diff --git a/php/src/tools/pman/ComposerFile.php b/php/cli/pman/ComposerFile.php
similarity index 96%
rename from php/src/tools/pman/ComposerFile.php
rename to php/cli/pman/ComposerFile.php
index c3dd7a1..e40a0df 100644
--- a/php/src/tools/pman/ComposerFile.php
+++ b/php/cli/pman/ComposerFile.php
@@ -1,18 +1,17 @@
 composerFile = $composerFile;
     $this->load();
diff --git a/php/src/tools/pman/ComposerPmanFile.php b/php/cli/pman/ComposerPmanFile.php
similarity index 92%
rename from php/src/tools/pman/ComposerPmanFile.php
rename to php/cli/pman/ComposerPmanFile.php
index d8c6474..1ab44c7 100644
--- a/php/src/tools/pman/ComposerPmanFile.php
+++ b/php/cli/pman/ComposerPmanFile.php
@@ -1,11 +1,11 @@
 configFile = $configFile;
     $this->load();
@@ -66,9 +65,7 @@ class ComposerPmanFile {
 
   function getProfileConfig(string $profile, ?array $composerRequires=null, ?array $composerRequireDevs=null): array {
     $config = $this->data["composer"][$profile] ?? null;
-    if ($config === null) {
-      throw new ValueException("$profile: profil invalide");
-    }
+    if ($config === null) throw exceptions::invalid_value($profile, "ce profil");
     if ($composerRequires !== null) {
       $matchRequires = $this->data["composer"]["match_require"];
       foreach ($composerRequires as $dep => $version) {
diff --git a/php/run-tests b/php/run-tests
index 1b0c5a1..75d58d8 100755
--- a/php/run-tests
+++ b/php/run-tests
@@ -1,5 +1,5 @@
 #!/bin/bash
 # -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
 MYDIR="$(dirname -- "$0")"
-VENDOR="$MYDIR/../vendor"
+VENDOR="$MYDIR/vendor"
 "$VENDOR/bin/phpunit" --bootstrap "$VENDOR/autoload.php" "$@" "$MYDIR/tests"
diff --git a/php/src/A.php b/php/src/A.php
index dffd3d2..b1e7cc6 100644
--- a/php/src/A.php
+++ b/php/src/A.php
@@ -1,7 +1,6 @@
 trace = self::extract_trace($exception->getTrace());
     $previous = $exception->getPrevious();
     if ($previous !== null) $this->previous = new static($previous);
+    if ($exception instanceof UserException) {
+      $this->userMessage = $exception->getUserMessage();
+      $this->techMessage = $exception->getTechMessage();
+    } else {
+      $this->userMessage = null;
+      $this->techMessage = null;
+    }
   }
 
-  /** @var string */
-  protected $class;
+  protected string $class;
 
   function getClass(): string {
     return $this->class;
   }
 
-  /** @var string */
-  protected $message;
+  protected string $message;
 
   function getMessage(): string {
     return $this->message;
@@ -61,22 +66,19 @@ class ExceptionShadow {
     return $this->code;
   }
 
-  /** @var string */
-  protected $file;
+  protected string $file;
 
   function getFile(): string {
     return $this->file;
   }
 
-  /** @var int */
-  protected $line;
+  protected int $line;
 
   function getLine(): int {
     return $this->line;
   }
 
-  /** @var array */
-  protected $trace;
+  protected array $trace;
 
   function getTrace(): array {
     return $this->trace;
@@ -92,10 +94,21 @@ class ExceptionShadow {
     return implode("\n", $lines);
   }
 
-  /** @var ExceptionShadow */
-  protected $previous;
+  protected ?ExceptionShadow $previous;
 
   function getPrevious(): ?ExceptionShadow {
     return $this->previous;
   }
+
+  protected ?array $userMessage;
+
+  function getUserMessage(): ?array {
+    return $this->userMessage;
+  }
+
+  protected ?array $techMessage;
+
+  function getTechMessage(): ?array {
+    return $this->techMessage;
+  }
 }
diff --git a/php/src/ExitError.php b/php/src/ExitError.php
index a14c3a8..de8501b 100644
--- a/php/src/ExitError.php
+++ b/php/src/ExitError.php
@@ -18,8 +18,7 @@ class ExitError extends Error {
     return $this->getCode() !== 0;
   }
 
-  /** @var ?string */
-  protected $userMessage;
+  protected ?string $userMessage;
 
   function haveUserMessage(): bool {
     return $this->userMessage !== null;
diff --git a/php/src/StateException.php b/php/src/StateException.php
index 3eadf1d..a2f6bfe 100644
--- a/php/src/StateException.php
+++ b/php/src/StateException.php
@@ -12,12 +12,12 @@ class StateException extends LogicException {
     if ($method === null) $method = "this method";
     $message = "$method is not implemented";
     if ($prefix) $prefix = "$prefix: ";
-    return new static($prefix.$message);
+    return new static("$prefix$message");
   }
 
   static final function unexpected_state(?string $suffix=null): self {
     $message = "unexpected state";
     if ($suffix) $suffix = ": $suffix";
-    return new static($message.$suffix);
+    return new static("$message$suffix");
   }
 }
diff --git a/php/src/UserException.php b/php/src/UserException.php
index 1bef745..e41dc2c 100644
--- a/php/src/UserException.php
+++ b/php/src/UserException.php
@@ -1,90 +1,35 @@
 getUserMessage();
-    else return null;
+  function __construct($userMessage, $code=0, ?Throwable $previous=null) {
+    $this->userMessage = $userMessage = c::resolve($userMessage);
+    parent::__construct(c::to_string($userMessage), $code, $previous);
   }
 
-  /** @param Throwable|ExceptionShadow $e */
-  static final function get_user_summary($e): string {
-    $parts = [];
-    $first = true;
-    while ($e !== null) {
-      $message = self::get_user_message($e);
-      if (!$message) $message = "(no message)";
-      if ($first) $first = false;
-      else $parts[] = "caused by ";
-      $parts[] = get_class($e) . ": " . $message;
-      $e = $e->getPrevious();
-    }
-    return implode(", ", $parts);
-  }
+  protected ?array $userMessage;
 
-  /** @param Throwable|ExceptionShadow $e */
-  static function get_message($e): ?string {
-    $message = $e->getMessage();
-    if (!$message && $e instanceof self) $message = $e->getUserMessage();
-    return $message;
-  }
-
-  /** @param Throwable|ExceptionShadow $e */
-  static final function get_summary($e): string {
-    $parts = [];
-    $first = true;
-    while ($e !== null) {
-      $message = self::get_message($e);
-      if (!$message) $message = "(no message)";
-      if ($first) $first = false;
-      else $parts[] = "caused by ";
-      if ($e instanceof ExceptionShadow) $class = $e->getClass();
-      else $class = get_class($e);
-      $parts[] = "$class: $message";
-      $e = $e->getPrevious();
-    }
-    return implode(", ", $parts);
-  }
-
-  /** @param Throwable|ExceptionShadow $e */
-  static final function get_traceback($e): string {
-    $tbs = [];
-    $previous = false;
-    while ($e !== null) {
-      if (!$previous) {
-        $efile = $e->getFile();
-        $eline = $e->getLine();
-        $tbs[] = "at $efile($eline)";
-      } else {
-        $tbs[] = "~~ caused by: " . self::get_summary($e);
-      }
-      $tbs[] = $e->getTraceAsString();
-      $e = $e->getPrevious();
-      $previous = true;
-      #XXX il faudrait ne pas réinclure les lignes communes aux exceptions qui
-      # ont déjà été affichées
-    }
-    return implode("\n", $tbs);
-  }
-
-  function __construct($userMessage, $techMessage=null, $code=0, ?Throwable $previous=null) {
-    $this->userMessage = $userMessage;
-    if ($techMessage === null) $techMessage = $userMessage;
-    parent::__construct($techMessage, $code, $previous);
-  }
-
-  /** @var ?string */
-  protected $userMessage;
-
-  function getUserMessage(): ?string {
+  function getUserMessage(): ?array {
     return $this->userMessage;
   }
+
+  protected ?array $techMessage = null;
+
+  function getTechMessage(): ?array {
+    return $this->techMessage;
+  }
+
+  function setTechMessage($techMessage): self {
+    if ($techMessage !== null) $techMessage = c::resolve($techMessage);
+    $this->techMessage = $techMessage;
+    return $this;
+  }
 }
diff --git a/php/src/ValueException.php b/php/src/ValueException.php
index 12813d2..b321866 100644
--- a/php/src/ValueException.php
+++ b/php/src/ValueException.php
@@ -5,72 +5,4 @@ namespace nulib;
  * Class ValueException: indiquer qu'une valeur est invalide
  */
 class ValueException extends UserException {
-  private static function value($value): string {
-    if (is_object($value)) {
-      return "<".get_class($value).">";
-    } elseif (is_array($value)) {
-      $values = $value;
-      $parts = [];
-      $index = 0;
-      foreach ($values as $key => $value) {
-        if ($key === $index) {
-          $index++;
-          $parts[] = self::value($value);
-        } else {
-          $parts[] = "$key=>".self::value($value);
-        }
-      }
-      return "[" . implode(", ", $parts) . "]";
-    } elseif (is_string($value)) {
-      return $value;
-    } else {
-      return var_export($value, true);
-    }
-  }
-
-  private static function message($value, ?string $message, ?string $kind, ?string $prefix, ?string $suffix): string {
-    if ($kind === null) $kind = "value";
-    if ($message === null) $message = "$kind$suffix";
-    if ($value !== null) {
-      $value = self::value($value);
-      if ($prefix) $prefix = "$prefix: $value";
-      else $prefix = $value;
-    }
-    if ($prefix) $prefix = "$prefix: ";
-    return $prefix.$message;
-  }
-
-  static final function null(?string $kind=null, ?string $prefix=null, ?string $message=null): self {
-    return new static(self::message(null, $message, $kind, $prefix, " should not be null"));
-  }
-
-  static final function check_null($value, ?string $kind=null, ?string $prefix=null, ?string $message=null) {
-    if ($value === null) throw static::null($kind, $prefix, $message);
-    return $value;
-  }
-
-  static final function invalid_kind($value=null, ?string $kind=null, ?string $prefix=null, ?string $message=null): self {
-    return new static(self::message($value, $message, $kind, $prefix, " is invalid"));
-  }
-
-  static final function invalid_key($value, ?string $prefix=null, ?string $message=null): self {
-    return self::invalid_kind($value, "key", $prefix, $message);
-  }
-
-  static final function invalid_value($value, ?string $prefix=null, ?string $message=null): self {
-    return self::invalid_kind($value, "value", $prefix, $message);
-  }
-
-  static final function invalid_type($value, string $expected_type): self {
-    return new static(self::message($value, null, "type", null, " is invalid, expected $expected_type"));
-  }
-
-  static final function invalid_class($class, string $expected_class): self {
-    if (is_object($class)) $class = get_class($class);
-    return new static(self::message($class, null, "class", null, " is invalid, expected $expected_class"));
-  }
-
-  static final function forbidden($value=null, ?string $kind=null, ?string $prefix=null, ?string $message=null): self {
-    return new static(self::message($value, $message, $kind, $prefix, " is forbidden"));
-  }
 }
diff --git a/php/src/app/RunFile.php b/php/src/app/RunFile.php
index bd34357..c82b5ad 100644
--- a/php/src/app/RunFile.php
+++ b/php/src/app/RunFile.php
@@ -2,7 +2,6 @@
 namespace nulib\app;
 
 use nulib\A;
-use nulib\app;
 use nulib\cl;
 use nulib\file\SharedFile;
 use nulib\os\path;
diff --git a/php/src/app/TODO.md b/php/src/app/TODO.md
index bec0657..4b12019 100644
--- a/php/src/app/TODO.md
+++ b/php/src/app/TODO.md
@@ -1,8 +1,5 @@
 # nulib\app
 
-* [ ] ajouter des méthodes normalisées `app::get_cachedir()` et
-  `app::get_cachefile($name)` avec la valeur par défaut
-  `cachedir = $vardir/cache`
 * [ ] `app::action()` et `app::step()` appellent automatiquement
   `app::_dispatch_signals()`
 
diff --git a/php/src/app/app.php b/php/src/app/app.php
new file mode 100644
index 0000000..87b88bc
--- /dev/null
+++ b/php/src/app/app.php
@@ -0,0 +1,655 @@
+getParams();
+    } elseif ($app instanceof Application) {
+      $class = get_class($app);
+      $params = [
+        "class" => $class,
+        "projdir" => $app::PROJDIR,
+        "vendor" => $app::VENDOR,
+        "projcode" => $app::PROJCODE,
+        "datadir" => $app::DATADIR,
+        "etcdir" => $app::ETCDIR,
+        "vardir" => $app::VARDIR,
+        "cachedir" => $app::CACHEDIR,
+        "logdir" => $app::LOGDIR,
+        "appgroup" => $app::APPGROUP,
+        "name" => $app::NAME,
+        "title" => $app::TITLE,
+      ];
+    } elseif (self::isa_Application($app)) {
+      $class = $app;
+      $params = [
+        "class" => $class,
+        "projdir" => constant("$app::PROJDIR"),
+        "vendor" => constant("$app::VENDOR"),
+        "projcode" => constant("$app::PROJCODE"),
+        "datadir" => constant("$app::DATADIR"),
+        "etcdir" => constant("$app::ETCDIR"),
+        "vardir" => constant("$app::VARDIR"),
+        "cachedir" => constant("$app::CACHEDIR"),
+        "logdir" => constant("$app::LOGDIR"),
+        "appgroup" => constant("$app::APPGROUP"),
+        "name" => constant("$app::NAME"),
+        "title" => constant("$app::TITLE"),
+      ];
+    } elseif (is_array($app)) {
+      $params = $app;
+    } else {
+      throw exceptions::invalid_type($app, "app", Application::class);
+    }
+    return $params;
+  }
+
+  protected static ?self $app = null;
+
+  /**
+   * @param Application|string|array $app
+   * @param Application|string|array|null $proj
+   */
+  static function with($app, $proj=null): self {
+    $params = self::get_params($app);
+    $proj ??= self::params_getenv();
+    $proj ??= self::$app;
+    $proj_params = $proj !== null? self::get_params($proj): null;
+    if ($proj_params !== null) {
+      A::merge($params, cl::select($proj_params, [
+        "projdir",
+        "vendor",
+        "projcode",
+        "cwd",
+        "datadir",
+        "etcdir",
+        "vardir",
+        "cachedir",
+        "logdir",
+        "profile",
+        "facts",
+        "debug",
+      ]));
+    }
+    return new static($params, $proj_params !== null);
+  }
+
+  static function init($app, $proj=null): void {
+    self::$app = static::with($app, $proj);
+  }
+
+  static function get(): self {
+    return self::$app ??= new static(null);
+  }
+
+  static function params_putenv(): void {
+    $params = serialize(self::get()->getParams());
+    putenv("NULIB_APP_app_params=$params");
+  }
+
+  static function params_getenv(): ?array {
+    $params = getenv("NULIB_APP_app_params");
+    if ($params === false) return null;
+    return unserialize($params);
+  }
+
+  static function get_profile(?bool &$productionMode=null): string {
+    return self::get()->getProfile($productionMode);
+  }
+
+  static function is_production_mode(): bool {
+    return self::get()->isProductionMode();
+  }
+
+  static function is_prod(): bool {
+    return self::get_profile() === ref_profiles::PROD;
+  }
+
+  static function is_test(): bool {
+    return self::get_profile() === ref_profiles::TEST;
+  }
+
+  static function is_devel(): bool {
+    return self::get_profile() === ref_profiles::DEVEL;
+  }
+
+  static function set_profile(?string $profile=null, ?bool $productionMode=null): void {
+    self::get()->setProfile($profile, $productionMode);
+  }
+
+  const FACT_WEB_APP = "web-app";
+  const FACT_CLI_APP = "cli-app";
+
+  static final function is_fact(string $fact, $value=true): bool {
+    return self::get()->isFact($fact, $value);
+  }
+
+  static final function set_fact(string $fact, $value=true): void {
+    self::get()->setFact($fact, $value);
+  }
+
+  static function is_debug(): bool {
+    return self::get()->isDebug();
+  }
+
+  static function set_debug(?bool $debug=true): void {
+    self::get()->setDebug($debug);
+  }
+
+  /**
+   * @var array répertoires vendor exprimés relativement à PROJDIR
+   */
+  const DEFAULT_VENDOR = [
+    "bindir" => "vendor/bin",
+    "autoload" => "vendor/autoload.php",
+  ];
+
+  function __construct(?array $params, bool $useProjParams=false) {
+    if ($useProjParams) {
+      [
+        "projdir" => $projdir,
+        "vendor" => $vendor,
+        "projcode" => $projcode,
+        "datadir" => $datadir,
+        "etcdir" => $etcdir,
+        "vardir" => $vardir,
+        "cachedir" => $cachedir,
+        "logdir" => $logdir,
+      ] = $params;
+      $cwd = $params["cwd"] ?? null;
+      $datadirIsDefined = true;
+    } else {
+      # projdir
+      $projdir = $params["projdir"] ?? null;
+      if ($projdir === null) {
+        global $_composer_autoload_path, $_composer_bin_dir;
+        $autoload = $_composer_autoload_path ?? null;
+        $bindir = $_composer_bin_dir ?? null;
+        if ($autoload !== null) {
+          $vendor = preg_replace('/\/[^\/]+\.php$/', "", $autoload);
+          $bindir ??= "$vendor/bin";
+          $projdir = preg_replace('/\/[^\/]+$/', "", $vendor);
+          $params["vendor"] = [
+            "autoload" => $autoload,
+            "bindir" => $bindir,
+          ];
+        }
+      }
+      if ($projdir === null) $projdir = ".";
+      $projdir = path::abspath($projdir);
+      # vendor
+      $vendor = $params["vendor"] ?? self::DEFAULT_VENDOR;
+      $vendor["bindir"] = path::reljoin($projdir, $vendor["bindir"]);
+      $vendor["autoload"] = path::reljoin($projdir, $vendor["autoload"]);
+      # projcode
+      $projcode = $params["projcode"] ?? null;
+      if ($projcode === null) {
+        $projcode = str::without_suffix("-app", path::basename($projdir));
+      }
+      $PROJCODE = str_replace("-", "_", strtoupper($projcode));
+      # cwd
+      $cwd = $params["cwd"] ?? null;
+      # datadir
+      $datadir = getenv("${PROJCODE}_DATADIR");
+      $datadirIsDefined = $datadir !== false;
+      if ($datadir === false) $datadir = $params["datadir"] ?? null;
+      if ($datadir === null) $datadir = "devel";
+      $datadir = path::reljoin($projdir, $datadir);
+      # etcdir
+      $etcdir = getenv("${PROJCODE}_ETCDIR");
+      if ($etcdir === false) $etcdir = $params["etcdir"] ?? null;
+      if ($etcdir === null) $etcdir = "etc";
+      $etcdir = path::reljoin($datadir, $etcdir);
+      # vardir
+      $vardir = getenv("${PROJCODE}_VARDIR");
+      if ($vardir === false) $vardir = $params["vardir"] ?? null;
+      if ($vardir === null) $vardir = "var";
+      $vardir = path::reljoin($datadir, $vardir);
+      # cachedir
+      $cachedir = getenv("${PROJCODE}_CACHEDIR");
+      if ($cachedir === false) $cachedir = $params["cachedir"] ?? null;
+      if ($cachedir === null) $cachedir = "cache";
+      $cachedir = path::reljoin($vardir, $cachedir);
+      # logdir
+      $logdir = getenv("${PROJCODE}_LOGDIR");
+      if ($logdir === false) $logdir = $params["logdir"] ?? null;
+      if ($logdir === null) $logdir = "log";
+      $logdir = path::reljoin($datadir, $logdir);
+    }
+    # cwd
+    $cwd ??= getcwd();
+    # profile
+    $this->profileManager = new ProfileManager([
+      "app" => true,
+      "name" => $projcode,
+      "default_profile" => $datadirIsDefined? "prod": "devel",
+      "profile" => $params["profile"] ?? null,
+    ]);
+    # $facts
+    $this->facts = $params["facts"] ?? null;
+    # debug
+    $this->debug = $params["debug"] ?? null;
+
+    $this->projdir = $projdir;
+    $this->vendor = $vendor;
+    $this->projcode = $projcode;
+    $this->cwd = $cwd;
+    $this->datadir = $datadir;
+    $this->etcdir = $etcdir;
+    $this->vardir = $vardir;
+    $this->cachedir = $cachedir;
+    $this->logdir = $logdir;
+
+    # name, title
+    $appgroup = $params["appgroup"] ?? null;
+    $name = $params["name"] ?? $params["class"] ?? null;
+    if ($name === null) {
+      $name = $projcode;
+    } else {
+      # si $name est une classe, enlever le package et normaliser i.e
+      # my\package\MyApplication --> my-application.php
+      $name = preg_replace('/.*\\\\/', "", $name);
+      $name = str::camel2us($name, false, "-");
+      $name = str::without_suffix("-app", $name);
+    }
+    $this->appgroup = $appgroup;
+    $this->name = $name;
+    $this->title = $params["title"] ?? null;
+  }
+
+  #############################################################################
+  # Paramètres partagés par tous les scripts d'un projet (et les scripts lancés
+  # à partir d'une application de ce projet)
+
+  protected string $projdir;
+
+  function getProjdir(): string {
+    return $this->projdir;
+  }
+
+  protected array $vendor;
+
+  function getVendorBindir(): string {
+    return $this->vendor["bindir"];
+  }
+
+  function getVendorAutoload(): string {
+    return $this->vendor["autoload"];
+  }
+
+  protected string $projcode;
+
+  function getProjcode(): string {
+    return $this->projcode;
+  }
+
+  protected string $cwd;
+
+  function getCwd(): string {
+    return $this->cwd;
+  }
+
+  protected string $datadir;
+
+  function getDatadir(): string {
+    return $this->datadir;
+  }
+
+  protected string $etcdir;
+
+  function getEtcdir(): string {
+    return $this->etcdir;
+  }
+
+  protected string $vardir;
+
+  function getVardir(): string {
+    return $this->vardir;
+  }
+
+  protected string $cachedir;
+
+  function getCachedir(): string {
+    return $this->cachedir;
+  }
+
+  protected string $logdir;
+
+  function getLogdir(): string {
+    return $this->logdir;
+  }
+
+  protected ProfileManager $profileManager;
+
+  function getProfile(?bool &$productionMode=null): string {
+    return $this->profileManager->getProfile($productionMode);
+  }
+
+  function isProductionMode(): bool {
+    return $this->profileManager->isProductionMode();
+  }
+
+  function setProfile(?string $profile, ?bool $productionMode=null): void {
+    $this->profileManager->setProfile($profile, $productionMode);
+  }
+
+  protected ?array $facts;
+
+  function isFact(string $fact, $value=true): bool {
+    return ($this->facts[$fact] ?? false) === $value;
+  }
+
+  function setFact(string $fact, $value=true): void {
+    $this->facts[$fact] = $value;
+  }
+
+  protected ?bool $debug;
+
+  function isDebug(): bool {
+    $debug = $this->debug;
+    if ($debug === null) {
+      $debug = defined("DEBUG")? DEBUG: null;
+      $DEBUG = getenv("DEBUG");
+      $debug ??= $DEBUG !== false? $DEBUG: null;
+      $debug ??= config::k("debug");
+      $debug ??= false;
+      $this->debug = $debug;
+    }
+    return $debug;
+  }
+
+  function setDebug(bool $debug=true): void {
+    $this->debug = $debug;
+  }
+
+  /**
+   * @param ?string|false $profile
+   *
+   * false === pas de profil
+   * null === profil par défaut
+   */
+  function withProfile(string $file, $profile): string {
+    if ($profile !== false) {
+      $profile ??= $this->getProfile();
+      [$dir, $filename] = path::split($file);
+      $basename = path::basename($filename);
+      $ext = path::ext($file);
+      $file = path::join($dir, "$basename.$profile$ext");
+    }
+    return $file;
+  }
+
+  function findFile(array $dirs, array $names, $profile=null): string {
+    # d'abord chercher avec le profil
+    if ($profile !== false) {
+      foreach ($dirs as $dir) {
+        foreach ($names as $name) {
+          $file = path::join($dir, $name);
+          $file = $this->withProfile($file, $profile);
+          if (file_exists($file)) return $file;
+        }
+      }
+    }
+    # puis sans profil
+    foreach ($dirs as $dir) {
+      foreach ($names as $name) {
+        $file = path::join($dir, $name);
+        if (file_exists($file)) return $file;
+      }
+    }
+    # la valeur par défaut est avec profil
+    return $this->withProfile(path::join($dirs[0], $names[0]), $profile);
+  }
+
+  function fencedJoin(string $basedir, ?string ...$paths): string {
+    $path = path::reljoin($basedir, ...$paths);
+    if (!path::is_within($path, $basedir)) {
+      throw exceptions::invalid_value($path, "path");
+    }
+    return $path;
+  }
+
+  #############################################################################
+  # Paramètres spécifiques à cette application
+
+  protected ?string $appgroup;
+
+  function getAppgroup(): ?string {
+    return $this->appgroup;
+  }
+
+  protected string $name;
+
+  function getName(): ?string {
+    return $this->name;
+  }
+
+  protected ?string $title;
+
+  function getTitle(): ?string {
+    return $this->title;
+  }
+
+  #############################################################################
+  # Méthodes outils
+
+  /** recréer le tableau des paramètres */
+  function getParams(): array {
+    return [
+      "projdir" => $this->projdir,
+      "vendor" => $this->vendor,
+      "projcode" => $this->projcode,
+      "cwd" => $this->cwd,
+      "datadir" => $this->datadir,
+      "etcdir" => $this->etcdir,
+      "vardir" => $this->vardir,
+      "cachedir" => $this->cachedir,
+      "logdir" => $this->logdir,
+      "profile" => $this->getProfile(),
+      "facts" => $this->facts,
+      "debug" => $this->debug,
+      "appgroup" => $this->appgroup,
+      "name" => $this->name,
+      "title" => $this->title,
+    ];
+  }
+
+  /**
+   * obtenir le chemin vers le fichier de configuration. par défaut, retourner
+   * une valeur de la forme "$ETCDIR/$name[.$profile].conf"
+   */
+  function getEtcfile(?string $name=null, $profile=null): string {
+    $name ??= "{$this->name}.conf";
+    return $this->findFile([$this->etcdir], [$name], $profile);
+  }
+
+  /**
+   * obtenir le chemin vers le fichier de travail. par défaut, retourner une
+   * valeur de la forme "$VARDIR/$appgroup/$name[.$profile].tmp"
+   */
+  function getVarfile(?string $name=null, $profile=null): string {
+    $name ??= "{$this->name}.tmp";
+    $file = $this->fencedJoin($this->vardir, $this->appgroup, $name);
+    $file = $this->withProfile($file, $profile);
+    sh::mkdirof($file);
+    return $file;
+  }
+
+  /**
+   * obtenir le chemin vers le fichier de cache. par défaut, retourner une
+   * valeur de la forme "$CACHEDIR/$appgroup/$name[.$profile].cache"
+   */
+  function getCachefile(?string $name=null, $profile=null): string {
+    $name ??= "{$this->name}.cache";
+    $file = $this->fencedJoin($this->cachedir, $this->appgroup, $name);
+    $file = $this->withProfile($file, $profile);
+    sh::mkdirof($file);
+    return $file;
+  }
+
+  /**
+   * obtenir le chemin vers le fichier de log. par défaut, retourner une
+   * valeur de la forme "$LOGDIR/$appgroup/$name.log" (sans le profil, parce
+   * qu'il s'agit du fichier de log par défaut)
+   *
+   * Si $name est spécifié, la valeur retournée sera de la forme
+   * "$LOGDIR/$appgroup/$basename[.$profile].$ext"
+   */
+  function getLogfile(?string $name=null, $profile=null): string {
+    if ($name === null) {
+      $name = "{$this->name}.log";
+      $profile ??= false;
+    }
+    $logfile = $this->fencedJoin($this->logdir, $this->appgroup, $name);
+    $logfile = $this->withProfile($logfile, $profile);
+    sh::mkdirof($logfile);
+    return $logfile;
+  }
+
+  /**
+   * obtenir le chemin absolu vers un fichier de travail
+   * - si le chemin est absolu, il est inchangé
+   * - sinon le chemin est exprimé par rapport à $vardir/$appgroup
+   *
+   * is $ensureDir, créer le répertoire du fichier s'il n'existe pas déjà
+   *
+   * la différence avec {@link self::getVarfile()} est que le fichier peut
+   * au final être situé ailleurs que dans $vardir. de plus, il n'y a pas de
+   * valeur par défaut pour $file
+   */
+  function getWorkfile(string $file, $profile=null, bool $ensureDir=true): string {
+    $file = path::reljoin($this->vardir, $this->appgroup, $file);
+    $file = $this->withProfile($file, $profile);
+    if ($ensureDir) sh::mkdirof($file);
+    return $file;
+  }
+
+  /**
+   * obtenir le chemin absolu vers un fichier spécifié par l'utilisateur.
+   * - si le chemin commence par /, il est laissé en l'état
+   * - si le chemin commence par ./ ou ../, il est exprimé par rapport à $cwd
+   * - sinon le chemin est exprimé par rapport à $vardir/$appgroup
+   *
+   * la différence est avec {@link self::getVarfile()} est que le fichier peut
+   * au final être situé ailleurs que dans $vardir. de plus, il n'y a pas de
+   * valeur par défaut pour $file
+   */
+  function getUserfile(string $file): string {
+    if (path::is_qualified($file)) {
+      return path::reljoin($this->cwd, $file);
+    } else {
+      return path::reljoin($this->vardir, $this->appgroup, $file);
+    }
+  }
+
+  protected ?RunFile $runfile = null;
+
+  function getRunfile(): RunFile {
+    $name = $this->name;
+    $runfile = $this->getWorkfile($name);
+    $logfile = $this->getLogfile("$name.out", false);
+    return $this->runfile ??= new RunFile($name, $runfile, $logfile);
+  }
+
+  protected ?array $lockFiles = null;
+
+  function getLockfile(?string $name=null): LockFile {
+    $this->lockFiles[$name] ??= $this->getRunfile()->getLockFile($name, $this->title);
+    return $this->lockFiles[$name];
+  }
+
+  #############################################################################
+
+  const EC_FORK_CHILD = 250;
+  const EC_FORK_PARENT = 251;
+  const EC_DISABLED = 252;
+  const EC_LOCKED = 253;
+  const EC_BAD_COMMAND = 254;
+  const EC_UNEXPECTED = 255;
+
+  #############################################################################
+
+  static bool $dispach_signals = false;
+
+  static function install_signal_handler(bool $allow=true): void {
+    if (!$allow) return;
+    $signalHandler = function(int $signo, $siginfo) {
+      throw new ExitError(128 + $signo);
+    };
+    pcntl_signal(SIGHUP, $signalHandler);
+    pcntl_signal(SIGINT, $signalHandler);
+    pcntl_signal(SIGQUIT, $signalHandler);
+    pcntl_signal(SIGTERM, $signalHandler);
+    self::$dispach_signals = true;
+  }
+
+  static function _dispatch_signals() {
+    if (self::$dispach_signals) pcntl_signal_dispatch();
+  }
+
+  #############################################################################
+
+  static ?func $bgapplication_enabled = null;
+
+  /**
+   * spécifier la fonction permettant de vérifier si l'exécution de tâches
+   * de fond est autorisée. Si cette méthode n'est pas utilisée, par défaut,
+   * les tâches planifiées sont autorisées
+   *
+   * si $func===true, spécifier une fonction qui retourne toujours vrai
+   * si $func===false, spécifiée une fonction qui retourne toujours faux
+   * sinon, $func doit être une fonction valide
+   */
+  static function set_bgapplication_enabled($func): void {
+    if (is_bool($func)) {
+      $enabled = $func;
+      $func = function () use ($enabled) {
+        return $enabled;
+      };
+    }
+    self::$bgapplication_enabled = func::with($func);
+  }
+
+  /**
+   * Si les exécutions en tâche de fond sont autorisée, retourner. Sinon
+   * afficher une erreur et quitter l'application
+   */
+  static function check_bgapplication_enabled(bool $forceEnabled=false): void {
+    if (self::$bgapplication_enabled === null || $forceEnabled) return;
+    if (!self::$bgapplication_enabled->invoke()) {
+      throw new ExitError(self::EC_DISABLED, "Planifications désactivées. La tâche n'a pas été lancée");
+    }
+  }
+
+  #############################################################################
+
+  static function action(?string $title, ?int $maxSteps=null): void {
+    self::get()->getRunfile()->action($title, $maxSteps);
+  }
+
+  static function step(int $nbSteps=1): void {
+    self::get()->getRunfile()->step($nbSteps);
+  }
+}
diff --git a/php/src/app/args/AbstractArgsParser.php b/php/src/app/args/AbstractArgsParser.php
new file mode 100644
index 0000000..f1fc241
--- /dev/null
+++ b/php/src/app/args/AbstractArgsParser.php
@@ -0,0 +1,110 @@
+ 0) throw $this->notEnoughArgs($count, $option);
+  }
+
+  protected function tooManyArgs(int $count, int $expected, ?string $arg=null): ArgsException {
+    if ($arg !== null) $arg .= ": ";
+    $reason = $arg._exceptions::unexpected_value_message($count - $expected);
+    return _exceptions::unexpected_value(null, null, $reason);
+  }
+
+  protected function invalidArg(string $arg): ArgsException {
+    return _exceptions::invalid_value($arg);
+  }
+
+  protected function ambiguousArg(string $arg, array $candidates): ArgsException {
+    $candidates = implode(", ", $candidates);
+    return new ArgsException("$arg: cet argument est ambigû (les valeurs possibles sont $candidates)");
+  }
+
+  /**
+   * consommer les arguments de $src en avançant l'index $srci et provisionner
+   * $dest à partir de $desti. si $desti est plus grand que 0, celà veut dire
+   * que $dest a déjà commencé à être provisionné, et qu'il faut continuer.
+   *
+   * $destmin est le nombre minimum d'arguments à consommer. $destmax est le
+   * nombre maximum d'arguments à consommer.
+   *
+   * $srci est la position de l'élément courant à consommer le cas échéant
+   * retourner le nombre d'arguments qui manquent (ou 0 si tous les arguments
+   * ont été consommés)
+   *
+   * pour les arguments optionnels, ils sont consommés tant qu'il y en a de
+   * disponible, ou jusqu'à la présence de '--'. Si $keepsep, l'argument '--'
+   * est gardé dans la liste des arguments optionnels.
+   */
+  protected static function consume_args($src, &$srci, &$dest, $desti, $destmin, $destmax, bool $keepsep): int {
+    $srcmax = count($src);
+    # arguments obligatoires
+    while ($desti < $destmin) {
+      if ($srci < $srcmax) {
+        $dest[] = $src[$srci];
+      } else {
+        # pas assez d'arguments
+        return $destmin - $desti;
+      }
+      $srci++;
+      $desti++;
+    }
+    # arguments facultatifs
+    $eoo = false; // l'option a-t-elle été terminée?
+    while ($desti < $destmax && $srci < $srcmax) {
+      $opt = $src[$srci];
+      $srci++;
+      $desti++;
+      if ($opt === "--") {
+        # fin des arguments facultatifs en entrée
+        $eoo = true;
+        if ($keepsep) $dest[] = "--";
+        break;
+      }
+      $dest[] = $opt;
+    }
+    if (!$eoo && $desti < $destmax) {
+      # pas assez d'arguments en entrée, terminer avec "--"
+      if ($keepsep) $dest[] = "--";
+    }
+    return 0;
+  }
+
+  abstract function normalize(array $args): array;
+
+  /** @var object|array objet destination */
+  protected $dest;
+
+  protected function setDest(&$dest): void {
+    $this->dest =& $dest;
+  }
+
+  protected function unsetDest(): void {
+    unset($this->dest);
+  }
+
+  abstract function process(array $args);
+
+  function parse(&$dest, array $args=null): void {
+    if ($args === null) {
+      global $argv;
+      $args = array_slice($argv, 1);
+    }
+    $args = $this->normalize($args);
+    $dest ??= new stdClass();
+    $this->setDest($dest);
+    $this->process($args);
+    $this->unsetDest();
+  }
+
+  abstract function actionPrintHelp(string $arg): void;
+}
diff --git a/php/src/app/args/Aodef.php b/php/src/app/args/Aodef.php
new file mode 100644
index 0000000..921a197
--- /dev/null
+++ b/php/src/app/args/Aodef.php
@@ -0,0 +1,643 @@
+origDef = $def;
+    $this->mergeParse($def);
+    //$this->debugTrace("construct");
+  }
+
+  protected array $origDef;
+
+  public bool $show = true;
+  public ?bool $disabled = null;
+  public ?bool $isRemains = null;
+  public ?string $extends = null;
+
+  protected ?array $_removes = null;
+  protected ?array $_adds = null;
+
+  protected ?array $_args = null;
+  public ?string $argsdesc = null;
+
+  public ?bool $ensureArray = null;
+  public $action = null;
+  public ?func $func = null;
+  public ?bool $inverse = null;
+  public $value = null;
+  public ?string $name = null;
+  public ?string $property = null;
+  public ?string $key = null;
+
+  public ?string $help = null;
+
+  protected ?array $_options = [];
+
+  public bool $haveShortOptions = false;
+  public bool $haveLongOptions = false;
+  public bool $isCommand = false;
+  public bool $isHelp = false;
+
+  public bool $haveArgs = false;
+  public ?int $minArgs = null;
+  public ?int $maxArgs = null;
+
+  protected function mergeParse(array $def): void {
+    $merges = $defs["merges"] ?? null;
+    $merge = $defs["merge"] ?? null;
+    if ($merge !== null) $merges[] = $merge;
+    if ($merges !== null) {
+      foreach ($merges as $merge) {
+        if ($merge !== null) $this->mergeParse($merge);
+      }
+    }
+
+    $this->parse($def);
+
+    $merge = $defs["merge_after"] ?? null;
+    if ($merge !== null) $this->mergeParse($merge);
+  }
+
+  private static function verifix_args(?array &$options): ?array {
+    $args = null;
+    if ($options !== null) {
+      foreach ($options as &$option) {
+        if (preg_match('/^(.*:)([^:].*)$/', $option, $ms)) {
+          $option = $ms[1];
+          $args ??= explode(",", $ms[2]);
+        }
+      }; unset($option);
+    }
+    return $args;
+  }
+
+  protected function parse(array $def): void {
+    [$options, $params] = cl::split_assoc($def);
+
+    $this->show ??= $params["show"] ?? true;
+    $this->extends ??= $params["extends"] ?? null;
+
+    $args ??= $params["args"] ?? null;
+    $args ??= $params["arg"] ?? null;
+    if ($args === true) $args = 1;
+    elseif ($args === "*") $args = [null];
+    elseif ($args === "+") $args = ["value", null];
+    if (is_int($args)) $args = array_fill(0, $args, "value");
+
+    $this->disabled = vbool::withn($params["disabled"] ?? null);
+    $adds = varray::withn($params["add"] ?? null);
+    A::merge($this->_adds, $adds);
+    A::merge($this->_adds, $options);
+    $args ??= self::verifix_args($this->_adds);
+    $removes = varray::withn($params["remove"] ?? null);
+    A::merge($this->_removes, $removes);
+    self::verifix_args($this->_adds);
+
+    $this->_args ??= cl::withn($args);
+    $this->argsdesc ??= $params["argsdesc"] ?? null;
+
+    $this->ensureArray ??= $params["ensure_array"] ?? null;
+    $this->action = $params["action"] ?? null;
+    $this->inverse ??= $params["inverse"] ?? null;
+    $this->value ??= $params["value"] ?? null;
+    $this->name ??= $params["name"] ?? null;
+    $this->property ??= $params["property"] ?? null;
+    $this->key ??= $params["key"] ?? null;
+
+    $this->help ??= $params["help"] ?? null;
+  }
+
+  function isExtends(): bool {
+    return $this->extends !== null;
+  }
+
+  function setup1(bool $extends=false, ?Aolist $aolist=null): void {
+    if (!$extends && !$this->isExtends()) {
+      $this->processOptions();
+    } elseif ($extends && $this->isExtends()) {
+      $this->processExtends($aolist);
+    }
+    $this->initRemains();
+    //$this->debugTrace("setup1");
+  }
+
+  protected function processExtends(Aolist $argdefs): void {
+    $option = $this->extends;
+    if ($option === null) {
+      throw _exceptions::null_value("extends", "il doit spécifier l'argument destination");
+    }
+    $dest = $argdefs->get($option);
+    if ($dest === null) {
+      throw _exceptions::invalid_value($option, "extends", "il doit spécifier un argument valide");
+    }
+
+    if ($this->ensureArray !== null) $dest->ensureArray = $this->ensureArray;
+    if ($this->action !== null) $dest->action = $this->action;
+    if ($this->inverse !== null) $dest->inverse = $this->inverse;
+    if ($this->value !== null) $dest->value = $this->value;
+    if ($this->name !== null) $dest->name = $this->name;
+    if ($this->property !== null) $dest->property = $this->property;
+    if ($this->key !== null) $dest->key = $this->key;
+
+    A::merge($dest->_removes, $this->_removes);
+    A::merge($dest->_adds, $this->_adds);
+    $dest->processOptions();
+  }
+
+  function buildOptions(?array $options): array {
+    $result = [];
+    if ($options !== null) {
+      foreach ($options as $option) {
+        if (substr($option, 0, 2) === "--") {
+          $type = self::TYPE_LONG;
+          if (preg_match('/^--([^:-][^:]*)(::?)?$/', $option, $ms)) {
+            $name = $ms[1];
+            $args = $ms[2] ?? null;
+            $option = "--$name";
+          } else {
+            throw _exceptions::invalid_value($option, "cette option longue");
+          }
+        } elseif (substr($option, 0, 1) === "-") {
+          $type = self::TYPE_SHORT;
+          if (preg_match('/^-([^:-])(::?)?$/', $option, $ms)) {
+            $name = $ms[1];
+            $args = $ms[2] ?? null;
+            $option = "-$name";
+          } else {
+            throw _exceptions::invalid_value($option, " cette option courte");
+          }
+        } else {
+          $type = self::TYPE_COMMAND;
+          if (preg_match('/^([^:-][^:]*)$/', $option, $ms)) {
+            $name = $ms[1];
+            $args = null;
+            $option = "$name";
+          } else {
+            throw _exceptions::invalid_value($option, "cette commande");
+          }
+        }
+        if ($args === ":") {
+          $argsType = self::ARGS_MANDATORY;
+        } elseif ($args === "::") {
+          $argsType = self::ARGS_OPTIONAL;
+        } else {
+          $argsType = self::ARGS_NONE;
+        }
+        $result[$option] = [
+          "name" => $name,
+          "option" => $option,
+          "type" => $type,
+          "args_type" => $argsType,
+        ];
+      }
+    }
+    return $result;
+  }
+
+  protected function initRemains(): void {
+    if ($this->isRemains === null) {
+      $options = array_fill_keys(array_keys($this->_options), true);
+      foreach (array_keys($this->buildOptions($this->_removes)) as $option) {
+        unset($options[$option]);
+      }
+      foreach (array_keys($this->buildOptions($this->_adds)) as $option) {
+        unset($options[$option]);
+      }
+      if (!$options) $this->isRemains = true;
+    }
+  }
+
+  /** traiter le paramètre parent */
+  protected function processOptions(): void {
+    $this->removeOptions($this->_removes);
+    $this->_removes = null;
+    $this->addOptions($this->_adds);
+    $this->_adds = null;
+  }
+
+  function addOptions(?array $options): void {
+    // les options pouvant être numériques (e.g "-1"), utiliser A::merge2
+    A::merge2($this->_options, $this->buildOptions($options));
+    $this->updateType();
+  }
+
+  function removeOptions(?array $options): void {
+    foreach ($this->buildOptions($options) as $option) {
+      unset($this->_options[$option["option"]]);
+    }
+    $this->updateType();
+  }
+
+  function removeOption(string $option): void {
+    unset($this->_options[$option]);
+  }
+
+  /** mettre à jour le type d'option */
+  protected function updateType(): void {
+    $haveShortOptions = false;
+    $haveLongOptions = false;
+    $isCommand = false;
+    $isHelp = false;
+    foreach ($this->_options as $option) {
+      switch ($option["type"]) {
+      case self::TYPE_SHORT:
+        $haveShortOptions = true;
+        break;
+      case self::TYPE_LONG:
+        $haveLongOptions = true;
+        break;
+      case self::TYPE_COMMAND:
+        $isCommand = true;
+        break;
+      }
+      switch ($option["option"]) {
+      case "--help":
+      case "--help++":
+        $isHelp = true;
+        break;
+      }
+    }
+    $this->haveShortOptions = $haveShortOptions;
+    $this->haveLongOptions = $haveLongOptions;
+    $this->isCommand = $isCommand;
+    $this->isHelp = $isHelp;
+  }
+
+  function setup2(): void {
+    $this->processArgs();
+    $this->processAction();
+    $this->afterSetup();
+    //$this->debugTrace("setup2");
+  }
+
+  /**
+   * traiter les informations concernant les arguments puis calculer les nombres
+   * minimum et maximum d'arguments que prend l'option
+   */
+  protected function processArgs(): void {
+    $args = $this->_args;
+    if ($this->isRemains) {
+      $args ??= [null];
+      $haveArgs = boolval($args);
+    } elseif ($args === null) {
+      $haveArgs = false;
+      $optionalArgs = null;
+      foreach ($this->_options as $option) {
+        switch ($option["args_type"]) {
+        case self::ARGS_NONE:
+          break;
+        case self::ARGS_MANDATORY:
+          $haveArgs = true;
+          $optionalArgs = false;
+          break;
+        case self::ARGS_OPTIONAL:
+          $haveArgs = true;
+          $optionalArgs ??= true;
+          break;
+        }
+      }
+      $optionalArgs ??= false;
+      if ($haveArgs) {
+        $args = ["value"];
+        if ($optionalArgs) $args = [$args];
+      }
+    } else {
+      $haveArgs = boolval($args);
+    }
+
+    if ($this->isRemains) $desc = "remaining args";
+    else $desc = cl::first($this->_options)["option"];
+
+    $args ??= [];
+    $argsdesc = [];
+    $reqs = [];
+    $haveNull = false;
+    $optArgs = null;
+    foreach ($args as $arg) {
+      if (is_string($arg)) {
+        $reqs[] = $arg;
+        $argsdesc[] = strtoupper($arg);
+      } elseif (is_array($arg)) {
+        $optArgs = $arg;
+        break;
+      } elseif ($arg === null) {
+        $haveNull = true;
+        break;
+      } else {
+        throw _exceptions::invalid_value("$desc: $arg");
+      }
+    }
+
+    $opts = [];
+    $optArgsdesc = null;
+    $lastarg = "VALUE";
+    if ($optArgs !== null) {
+      $haveOpt = false;
+      foreach ($optArgs as $arg) {
+        if (is_string($arg)) {
+          $haveOpt = true;
+          $opts[] = $arg;
+          $lastarg = strtoupper($arg);
+          $optArgsdesc[] = $lastarg;
+        } elseif ($arg === null) {
+          $haveNull = true;
+          break;
+        } else {
+          throw _exceptions::invalid_value("$desc: $arg");
+        }
+      }
+      if (!$haveOpt) $haveNull = true;
+    }
+    if ($haveNull) $optArgsdesc[] = "${lastarg}s...";
+    if ($optArgsdesc !== null) {
+      $argsdesc[] = "[".implode(" ", $optArgsdesc)."]";
+    }
+
+    $minArgs = count($reqs);
+    if ($haveNull) $maxArgs = PHP_INT_MAX;
+    else $maxArgs = $minArgs + count($opts);
+
+    $this->haveArgs = $haveArgs;
+    $this->minArgs = $minArgs;
+    $this->maxArgs = $maxArgs;
+    $this->argsdesc ??= implode(" ", $argsdesc);
+  }
+
+  private static function get_longest(array $options, int $type): ?string {
+    $longest = null;
+    $maxlen = 0;
+    foreach ($options as $option) {
+      if ($option["type"] !== $type) continue;
+      $name = $option["name"];
+      $len = strlen($name);
+      if ($len > $maxlen) {
+        $longest = $name;
+        $maxlen = $len;
+      }
+    }
+    return $longest;
+  }
+
+  protected function processAction(): void {
+    $this->ensureArray ??= $this->isRemains || $this->maxArgs > 1;
+
+    $action = $this->action;
+    $func = $this->func;
+    if ($action === null) {
+      if ($this->isCommand) $action = "--set-command";
+      elseif ($this->isRemains) $action = "--set-args";
+      elseif ($this->isHelp) $action = "--show-help";
+      elseif ($this->haveArgs) $action = "--set";
+      elseif ($this->value !== null) $action = "--set";
+      else $action = "--inc";
+    }
+    if (is_string($action) && substr($action, 0, 2) === "--") {
+      # fonction interne
+    } else {
+      $func = func::with($action);
+      $action = "--func";
+    }
+    $this->action = $action;
+    $this->func = $func;
+
+    $name = $this->name;
+    $property = $this->property;
+    $key = $this->key;
+    if ($action !== "--func" && !$this->isRemains &&
+      $name === null && $property === null && $key === null
+    ) {
+      # si on ne précise pas le nom de la propriété, la dériver à partir du
+      # nom de l'option la plus longue
+      $longest = self::get_longest($this->_options, self::TYPE_LONG);
+      $longest ??= self::get_longest($this->_options, self::TYPE_COMMAND);
+      $longest ??= self::get_longest($this->_options, self::TYPE_SHORT);
+      if ($longest !== null) {
+        $longest = preg_replace('/[^A-Za-z0-9]+/', "_", $longest);
+        # les options --no-name mettent à jour la valeur $name et inversent
+        # le traitement
+        if ($longest !== "no_" && str::del_prefix($longest, "no_")) {
+          $this->inverse ??= true;
+        }
+        if (preg_match('/^[0-9]/', $longest)) {
+          # le nom de la propriété ne doit pas commencer par un chiffre
+          $longest = "p$longest";
+        }
+        $name = $longest;
+      }
+    } elseif ($name === null && $property !== null) {
+      $name = $property;
+    } elseif ($name === null && $key !== null) {
+      $name = $key;
+    }
+    $this->name = $name;
+  }
+
+  protected function afterSetup(): void {
+    $this->disabled ??= false;
+    $this->ensureArray ??= false;
+    $this->inverse ??= false;
+    if (str::del_prefix($this->help, "++")) {
+      $this->show = false;
+    }
+  }
+
+  function getOptions(): array {
+    if ($this->disabled) return [];
+    else return array_keys($this->_options);
+  }
+
+  function isEmpty(): bool {
+    return $this->disabled || (!$this->_options && !$this->isRemains);
+  }
+
+  function printHelp(?array $what=null): void {
+    $showDef = $what["show"] ?? $this->show;
+    if (!$showDef || $this->isRemains) return;
+
+    $prefix = $what["prefix"] ?? null;
+    if ($prefix !== null) echo $prefix;
+
+    $showOptions = $what["options"] ?? true;
+    if ($showOptions) {
+      echo "    ";
+      echo implode(", ", array_keys($this->_options));
+      if ($this->haveArgs) {
+        echo " ";
+        echo $this->argsdesc;
+      }
+      echo "\n";
+    }
+
+    $showHelp = $what["help"] ?? true;
+    if ($this->help && $showHelp) {
+      echo str::indent($this->help, "        ");
+      echo "\n";
+    }
+  }
+
+  function action(&$dest, $value, ?string $arg, AbstractArgsParser $parser): void {
+    if ($this->ensureArray) {
+      varray::ensure($value);
+    } elseif (is_array($value)) {
+      $count = count($value);
+      if ($count == 0) $value = null;
+      elseif ($count == 1) $value = $value[0];
+    }
+
+    switch ($this->action) {
+    case "--set": $this->actionSet($dest, $value); break;
+    case "--inc": $this->actionInc($dest); break;
+    case "--dec": $this->actionDec($dest); break;
+    case "--add": $this->actionAdd($dest, $value); break;
+    case "--adds": $this->actionAdds($dest, $value); break;
+    case "--merge": $this->actionMerge($dest, $value); break;
+    case "--merges": $this->actionMerges($dest, $value); break;
+    case "--func": $this->func->bind($dest)->invoke([$value, $arg, $this]); break;
+    case "--set-args": $this->actionSetArgs($dest, $value); break;
+    case "--set-command": $this->actionSetCommand($dest, $value); break;
+    case "--show-help": $parser->actionPrintHelp($arg); break;
+    default: throw _exceptions::invalid_value($this->action, null, "action non supportée");
+    }
+  }
+
+  function actionSet(&$dest, $value): void {
+    if ($this->property !== null) {
+      oprop::set($dest, $this->property, $value);
+    } elseif ($this->key !== null) {
+      akey::set($dest, $this->key, $value);
+    } elseif ($this->name !== null) {
+      valx::set($dest, $this->name, $value);
+    }
+  }
+
+  function actionInc(&$dest): void {
+    if ($this->property !== null) {
+      if ($this->inverse) oprop::dec($dest, $this->property);
+      else oprop::inc($dest, $this->property);
+    } elseif ($this->key !== null) {
+      if ($this->inverse) akey::dec($dest, $this->key);
+      else akey::inc($dest, $this->key);
+    } elseif ($this->name !== null) {
+      if ($this->inverse) valx::dec($dest, $this->name);
+      else valx::inc($dest, $this->name);
+    }
+  }
+
+  function actionDec(&$dest): void {
+    if ($this->property !== null) {
+      if ($this->inverse) oprop::inc($dest, $this->property);
+      else oprop::dec($dest, $this->property);
+    } elseif ($this->key !== null) {
+      if ($this->inverse) akey::inc($dest, $this->key);
+      else akey::dec($dest, $this->key);
+    } elseif ($this->name !== null) {
+      if ($this->inverse) valx::inc($dest, $this->name);
+      else valx::dec($dest, $this->name);
+    }
+  }
+
+  function actionAdd(&$dest, $value): void {
+    if ($this->property !== null) {
+      oprop::append($dest, $this->property, $value);
+    } elseif ($this->key !== null) {
+      akey::append($dest, $this->key, $value);
+    } elseif ($this->name !== null) {
+      valx::append($dest, $this->name, $value);
+    }
+  }
+
+  function actionAdds(&$dest, $value): void {
+    if ($this->property !== null) {
+      foreach (cl::with($value) as $value) {
+        oprop::append($dest, $this->property, $value);
+      }
+    } elseif ($this->key !== null) {
+      foreach (cl::with($value) as $value) {
+        akey::append($dest, $this->key, $value);
+      }
+    } elseif ($this->name !== null) {
+      foreach (cl::with($value) as $value) {
+        valx::append($dest, $this->name, $value);
+      }
+    }
+  }
+
+  function actionMerge(&$dest, $value): void {
+    if ($this->property !== null) {
+      oprop::merge($dest, $this->property, $value);
+    } elseif ($this->key !== null) {
+      akey::merge($dest, $this->key, $value);
+    } elseif ($this->name !== null) {
+      valx::merge($dest, $this->name, $value);
+    }
+  }
+
+  function actionMerges(&$dest, $value): void {
+    if ($this->property !== null) {
+      foreach (cl::with($value) as $value) {
+        oprop::merge($dest, $this->property, $value);
+      }
+    } elseif ($this->key !== null) {
+      foreach (cl::with($value) as $value) {
+        akey::merge($dest, $this->key, $value);
+      }
+    } elseif ($this->name !== null) {
+      foreach (cl::with($value) as $value) {
+        valx::merge($dest, $this->name, $value);
+      }
+    }
+  }
+
+  function actionSetArgs(&$dest, $value): void {
+    if ($this->property !== null) {
+      oprop::set($dest, $this->property, $value);
+    } elseif ($this->key !== null) {
+      akey::set($dest, $this->key, $value);
+    } elseif ($this->name !== null) {
+      valx::set($dest, $this->name, $value);
+    }
+  }
+
+  function actionSetCommand(&$dest, $value): void {
+    if ($this->property !== null) {
+      oprop::set($dest, $this->property, $value);
+    } elseif ($this->key !== null) {
+      akey::set($dest, $this->key, $value);
+    } elseif ($this->name !== null) {
+      valx::set($dest, $this->name, $value);
+    }
+  }
+
+  function __toString(): string {
+    $options = implode(",", $this->getOptions());
+    $args = $this->haveArgs? " ({$this->minArgs}-{$this->maxArgs})": false;
+    return "$options$args";
+  }
+  private function debugTrace(string $message): void {
+    $options = implode(",", cl::split_assoc($this->origDef)[0] ?? []);
+    echo "$options $message\n";
+  }
+}
diff --git a/php/src/app/args/Aogroup.php b/php/src/app/args/Aogroup.php
new file mode 100644
index 0000000..d293a75
--- /dev/null
+++ b/php/src/app/args/Aogroup.php
@@ -0,0 +1,36 @@
+all() as $aodef) {
+      $firstAodef ??= $aodef;
+      $aodef->printHelp(["help" => false]);
+    }
+    if ($firstAodef !== null) {
+      $firstAodef->printHelp(["options" => false]);
+    }
+  }
+}
diff --git a/php/src/app/args/Aolist.php b/php/src/app/args/Aolist.php
new file mode 100644
index 0000000..0862615
--- /dev/null
+++ b/php/src/app/args/Aolist.php
@@ -0,0 +1,268 @@
+origDefs = $defs;
+    $this->initDefs($defs, $setup);
+  }
+
+  protected array $origDefs;
+
+  protected ?array $aomain;
+  protected ?array $aosections;
+  protected ?array $aospecials;
+
+  public ?Aodef $remainsArgdef = null;
+
+  function initDefs(array $defs, bool $setup=true): void {
+    $this->mergeParse($defs, $aobjects);
+    $this->aomain = $aobjects["main"] ?? null;
+    $this->aosections = $aobjects["sections"] ?? null;
+    $this->aospecials = $aobjects["specials"] ?? null;
+    if ($setup) $this->setup();
+  }
+
+  protected function mergeParse(array $defs, ?array &$aobjects, bool $parse=true): void {
+    $aobjects ??= [];
+
+    $merges = $defs["merges"] ?? null;
+    $merge = $defs["merge"] ?? null;
+    if ($merge !== null) $merges[] = $merge;
+    if ($merges !== null) {
+      foreach ($merges as $merge) {
+        $this->mergeParse($merge, $aobjects, false);
+        $this->parse($merge, $aobjects);
+      }
+    }
+
+    if ($parse) $this->parse($defs, $aobjects);
+
+    $merge = $defs["merge_after"] ?? null;
+    if ($merge !== null) {
+      $this->mergeParse($merge, $aobjects, false);
+      $this->parse($merge, $aobjects);
+    }
+  }
+
+  protected function parse(array $defs, array &$aobjects): void {
+    [$defs, $params] = cl::split_assoc($defs);
+    if ($defs !== null) {
+      $aomain =& $aobjects["main"];
+      foreach ($defs as $def) {
+        $first = $def[0] ?? null;
+        if ($first === "group") {
+          $aobject = new Aogroup($def);
+        } else {
+          $aobject = new Aodef($def);
+        }
+        $aomain[] = $aobject;
+      }
+    }
+    $sections = $params["sections"] ?? null;
+    if ($sections !== null) {
+      $aosections =& $aobjects["sections"];
+      $index = 0;
+      foreach ($sections as $key => $section) {
+        if ($key === $index) {
+          $index++;
+          $aosections[] = new Aosection($section);
+        } else {
+          /** @var Aosection $aosection */
+          $aosection = $aosections[$key] ?? null;
+          if ($aosection === null) {
+            $aosections[$key] = new Aosection($section);
+          } else {
+            #XXX il faut implémenter la fusion en cas de section existante
+            # pour le moment, la liste existante est écrasée
+            $aosection->initDefs($section);
+          }
+        }
+      }
+    }
+    $this->parseParams($params);
+  }
+
+  protected function parseParams(?array $params): void {
+  }
+
+  function all(?array $what=null): iterable {
+    $returnsAodef = $what["aodef"] ?? true;
+    $returnsAolist = $what["aolist"] ?? false;
+    $returnExtends = $what["extends"] ?? false;
+    $withSpecials = $what["aospecials"] ?? true;
+    # lister les sections avant, pour que les options de la section principale
+    # soient prioritaires
+    $aosections = $this->aosections;
+    if ($aosections !== null) {
+      /** @var Aosection $aobject */
+      foreach ($aosections as $aosection) {
+        if ($returnsAolist) {
+          yield $aosection;
+        } elseif ($returnsAodef) {
+          yield from $aosection->all($what);
+        }
+      }
+    }
+
+    $aomain = $this->aomain;
+    if ($aomain !== null) {
+      /** @var Aodef $aobject */
+      foreach ($aomain as $aobject) {
+        if ($aobject instanceof Aodef) {
+          if ($returnsAodef) {
+            if ($returnExtends) {
+              if ($aobject->isExtends()) yield $aobject;
+            } else {
+              if (!$aobject->isExtends()) yield $aobject;
+            }
+          }
+        } elseif ($aobject instanceof Aolist) {
+          if ($returnsAolist) {
+            yield $aobject;
+          } elseif ($returnsAodef) {
+            yield from $aobject->all($what);
+          }
+        }
+      }
+    }
+
+    $aospecials = $this->aospecials;
+    if ($withSpecials && $aospecials !== null) {
+      /** @var Aodef $aobject */
+      foreach ($aospecials as $aobject) {
+        yield $aobject;
+      }
+    }
+  }
+
+  protected function filter(callable $callback): void {
+    $aomain = $this->aomain;
+    if ($aomain !== null) {
+      $filtered = [];
+      /** @var Aodef $aobject */
+      foreach ($aomain as $aobject) {
+        if ($aobject instanceof Aolist) {
+          $aobject->filter($callback);
+        }
+        if (call_user_func($callback, $aobject)) {
+          $filtered[] = $aobject;
+        }
+      }
+      $this->aomain = $filtered;
+    }
+    $aosections = $this->aosections;
+    if ($aosections !== null) {
+      $filtered = [];
+      /** @var Aosection $aosection */
+      foreach ($aosections as $aosection) {
+        $aosection->filter($callback);
+        if (call_user_func($callback, $aosection)) {
+          $filtered[] = $aosection;
+        }
+      }
+      $this->aosections = $filtered;
+    }
+  }
+
+  protected function setup(): void {
+    # calculer les options
+    foreach ($this->all() as $aodef) {
+      $aodef->setup1();
+    }
+    /** @var Aodef $aodef */
+    foreach ($this->all(["extends" => true]) as $aodef) {
+      $aodef->setup1(true, $this);
+    }
+    # ne garder que les objets non vides
+    $this->filter(function($aobject): bool {
+      if ($aobject instanceof Aodef) {
+        return !$aobject->isEmpty();
+      } elseif ($aobject instanceof Aolist) {
+        return !$aobject->isEmpty();
+      } else {
+        return false;
+      }
+    });
+    # puis calculer nombre d'arguments et actions
+    foreach ($this->all() as $aodef) {
+      $aodef->setup2();
+    }
+  }
+
+  function isEmpty(): bool {
+    foreach ($this->all() as $aobject) {
+      return false;
+    }
+    return true;
+  }
+
+  function get(string $option): ?Aodef {
+    return null;
+  }
+
+  function actionPrintHelp(string $arg): void {
+    $this->printHelp([
+      "show_all" => $arg === "--help++",
+    ]);
+  }
+
+  function printHelp(?array $what=null): void {
+    $show = $what["show_all"] ?? false;
+    if (!$show) $show = null;
+
+    $aosections = $this->aosections;
+    if ($aosections !== null) {
+      /** @var Aosection $aosection */
+      foreach ($aosections as $aosection) {
+        $aosection->printHelp(cl::merge($what, [
+          "show" => $show,
+          "prefix" => "\n",
+        ]));
+      }
+    }
+
+    $aomain = $this->aomain;
+    if ($aomain !== null) {
+      echo "\nOPTIONS\n";
+      foreach ($aomain as $aobject) {
+        $aobject->printHelp(cl::merge($what, [
+          "show" => $show,
+        ]));
+      }
+    }
+  }
+
+  function __toString(): string {
+    $items = [];
+    $what = [
+      "aodef" => true,
+      "aolist" => true,
+    ];
+    foreach ($this->all($what) as $aobject) {
+      if ($aobject instanceof Aodef) {
+        $items[] = strval($aobject);
+      } elseif ($aobject instanceof Aogroup) {
+        $items[] = implode("\n", [
+          "group",
+          str::indent(strval($aobject)),
+        ]);
+      } elseif ($aobject instanceof Aosection) {
+        $items[] = implode("\n", [
+          "section",
+          str::indent(strval($aobject)),
+        ]);
+      } else {
+        $items[] = false;
+      }
+    }
+    return implode("\n", $items);
+  }
+}
diff --git a/php/src/app/args/Aosection.php b/php/src/app/args/Aosection.php
new file mode 100644
index 0000000..ca85cf6
--- /dev/null
+++ b/php/src/app/args/Aosection.php
@@ -0,0 +1,45 @@
+show = vbool::with($params["show"] ?? true);
+    $this->prefix ??= $params["prefix"] ?? null;
+    $this->title ??= $params["title"] ?? null;
+    $this->description ??= $params["description"] ?? null;
+    $this->suffix ??= $params["suffix"] ?? null;
+  }
+
+  function printHelp(?array $what=null): void {
+    $showSection = $what["show"] ?? $this->show;
+    if (!$showSection) return;
+
+    $prefix = $what["prefix"] ?? null;
+    if ($prefix !== null) echo $prefix;
+
+    if ($this->prefix) echo "{$this->prefix}\n";
+    if ($this->title) echo "{$this->title}\n";
+    if ($this->description) echo "\n{$this->description}\n";
+    /** @var Aodef|Aolist $aobject */
+    foreach ($this->all(["aolist" => true]) as $aobject) {
+      $aobject->printHelp();
+    }
+    if ($this->suffix) echo "{$this->suffix}\n";
+  }
+}
diff --git a/php/src/app/args/ArgsException.php b/php/src/app/args/ArgsException.php
new file mode 100644
index 0000000..fe814e8
--- /dev/null
+++ b/php/src/app/args/ArgsException.php
@@ -0,0 +1,7 @@
+prefix ??= $params["prefix"] ?? null;
+    $this->name ??= $params["name"] ?? null;
+    $this->purpose ??= $params["purpose"] ?? null;
+    $this->usage ??= $params["usage"] ?? null;
+    $this->description ??= $params["description"] ?? null;
+    $this->suffix ??= $params["suffix"] ?? null;
+
+    $this->commandname ??= $params["commandname"] ?? null;
+    $this->commandproperty ??= $params["commandproperty"] ?? null;
+    $this->commandkey ??= $params["commandkey"] ?? null;
+
+    $this->argsname ??= $params["argsname"] ?? null;
+    $this->argsproperty ??= $params["argsproperty"] ?? null;
+    $this->argskey ??= $params["argskey"] ?? null;
+
+    $this->autohelp ??= vbool::withn($params["autohelp"] ?? null);
+    $this->autoremains ??= vbool::withn($params["autoremains"] ?? null);
+  }
+
+  /** @return string[] */
+  function getOptions(): array {
+    return array_keys($this->index);
+  }
+
+  protected function indexAodefs(): void {
+    $this->index = [];
+    foreach ($this->all() as $aodef) {
+      $options = $aodef->getOptions();
+      foreach ($options as $option) {
+        /** @var Aodef $prevAodef */
+        $prevAodef = $this->index[$option] ?? null;
+        if ($prevAodef !== null) $prevAodef->removeOption($option);
+        $this->index[$option] = $aodef;
+      }
+    }
+  }
+
+  protected function setup(): void {
+    # calculer les options pour les objets déjà fusionnés
+    /** @var Aodef $aodef */
+    foreach ($this->all() as $aodef) {
+      $aodef->setup1();
+    }
+
+    # puis traiter les extensions d'objets et calculer les options pour ces
+    # objets sur la base de l'index que l'on crée une première fois
+    $this->indexAodefs();
+    /** @var Aodef $aodef */
+    foreach ($this->all(["extends" => true]) as $aodef) {
+      $aodef->setup1(true, $this);
+    }
+
+    # ne garder que les objets non vides
+    $this->filter(function($aobject) {
+      if ($aobject instanceof Aodef) {
+        return !$aobject->isEmpty();
+      } elseif ($aobject instanceof Aolist) {
+        return !$aobject->isEmpty();
+      } else {
+        return false;
+      }
+    });
+
+    # rajouter remains et help si nécessaire
+    $this->aospecials = [];
+    $helpArgdef = null;
+    $remainsArgdef = null;
+    /** @var Aodef $aodef */
+    foreach ($this->all() as $aodef) {
+      if ($aodef->isHelp) $helpArgdef = $aodef;
+      if ($aodef->isRemains) $remainsArgdef = $aodef;
+    }
+
+    $this->autohelp ??= true;
+    if ($helpArgdef === null && $this->autohelp) {
+      $helpArgdef = new Aodef([
+        "--help", "--help++",
+        "action" => "--show-help",
+        "help" => "Afficher l'aide",
+      ]);
+      $helpArgdef->setup1();
+      $this->aospecials[] = $helpArgdef;
+    }
+
+    $this->autoremains ??= true;
+    if ($remainsArgdef === null && $this->autoremains) {
+      $remainsArgdef = new Aodef([
+        "args" => [null],
+        "action" => "--set-args",
+        "name" => $this->argsname ?? "args",
+        "property" => $this->argsproperty,
+        "key" => $this->argskey,
+      ]);
+      $remainsArgdef->setup1();
+      $this->aospecials[] = $remainsArgdef;
+    }
+    $this->remainsArgdef = $remainsArgdef;
+
+    # puis calculer nombre d'arguments et actions
+    $this->indexAodefs();
+    /** @var Aodef $aodef */
+    foreach ($this->all() as $aodef) {
+      $aodef->setup2();
+    }
+  }
+
+  function get(string $option): ?Aodef {
+    return $this->index[$option] ?? null;
+  }
+
+  function printHelp(?array $what = null): void {
+    $showList = $what["show"] ?? true;
+    if (!$showList) return;
+
+    $prefix = $what["prefix"] ?? null;
+    if ($prefix !== null) echo $prefix;
+
+    if ($this->prefix) echo "{$this->prefix}\n";
+    if ($this->purpose) {
+      echo "{$this->name}: {$this->purpose}\n";
+    } elseif (!$this->prefix) {
+      # s'il y a un préfixe sans purpose, il remplace purpose
+      echo "{$this->name}\n";
+    }
+    if ($this->usage) {
+      echo "\nUSAGE\n";
+      foreach (cl::with($this->usage) as $usage) {
+        echo "    {$this->name} $usage\n";
+      }
+    }
+    if ($this->description) echo "\n{$this->description}\n";
+    parent::printHelp($what);
+    if ($this->suffix) echo "{$this->suffix}\n";
+  }
+
+  function __toString(): string {
+    return implode("\n", [
+      "objects:",
+      str::indent(parent::__toString()),
+      "index:",
+      str::indent(implode("\n", array_keys($this->index))),
+    ]);
+  }
+}
diff --git a/php/src/app/args/SimpleArgsParser.php b/php/src/app/args/SimpleArgsParser.php
new file mode 100644
index 0000000..cdb181a
--- /dev/null
+++ b/php/src/app/args/SimpleArgsParser.php
@@ -0,0 +1,247 @@
+aolist = new SimpleAolist($defs);
+  }
+
+  protected SimpleAolist $aolist;
+
+  protected function getArgdef(string $option): ?Aodef {
+    return $this->aolist->get($option);
+  }
+
+  protected function getOptions(): array {
+    return $this->aolist->getOptions();
+  }
+
+  function normalize(array $args): array {
+    $i = 0;
+    $max = count($args);
+    $options = [];
+    $remains = [];
+    $parseOpts = true;
+    while ($i < $max) {
+      $arg = $args[$i++];
+      if (!$parseOpts) {
+        # le reste n'est que des arguments
+        $remains[] = $arg;
+        continue;
+      }
+      if ($arg === "--") {
+        # fin des options
+        $parseOpts = false;
+        continue;
+      }
+
+      if (substr($arg, 0, 2) === "--") {
+        #######################################################################
+        # option longue
+        $pos = strpos($arg, "=");
+        if ($pos !== false) {
+          # option avec valeur
+          $option = substr($arg, 0, $pos);
+          $value = substr($arg, $pos + 1);
+        } else {
+          # option sans valeur
+          $option = $arg;
+          $value = null;
+        }
+        $argdef = $this->getArgdef($option);
+        if ($argdef === null) {
+          # chercher une correspondance
+          $len = strlen($option);
+          $candidates = [];
+          foreach ($this->getOptions() as $candidate) {
+            if (substr($candidate, 0, $len) === $option) {
+              $candidates[] = $candidate;
+            }
+          }
+          switch (count($candidates)) {
+          case 0: throw $this->invalidArg($option);
+          case 1: $option = $candidates[0]; break;
+          default: throw $this->ambiguousArg($option, $candidates);
+          }
+          $argdef = $this->getArgdef($option);
+        }
+
+        if ($argdef->haveArgs) {
+          $minArgs = $argdef->minArgs;
+          $maxArgs = $argdef->maxArgs;
+          $values = [];
+          if ($value !== null) {
+            $values[] = $value;
+            $offset = 1;
+          } elseif ($minArgs == 0) {
+            # cas particulier: la première valeur doit être collée à l'option
+            # si $maxArgs == 1
+            $offset = $maxArgs == 1 ? 1 : 0;
+          } else {
+            $offset = 0;
+          }
+          $this->checkEnoughArgs($option,
+            self::consume_args($args, $i, $values, $offset, $minArgs, $maxArgs, true));
+
+          if ($minArgs == 0 && $maxArgs == 1) {
+            # cas particulier: la première valeur doit être collée à l'option
+            if (count($values) > 0) {
+              $options[] = "$option=$values[0]";
+              $values = array_slice($values, 1);
+            } else {
+              $options[] = $option;
+            }
+          } else {
+            $options[] = $option;
+          }
+          $options = array_merge($options, $values);
+        } elseif ($value !== null) {
+          throw $this->tooManyArgs(1, 0, $option);
+        } else {
+          $options[] = $option;
+        }
+
+      } elseif (substr($arg, 0, 1) === "-") {
+        #######################################################################
+        # option courte
+        $pos = 1;
+        $len = strlen($arg);
+        while ($pos < $len) {
+          $option = "-".substr($arg, $pos, 1);
+          $argdef = $this->getArgdef($option);
+          if ($argdef === null) throw $this->invalidArg($option);
+          if ($argdef->haveArgs) {
+            $minArgs = $argdef->minArgs;
+            $maxArgs = $argdef->maxArgs;
+            $values = [];
+            if ($len > $pos + 1) {
+              $values[] = substr($arg, $pos + 1);
+              $offset = 1;
+              $pos = $len;
+            } elseif ($minArgs == 0) {
+              # cas particulier: la première valeur doit être collée à l'option
+              # si $maxArgs == 1
+              $offset = $maxArgs == 1 ? 1 : 0;
+            } else {
+              $offset = 0;
+            }
+            $this->checkEnoughArgs($option,
+              self::consume_args($args, $i, $values, $offset, $minArgs, $maxArgs, true));
+
+            if ($minArgs == 0 && $maxArgs == 1) {
+              # cas particulier: la première valeur doit être collée à l'option
+              if (count($values) > 0) {
+                $options[] = "$option$values[0]";
+                $values = array_slice($values, 1);
+              } else {
+                $options[] = $option;
+              }
+            } else {
+              $options[] = $option;
+            }
+            $options = array_merge($options, $values);
+          } else {
+            $options[] = $option;
+          }
+          $pos++;
+        }
+      } else {
+        #XXX implémenter les commandes
+
+        #######################################################################
+        # argument
+        $remains[] = $arg;
+      }
+    }
+    return array_merge($options, ["--"], $remains);
+  }
+
+  function process(array $args) {
+    $i = 0;
+    $max = count($args);
+    # d'abord traiter les options
+    while ($i < $max) {
+      $arg = $args[$i++];
+      if ($arg === "--") {
+        # fin des options
+        break;
+      }
+
+      if (preg_match('/^(--[^=]+)(?:=(.*))?/', $arg, $ms)) {
+        # option longue
+      } elseif (preg_match('/^(-.)(.+)?/', $arg, $ms)) {
+        # option courte
+      } else {
+        # commande
+        throw StateException::unexpected_state("commands are not supported");
+      }
+      $option = $ms[1];
+      $ovalue = $ms[2] ?? null;
+      $argdef = $this->getArgdef($option);
+      if ($argdef === null) throw StateException::unexpected_state();
+      $defvalue = $argdef->value;
+      if ($argdef->haveArgs) {
+        $minArgs = $argdef->minArgs;
+        $maxArgs = $argdef->maxArgs;
+        if ($minArgs == 0 && $maxArgs == 1) {
+          # argument facultatif
+          if ($ovalue !== null) $value = [$ovalue];
+          else $value = cl::with($defvalue);
+          $offset = 1;
+        } else {
+          $value = [];
+          $offset = 0;
+        }
+        self::consume_args($args, $i, $value, $offset, $minArgs, $maxArgs, false);
+      } else {
+        $value = $defvalue;
+      }
+
+      $this->action($value, $arg, $argdef);
+    }
+
+    # construire la liste des arguments qui restent
+    $args = array_slice($args, $i);
+    $i = 0;
+    $max = count($args);
+    $argdef = $this->aolist->remainsArgdef;
+    if ($argdef !== null && $argdef->haveArgs) {
+      $minArgs = $argdef->minArgs;
+      $maxArgs = $argdef->maxArgs;
+      if ($maxArgs == PHP_INT_MAX) {
+        # cas particulier: si le nombre d'arguments restants est non borné,
+        # les prendre tous sans distinction ni traitement de '--'
+        $value = $args;
+        # mais tester tout de même s'il y a le minimum requis d'arguments
+        $this->checkEnoughArgs(null,  $minArgs - $max);
+      } else {
+        $value = [];
+        $this->checkEnoughArgs(null,
+          self::consume_args($args, $i, $value, 0, $minArgs, $maxArgs, false));
+        if ($i <= $max - 1) throw $this->tooManyArgs($max, $i);
+      }
+      $this->action($value, null, $argdef);
+    } elseif ($i <= $max - 1) {
+      throw $this->tooManyArgs($max, $i);
+    }
+  }
+
+  function action($value, ?string $arg, Aodef $argdef) {
+    $argdef->action($this->dest, $value, $arg, $this);
+  }
+
+  public function actionPrintHelp(string $arg): void {
+    $this->aolist->actionPrintHelp($arg);
+    throw new ExitError(0);
+  }
+
+  function showDebugInfos() {
+    echo $this->aolist."\n"; #XXX
+  }
+}
diff --git a/php/src/app/args/TODO.md b/php/src/app/args/TODO.md
new file mode 100644
index 0000000..da6b1ad
--- /dev/null
+++ b/php/src/app/args/TODO.md
@@ -0,0 +1,20 @@
+# nulib\app\args
+
+* [ ] transformer un schéma en définition d'arguments, un tableau en liste d'arguments, et vice-versa
+* [ ] faire une implémentation ArgsParser qui supporte les commandes, et les options dynamiques
+  * commandes:
+    `program [options] command [options]`
+  * multi-commandes:
+    `program [options] command [options] // command [options] // ...`
+  * dynamique: la liste des options et des commandes supportées est calculée dynamiquement
+
+## support des commandes
+
+faire une interface Runnable qui représente un composant pouvant être exécuté.
+Application implémente Runnable, mais l'analyse des arguments peut retourner une
+autre instance de runnable pour faciliter l'implémentation de différents
+sous-outils
+
+## BUGS
+
+-*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary
\ No newline at end of file
diff --git a/php/src/app/args/_exceptions.php b/php/src/app/args/_exceptions.php
new file mode 100644
index 0000000..aa8104f
--- /dev/null
+++ b/php/src/app/args/_exceptions.php
@@ -0,0 +1,10 @@
+getDesc();
+      echo implode("\n", $desc["message"])."\n";
+      $ec = $desc["exitcode"] ?? 0;
+      break;
+    case "dump":
+    case "d":
+      yaml::dump($runfile->read());
+      break;
+    case "reset":
+    case "z":
+      if (!$runfile->isRunning()) $runfile->reset();
+      else $ec = self::_error("cannot reset while running");
+      break;
+    case "release":
+    case "rl":
+      $runfile->release();
+      break;
+    case "start":
+    case "s":
+      array_splice($argv, 1, 1); $argc--;
+      return;
+    case "kill":
+    case "k":
+      if ($runfile->isRunning()) $runfile->wfKill();
+      else $ec = self::_error("not running");
+      break;
+    default:
+      $ec = self::_error("$argv[1]: unexpected command", app::EC_BAD_COMMAND);
+    }
+    exit($ec);
+  }
+
+  static function run(?Application $app=null): void {
+    $unlock = false;
+    $stop = false;
+    $shutdown = function () use (&$unlock, &$stop) {
+      if ($unlock) {
+        app::get()->getRunfile()->release();
+        $unlock = false;
+      }
+      if ($stop) {
+        app::get()->getRunfile()->wfStop();
+        $stop = false;
+      }
+    };
+    register_shutdown_function($shutdown);
+    app::install_signal_handler(static::INSTALL_SIGNAL_HANDLER);
+    try {
+      static::_initialize_app();
+      $useRunfile = static::USE_RUNFILE;
+      $useRunlock = static::USE_RUNLOCK;
+      if ($useRunfile) {
+        $runfile = app::get()->getRunfile();
+
+        global $argc, $argv;
+        self::_manage_runfile($argc, $argv, $runfile);
+        if ($useRunlock && $runfile->warnIfLocked()) exit(app::EC_LOCKED);
+
+        $runfile->wfStart();
+        $stop = true;
+        if ($useRunlock) {
+          $runfile->lock();
+          $unlock = true;
+        }
+      }
+      if ($app === null) $app = new static();
+      static::_configure_app($app);
+      static::_start_app($app);
+    } catch (ExitError $e) {
+      if ($e->haveUserMessage()) msg::error($e->getUserMessage());
+      exit($e->getCode());
+    } catch (Exception $e) {
+      msg::error($e);
+      exit(app::EC_UNEXPECTED);
+    }
+  }
+
+  protected static function _initialize_app(): void {
+    app::init(static::class);
+    app::set_fact(app::FACT_CLI_APP);
+    $con = new ConsoleMessenger([
+      "min_level" => msg::DEBUG,
+    ]);
+    say::set_messenger($con);
+    msg::set_messenger($con);
+  }
+
+  protected static function _configure_app(Application $app): void {
+    config::configure(config::CONFIGURE_INITIAL_ONLY);
+
+    $con = con::set_messenger(new ConsoleMessenger([
+      "min_level" => con::NORMAL,
+    ]));
+    say::set_messenger($con, true);
+    msg::set_messenger($con, true);
+    if (static::USE_LOGFILE) {
+      $log = log::set_messenger(new LogMessenger([
+        "output" => app::get()->getLogfile(),
+        "min_level" => msg::MINOR,
+      ]));
+    } else {
+      $log = log::set_messenger(new ProxyMessenger());
+    }
+    msg::set_messenger($log);
+
+    $app->parseArgs();
+    config::configure();
+  }
+
+  protected static function _start_app(Application $app): void {
+    $retcode = $app->main();
+    if (is_int($retcode)) exit($retcode);
+    elseif (is_bool($retcode)) exit($retcode? 0: 1);
+    elseif ($retcode !== null) exit(strval($retcode));
+  }
+
+  /**
+   * sortir de l'application avec un code d'erreur, qui est 0 par défaut (i.e
+   * pas d'erreur)
+   *
+   * équivalent à lancer l'exception {@link ExitError}
+   */
+  protected static final function exit(int $exitcode=0, $message=null) {
+    throw new ExitError($exitcode, $message);
+  }
+
+  /**
+   * sortir de l'application avec un code d'erreur, qui vaut 1 par défaut (i.e
+   * une erreur s'est produite)
+   *
+   * équivalent à lancer l'exception {@link ExitError}
+   */
+  protected static final function die($message=null, int $exitcode=1) {
+    throw new ExitError($exitcode, $message);
+  }
+
+  const PROFILE_SECTION = [
+    "title" => "PROFIL D'EXECUTION",
+    "show" => false,
+    ["-c", "--config", "--app-config",
+      "args" => "file", "argsdesc" => "CONFIG.yml",
+      "action" => [config::class, "load_config"],
+      "help" => "spécifier un fichier de configuration",
+    ],
+    ["group",
+      ["-g", "--profile", "--app-profile",
+        "args" => 1, "argsdesc" => "PROFILE",
+        "action" => [app::class, "set_profile"],
+        "help" => "spécifier le profil d'exécution",
+      ],
+      ["-P", "--prod", "action" => [app::class, "set_profile", ref_profiles::PROD]],
+      ["-T", "--test", "action" => [app::class, "set_profile", ref_profiles::TEST]],
+      ["--devel", "action" => [app::class, "set_profile", ref_profiles::DEVEL]],
+    ],
+  ];
+
+  const VERBOSITY_SECTION = [
+    "title" => "NIVEAU D'INFORMATION",
+    "show" => false,
+    ["group",
+      ["-V", "--verbosity",
+        "args" => "verbosity", "argsdesc" => "silent|quiet|verbose|debug",
+        "action" => [con::class, "set_verbosity"],
+        "help" => "Spécifier le niveau d'informations affiché sur la console",
+      ],
+      ["-q", "--quiet", "action" => [con::class, "set_verbosity", "quiet"]],
+      ["-v", "--verbose", "action" => [con::class, "set_verbosity", "verbose"]],
+      ["-D", "--debug", "action" => [con::class, "set_verbosity", "debug"]],
+    ],
+    ["group",
+      ["--color",
+        "action" => [con::class, "set_color", true],
+        "help" => "Afficher (resp. ne pas afficher) la sortie en couleur par défaut",
+      ],
+      ["--no-color", "action" => [con::class, "set_color", false]],
+    ],
+    ["group",
+      ["-L", "--logfile",
+        "args" => "output",
+        "action" => [log::class, "set_output"],
+        "help" => "Logger les messages de l'application dans le fichier spécifié",
+      ],
+      ["--lV", "--lverbosity",
+        "args" => "verbosity", "argsdesc" => "silent|quiet|verbose|debug",
+        "action" => [log::class, "set_verbosity"],
+        "help" => "Spécifier le niveau des informations ajoutées dans les logs",
+      ],
+      ["--lq", "--lquiet", "action" => [log::class, "set_verbosity", "quiet"]],
+      ["--lv", "--lverbose", "action" => [log::class, "set_verbosity", "verbose"]],
+      ["--lD", "--ldebug", "action" => [log::class, "set_verbosity", "debug"]],
+    ],
+  ];
+
+  const ARGS = [
+    "sections" => [
+      self::PROFILE_SECTION,
+      self::VERBOSITY_SECTION,
+    ],
+  ];
+
+  protected function getArgsParser(): AbstractArgsParser {
+    return new SimpleArgsParser(static::ARGS);
+  }
+
+  /** @throws ArgsException */
+  function parseArgs(array $args=null): void {
+    $this->getArgsParser()->parse($this, $args);
+  }
+
+  const PROFILE_COLORS = [
+    ref_profiles::PROD => "@r",
+    ref_profiles::TEST => "@g",
+    ref_profiles::DEVEL => "@w",
+  ];
+  const DEFAULT_PROFILE_COLOR = "y";
+
+  /** retourner le profil courant en couleur */
+  static function get_profile(?string $profile=null): string {
+    if ($profile === null) $profile = app::get_profile();
+    foreach (static::PROFILE_COLORS as $text => $color) {
+      if (strpos($profile, $text) !== false) {
+        return $color? "$profile": $profile;
+      }
+    }
+    $color = static::DEFAULT_PROFILE_COLOR;
+    return $color? "$profile": $profile;
+  }
+
+  protected ?array $args = null;
+
+  abstract function main();
+
+  static function runfile(): RunFile {
+    return app::with(static::class)->getRunfile();
+  }
+}
diff --git a/php/src/app/cli/include-launcher.php b/php/src/app/cli/include-launcher.php
index 0958cb7..d0d04b5 100644
--- a/php/src/app/cli/include-launcher.php
+++ b/php/src/app/cli/include-launcher.php
@@ -3,7 +3,7 @@
 # les constantes suivantes doivent être définies AVANT de chager ce script:
 # - NULIB_APP_app_params : paramètres du projet
 
-use nulib\app;
+use nulib\app\app;
 use nulib\os\path;
 
 if ($argc <= 1) die("invalid arguments");
diff --git a/php/src/app/config.php b/php/src/app/config.php
new file mode 100644
index 0000000..6fd270a
--- /dev/null
+++ b/php/src/app/config.php
@@ -0,0 +1,56 @@
+addConfigurator($configurators);
+  }
+
+  # certains types de configurations sont normalisés
+  /** ne configurer que le minimum pour que l'application puisse s'initialiser */
+  const CONFIGURE_INITIAL_ONLY = ["include" => "initial"];
+  /** ne configurer que les routes */
+  const CONFIGURE_ROUTES_ONLY = ["include" => "routes"];
+  /** configurer uniquement ce qui ne nécessite pas d'avoir une session */
+  const CONFIGURE_NO_SESSION = ["exclude" => "session"];
+
+  static function configure(?array $params=null): void {
+    self::$config->configure($params);
+  }
+
+  static final function add($config, string ...$profiles): void { self::$config->addConfig($config, $profiles); }
+  static final function load_config($file): void {
+    $ext = path::ext($file);
+    if ($ext === ".yml" || $ext === ".yaml") {
+      $config = new YamlConfig($file);
+    } elseif ($ext === ".json") {
+      $config = new JsonConfig($file);
+    } else {
+      throw exceptions::invalid_value($file, "config file");
+    }
+    self::add($config);
+  }
+
+  static final function get(string $pkey, $default=null, ?string $profile=null) { return self::$config->getValue($pkey, $default, $profile); }
+  static final function k(string $pkey, $default=null) { return self::$config->getValue("app.$pkey", $default); }
+  static final function db(string $pkey, $default=null) { return self::$config->getValue("dbs.$pkey", $default); }
+  static final function m(string $pkey, $default=null) { return self::$config->getValue("msgs.$pkey", $default); }
+  static final function l(string $pkey, $default=null) { return self::$config->getValue("mails.$pkey", $default); }
+}
+
+new class extends config {
+  function __construct() {
+    self::$config = new ConfigManager();
+  }
+};
\ No newline at end of file
diff --git a/php/src/app/config/ArrayConfig.php b/php/src/app/config/ArrayConfig.php
new file mode 100644
index 0000000..6a02c8e
--- /dev/null
+++ b/php/src/app/config/ArrayConfig.php
@@ -0,0 +1,50 @@
+APP(); break;
+      case "dbs": $default = $this->DBS(); break;
+      case "msgs": $default = $this->MSGS(); break;
+      case "mails": $default = $this->MAILS(); break;
+      default: $default = [];
+      }
+      $config[$key] ??= $default;
+    }
+    $this->config = $config;
+  }
+
+  protected array $config;
+
+  function has(string $pkey, string $profile): bool {
+    return cl::phas($this->config, $pkey);
+  }
+
+  function get(string $pkey, string $profile) {
+    return cl::pget($this->config, $pkey);
+  }
+
+  function set(string $pkey, $value, string $profile): void {
+    cl::pset($this->config, $pkey, $value);
+  }
+}
diff --git a/php/src/app/config/ConfigManager.php b/php/src/app/config/ConfigManager.php
new file mode 100644
index 0000000..d2b2ec2
--- /dev/null
+++ b/php/src/app/config/ConfigManager.php
@@ -0,0 +1,148 @@
+configurators, cl::with($configurators));
+  }
+
+  protected array $configured = [];
+
+  /**
+   * configurer les objets et les classes qui ne l'ont pas encore été. la liste
+   * des objets et des classes à configurer est fournie en appelant la méthode
+   * {@link addConfigurator()}
+   *
+   * par défaut, la configuration se fait en appelant toutes les méthodes
+   * publiques des objets et toutes les méthodes statiques des classes qui
+   * commencent par 'configure', e.g 'configureThis()' ou 'configure_db()',
+   * si elles n'ont pas déjà été appelées
+   *
+   * Il est possible de modifier la liste des méthodes appelées avec le tableau
+   * $params, qui doit être conforme au schema de {@link func::CALL_ALL_SCHEMA}
+   */
+  function configure(?array $params=null): void {
+    $params["prefix"] ??= "configure";
+    foreach ($this->configurators as $key => $configurator) {
+      $configured =& $this->configured[$key];
+      /** @var func[] $methods */
+      $methods = func::get_all($configurator, $params);
+      foreach ($methods as $method) {
+        $name = $method->getName() ?? "(no name)";
+        $done = $configured[$name] ?? false;
+        if (!$done) {
+          $method->invoke();
+          $configured[$name] = true;
+        }
+      }
+    }
+  }
+
+  #############################################################################
+
+  protected $cache = [];
+
+  protected function resetCache(): void {
+    $this->cache = [];
+  }
+
+  protected function cacheHas(string $pkey, string $profile) {
+    return array_key_exists("$profile.$pkey", $this->cache);
+  }
+
+  protected function cacheGet(string $pkey, string $profile) {
+    return cl::get($this->cache, "$profile.$pkey");
+  }
+
+  protected function cacheSet(string $pkey, $value, string $profile): void {
+    $this->cache["$profile.$pkey"] = $value;
+  }
+
+  protected array $profileConfigs = [];
+
+  /**
+   * Ajouter une configuration valide pour le(s) profil(s) spécifié(s)
+   *
+   * $config est un objet ou une classe qui définit une ou plusieurs des
+   * constantes APP, DBS, MSGS, MAILS
+   *
+   * si !$inProfiles, la configuration est valide dans tous les profils
+   */
+  function addConfig($config, ?array $inProfiles=null): void {
+    if (is_string($config)) {
+      $c = new ReflectionClass($config);
+      if ($c->implementsInterface(IConfig::class)) {
+        $config = $c->newInstance();
+      } else {
+        $config = [];
+        foreach (IConfig::CONFIG_KEYS as $key) {
+          $config[$key] = cl::with($c->getConstant(strtoupper($key)));
+        }
+        $config = new ArrayConfig($config);
+      }
+    } elseif (is_array($config)) {
+      $config = new ArrayConfig($config);
+    } elseif (!($config instanceof IConfig)) {
+      throw exceptions::invalid_type($config, "config", ["array", IConfig::class]);
+    }
+
+    if (!$inProfiles) $inProfiles = [IConfig::PROFILE_ALL];
+    foreach ($inProfiles as $profile) {
+      $this->profileConfigs[$profile][] = $config;
+    }
+
+    $this->resetCache();
+  }
+
+  function _getValue(string $pkey, $default, string $inProfile) {
+    $profiles = [$inProfile];
+    if ($inProfile !== IConfig::PROFILE_ALL) $profiles[] = IConfig::PROFILE_ALL;
+    foreach ($profiles as $profile) {
+      /** @var IConfig[] $configs */
+      $configs = $this->profileConfigs[$profile] ?? [];
+      foreach (array_reverse($configs) as $config) {
+        if ($config->has($pkey, $profile)) {
+          return $config->get($pkey, $profile);
+        }
+      }
+    }
+    return $default;
+  }
+
+  /**
+   * obtenir la valeur au chemin de clé $pkey dans le profil spécifié
+   *
+   * le $inProfile===null, prendre le profil par défaut.
+   */
+  function getValue(string $pkey, $default=null, ?string $inProfile=null) {
+    $inProfile ??= app::get_profile();
+
+    if ($this->cacheHas($pkey, $inProfile)) {
+      return $this->cacheGet($pkey, $inProfile);
+    }
+
+    $value = $this->_getValue($pkey, $default, $inProfile);
+    $this->cacheSet($pkey, $value, $inProfile);
+    return $value;
+  }
+
+  function setValue(string $pkey, $value, ?string $inProfile=null): void {
+    $inProfile ??= app::get_profile();
+    /** @var IConfig[] $configs */
+    $configs =& $this->profileConfigs[$inProfile];
+    if ($configs === null) $key = 0;
+    else $key = array_key_last($configs);
+    $configs[$key] ??= new ArrayConfig([]);
+    $configs[$key]->set($pkey, $value, $inProfile);
+  }
+}
diff --git a/php/src/app/config/EnvConfig.php b/php/src/app/config/EnvConfig.php
new file mode 100644
index 0000000..b07bf47
--- /dev/null
+++ b/php/src/app/config/EnvConfig.php
@@ -0,0 +1,112 @@
+ "mysql", "name" => "mysql:host=authdb;dbname=auth;charset=utf8",
+ *     "user" => "auth_int", "pass" => "auth" ]
+ * situé au chemin de clé dbs.auth dans le profil prod, on peut par exemple
+ * définir les variables suivantes:
+ *   CONFIG_prod_dbs__auth__type="mysql"
+ *   CONFIG_prod_dbs__auth__name="mysql:host=authdb;dbname=auth;charset=utf8"
+ *   CONFIG_prod_dbs__auth__user="auth_int"
+ *   CONFIG_prod_dbs__auth__pass="auth"
+ * ou alternativement:
+ *   JSON_CONFIG_prod_dbs__auth='{"type":"mysql","name":"mysql:host=authdb;dbname=auth;charset=utf8","user":"auth_int","pass":"auth"}'
+ *
+ * Les préfixes supportés sont, dans l'ordre de précédence:
+ * - JSON_FILE_CONFIG -- une valeur au format JSON inscrite dans un fichier
+ * - JSON_CONFIG -- une valeur au format JSON
+ * - FILE_CONFIG -- une valeur inscrite dans un fichier
+ * - CONFIG -- une valeur scalaire
+ */
+class EnvConfig implements IConfig{
+  protected ?array $profileConfigs = null;
+
+  /** analyser $name et retourner [$pkey, $profile] */
+  private static function parse_pkey_profile($name): array {
+    $i = strpos($name, "_");
+    if ($i === false) return [false, false];
+    $profile = substr($name, 0, $i);
+    if ($profile === "ALL") $profile = IConfig::PROFILE_ALL;
+    $name = substr($name, $i + 1);
+    $pkey = str_replace("__", ".", $name);
+    return [$pkey, $profile];
+  }
+
+  function loadEnvConfig(): void {
+    if ($this->profileConfigs !== null) return;
+    $json_files = [];
+    $jsons = [];
+    $files = [];
+    $vars = [];
+    foreach (getenv() as $name => $value) {
+      if (str::starts_with("JSON_FILE_CONFIG_", $name)) {
+        $json_files[str::without_prefix("JSON_FILE_CONFIG_", $name)] = $value;
+      } elseif (str::starts_with("JSON_CONFIG_", $name)) {
+        $jsons[str::without_prefix("JSON_CONFIG_", $name)] = $value;
+      } elseif (str::starts_with("FILE_CONFIG_", $name)) {
+        $files[str::without_prefix("FILE_CONFIG_", $name)] = $value;
+      } elseif (str::starts_with("CONFIG_", $name)) {
+        $vars[str::without_prefix("CONFIG_", $name)] = $value;
+      }
+    }
+    $profileConfigs = [];
+    foreach ($json_files as $name => $file) {
+      [$pkey, $profile] = self::parse_pkey_profile($name);
+      $value = json::load($file);
+      cl::pset($profileConfigs, "$profile.$pkey", $value);
+    }
+    foreach ($jsons as $name => $json) {
+      [$pkey, $profile] = self::parse_pkey_profile($name);
+      $value = json::decode($json);
+      cl::pset($profileConfigs, "$profile.$pkey", $value);
+    }
+    foreach ($files as $name => $file) {
+      [$pkey, $profile] = self::parse_pkey_profile($name);
+      $value = file::reader($file)->getContents();
+      cl::pset($profileConfigs, "$profile.$pkey", $value);
+    }
+    foreach ($vars as $name => $value) {
+      [$pkey, $profile] = self::parse_pkey_profile($name);
+      cl::pset($profileConfigs, "$profile.$pkey", $value);
+    }
+    $this->profileConfigs = $profileConfigs;
+  }
+
+  function has(string $pkey, string $profile): bool {
+    $this->loadEnvConfig();
+    $config = $this->profileConfigs[$profile] ?? null;
+    return cl::phas($config, $pkey);
+  }
+
+  function get(string $pkey, string $profile) {
+    $this->loadEnvConfig();
+    $config = $this->profileConfigs[$profile] ?? null;
+    return cl::pget($config, $pkey);
+  }
+
+  function set(string $pkey, $value, string $profile): void {
+    $this->loadEnvConfig();
+    $config =& $this->profileConfigs[$profile];
+    cl::pset($config, $pkey, $value);
+  }
+}
diff --git a/php/src/app/config/IConfig.php b/php/src/app/config/IConfig.php
new file mode 100644
index 0000000..dcba89f
--- /dev/null
+++ b/php/src/app/config/IConfig.php
@@ -0,0 +1,24 @@
+ profil effectif
+   *
+   * ce mapping est utilisé quand il faut calculer le profil courant s'il n'a
+   * pas été spécifié par l'utilisateur. il permet de faire correspondre le
+   * profil courant de l'application avec le profil effectif à sélectionner
+   */
+  const PROFILE_MAP = null;
+
+  function __construct(?array $params=null) {
+    $this->isAppProfile = $params["app"] ?? false;
+    $this->profiles = static::PROFILES;
+    $this->productionModes = static::PRODUCTION_MODES;
+    $this->profileMap = static::PROFILE_MAP;
+    $name = $params["name"] ?? static::NAME;
+    if ($name === null) {
+      $this->configKey = null;
+      $this->envKeys = ["APP_PROFILE"];
+    } else {
+      $configKey = "${name}_profile";
+      $envKey = strtoupper($configKey);
+      if ($this->isAppProfile) {
+        $this->configKey = null;
+        $this->envKeys = [$envKey, "APP_PROFILE"];
+      } else {
+        $this->configKey = $configKey;
+        $this->envKeys = [$envKey];
+      }
+    }
+    $this->defaultProfile = $params["default_profile"] ?? null;
+    $profile = $params["profile"] ?? null;
+    $productionMode = $params["production_mode"] ?? null;
+    $productionMode ??= $this->productionModes[$profile] ?? false;
+    $this->profile = $profile;
+    $this->productionMode = $productionMode;
+  }
+
+  /**
+   * @var bool cet objet est-il utilisé pour gérer le profil de l'application?
+   */
+  protected bool $isAppProfile;
+
+  protected ?array $profiles;
+
+  protected ?array $productionModes;
+
+  protected ?array $profileMap;
+
+  protected function mapProfile(?string $profile): ?string {
+    return $this->profileMap[$profile] ?? $profile;
+  }
+
+  protected ?string $configKey;
+
+  function getConfigProfile(): ?string {
+    if ($this->configKey === null) return null;
+    return config::k($this->configKey);
+  }
+
+  protected array $envKeys;
+
+  function getEnvProfile(): ?string {
+    foreach ($this->envKeys as $envKey) {
+      $profile = getenv($envKey);
+      if ($profile !== false) return $profile;
+    }
+    return null;
+  }
+
+  protected ?string $defaultProfile;
+
+  function getDefaultProfile(): ?string {
+    return $this->defaultProfile;
+  }
+
+  function setDefaultProfile(?string $profile): void {
+    $this->defaultProfile = $profile;
+  }
+
+  protected ?string $profile;
+
+  protected bool $productionMode;
+
+  protected function resolveProfile(): void {
+    $profile ??= $this->getenvProfile();
+    $profile ??= $this->getConfigProfile();
+    $profile ??= $this->getDefaultProfile();
+    if ($this->isAppProfile) {
+      $profile ??= $this->profiles[0] ?? ref_profiles::PROD;
+    } else {
+      $profile ??= $this->mapProfile(app::get_profile());
+    }
+    $this->profile = $profile;
+    $this->productionMode = $this->productionModes[$profile] ?? false;
+  }
+
+  function getProfile(?bool &$productionMode=null): string {
+    if ($this->profile === null) $this->resolveProfile();
+    $productionMode = $this->productionMode;
+    return $this->profile;
+  }
+
+  function isProductionMode(): bool {
+    return $this->productionMode;
+  }
+
+  function setProfile(?string $profile=null, ?bool $productionMode=null): void {
+    if ($profile === null) $this->profile = null;
+    $profile ??= $this->getProfile($productionMode);
+    $productionMode ??= $this->productionModes[$profile] ?? false;
+    $this->profile = $profile;
+    $this->productionMode = $productionMode;
+  }
+}
diff --git a/php/src/app/config/YamlConfig.php b/php/src/app/config/YamlConfig.php
new file mode 100644
index 0000000..e248c9c
--- /dev/null
+++ b/php/src/app/config/YamlConfig.php
@@ -0,0 +1,13 @@
+name = $name ?? "";
+    $this->compute = func::withn($compute ?? static::COMPUTE);
+  }
+
+  protected string $name;
+
+  function getName() : string {
+    return $this->name;
+  }
+
+  protected ?func $compute;
+
+  /** calculer la donnée */
+  function compute() {
+    $compute = $this->compute;
+    $data = $compute !== null? $compute->invoke(): null;
+    return $data;
+  }
+
+  /**
+   * le cache est-il externe? si non, utiliser {@link setDatafile()} pour
+   * spécifier le fichier destination de la valeur
+   */
+  abstract function isExternal(): bool;
+
+  /** spécifier le chemin du cache à partir du fichier de base */
+  abstract function setDatafile(?string $basefile): void;
+
+  /** indiquer si le cache existe */
+  abstract function exists(): bool;
+
+  /** charger la donnée depuis le cache */
+  abstract function load();
+
+  /** sauvegarder la donnée dans le cache et la retourner */
+  abstract function save($data);
+
+  /** supprimer le cache */
+  abstract function delete();
+}
diff --git a/php/src/cache/CacheFile.php b/php/src/cache/CacheFile.php
new file mode 100644
index 0000000..ea738d7
--- /dev/null
+++ b/php/src/cache/CacheFile.php
@@ -0,0 +1,356 @@
+initialDuration = Delay::with($params["duration"] ?? static::DURATION);
+    $this->overrideDuration = $params["override_duration"] ?? false;
+    $this->readonly = $params["readonly"] ?? false;
+    $this->cacheNull = $params["cache_null"] ?? false;
+    $data ??= $params["data"] ?? null;
+    $this->sources = null;
+    if (self::ensure_source($data, $source)) {
+      if ($source !== null) $source->setDatafile($basefile);
+      $this->sources = ["" => $source];
+    } else {
+      $sources = [];
+      $index = 0;
+      foreach ($data as $key => $source) {
+        self::ensure_source($source, $source, false);
+        if ($source !== null) {
+          $source->setDatafile($basefile);
+          if ($key === $index) {
+            $index++;
+            $key = $source->getName();
+          }
+        } elseif ($key === $index) {
+          $index++;
+        }
+        $sources[$key] = $source;
+      }
+      $this->sources = $sources;
+    }
+    parent::__construct($file);
+  }
+
+  protected Delay $initialDuration;
+
+  protected bool $overrideDuration;
+
+  protected bool $readonly;
+
+  protected bool $cacheNull;
+
+  /** @var ?CacheData[] */
+  protected ?array $sources;
+
+  /**
+   * vérifier si le fichier est valide. s'il est invalide, il faut le recréer.
+   *
+   * on assume que le fichier existe, vu qu'il a été ouvert en c+b
+   */
+  function isValid(): bool {
+    # considèrer que le fichier est invalide s'il est de taille nulle
+    return $this->getSize() > 0;
+  }
+
+  protected ?DateTime $start;
+
+  protected ?Delay $duration;
+
+  protected $data;
+
+  /** charger les données. le fichier a déjà été verrouillé en lecture */
+  protected function loadMetadata(): void {
+    if ($this->isValid()) {
+      $this->rewind();
+      [
+        "start" => $start,
+        "duration" => $duration,
+        "data" => $data,
+      ] = $this->unserialize(null, false, true);
+      if ($this->overrideDuration) {
+        $duration = Delay::with($this->initialDuration, $start);
+      }
+    } else {
+      $start = null;
+      $duration = null;
+      $data = null;
+    }
+    $this->start = $start;
+    $this->duration = $duration;
+    $this->data = $data;
+  }
+
+  /**
+   * tester s'il faut mettre les données à jour. le fichier a déjà été
+   * verrouillé en lecture
+   */
+  protected function shouldUpdate(bool $noCache=false): bool {
+    if ($this->isValid()) {
+      $expired = $this->duration->isElapsed();
+    } else {
+      $expired = false;
+      $noCache = true;
+    }
+    return $noCache || $expired;
+  }
+
+  /** sauvegarder les données. le fichier a déjà été verrouillé en écriture */
+  protected function saveMetadata(): void {
+    $this->duration ??= $this->initialDuration;
+    if ($this->start === null) {
+      $this->start = new DateTime();
+      $this->duration = Delay::with($this->duration, $this->start);
+    }
+    $this->ftruncate();
+    $this->serialize([
+      "start" => $this->start,
+      "duration" => $this->duration,
+      "data" => $this->data,
+    ], false, true);
+  }
+
+  protected function unlinkFiles(bool $datafilesOnly=false): void {
+    foreach ($this->sources as $source) {
+      if ($source !== null) $source->delete();
+    }
+    if (!$datafilesOnly) @unlink($this->file);
+  }
+
+  /** tester si $value peut être mis en cache */
+  protected function shouldCache($value): bool {
+    return $this->cacheNull || $value !== null;
+  }
+
+  protected ?DateTime $ostart;
+
+  protected ?Delay $oduration;
+
+  protected $odata;
+
+  protected function beforeAction() {
+    $this->loadMetadata();
+    $this->ostart = cv::clone($this->start);
+    $this->oduration = cv::clone($this->duration);
+    $this->odata = cv::clone($this->data);
+  }
+
+  protected function afterAction() {
+    # égalité non stricte pour start et duration
+    $modified = false;
+    if ($this->start != $this->ostart) $modified = true;
+    $duration = $this->duration;
+    $oduration = $this->oduration;
+    if ($duration === null || $oduration === null) $modified = true;
+    elseif ($duration->getDest() != $oduration->getDest()) $modified = true;
+    # égalité stricte pour $data
+    if ($this->data !== $this->odata) $modified = true;
+    if ($modified && !$this->readonly) {
+      $this->lockWrite();
+      $this->saveMetadata();
+    }
+  }
+
+  protected function action(callable $callback, bool $willWrite=false) {
+    if ($willWrite && !$this->readonly) $this->lockWrite();
+    else $this->lockRead();
+    try {
+      $this->beforeAction();
+      $result = $callback();
+      $this->afterAction();
+      return $result;
+    } finally {
+      $this->ostart = null;
+      $this->oduration = null;
+      $this->odata = null;
+      $this->start = null;
+      $this->duration = null;
+      $this->data = null;
+      $this->unlock(true);
+    }
+  }
+
+  protected function compute() {
+    return null;
+  }
+
+  protected function refreshData($key, bool $noCache) {
+    $source = $this->sources[$key] ?? null;
+    $external = $source !== null && $source->isExternal();
+
+    $updateMetadata = $this->shouldUpdate($noCache);
+    if (!$key && !$external) $updateData = $this->data === null;
+    else $updateData = !$source->exists();
+    if (!$this->readonly && ($updateMetadata || $updateData)) {
+      $this->lockWrite();
+      if ($updateMetadata) {
+        # il faut refaire tout le cache
+        $this->unlinkFiles(true);
+        $this->start = null;
+        $this->duration = null;
+        $this->data = null;
+      }
+      if (!$key && !$external) {
+        # calculer la valeur
+        try {
+          if ($source !== null) $data = $source->compute();
+          else $data = $this->compute();
+        } catch (Exception $e) {
+          # le fichier n'est pas mis à jour, mais ce n'est pas gênant: lors
+          # des futurs appels, l'exception continuera d'être lancée ou la
+          # valeur sera finalement mise à jour
+          throw $e;
+        }
+        if ($this->shouldCache($data)) $this->data = $data;
+        else $this->data = $data = null;
+      } elseif ($source !== null) {
+        # calculer la valeur
+        try {
+          $data = $source->compute();
+        } catch (Exception $e) {
+          # le fichier n'est pas mis à jour, mais ce n'est pas gênant: lors
+          # des futurs appels, l'exception continuera d'être lancée ou la
+          # valeur sera finalement mise à jour
+          throw $e;
+        }
+        if ($this->shouldCache($data)) {
+          $data = $source->save($data);
+        } else {
+          # ne pas garder le fichier s'il ne faut pas mettre en cache
+          $source->delete();
+          $data = null;
+        }
+      } else {
+        $data = null;
+      }
+    } elseif (!$key && !$external) {
+      $data = $this->data;
+    } elseif ($source !== null && $source->exists()) {
+      $data = $source->load();
+    } else {
+      $data = null;
+    }
+    return $data;
+  }
+
+  /**
+   * s'assurer que le cache est à jour avec les données les plus récentes. si
+   * les données sont déjà présentes dans le cache et n'ont pas encore expirées
+   * cette méthode est un NOP
+   */
+  function refresh(bool $noCache=false): self {
+    $this->action(function() use ($noCache) {
+      foreach (array_keys($this->sources) as $data) {
+        $this->refreshData($data, $noCache);
+      }
+    });
+    return $this;
+  }
+
+  function get($key=null, bool $noCache=false) {
+    return $this->action(function () use ($key, $noCache) {
+      return $this->refreshData($key, $noCache);
+    });
+  }
+
+  function all($key=null, bool $noCache=false): ?iterable {
+    $data = $this->get($key, $noCache);
+    if ($data !== null && !is_iterable($data)) $data = [$data];
+    return $data;
+  }
+
+  function delete($key=null): void {
+    $source = $this->sources[$key] ?? null;
+    if ($source !== null) $source->delete();
+  }
+
+  /** obtenir les informations sur le fichier */
+  function getInfos(): array {
+    return $this->action(function () {
+      if (!$this->isValid()) {
+        return ["valid" => false];
+      }
+      $start = $this->start;
+      $duration = $this->duration;
+      return [
+        "valid" => true,
+        "start" => $start,
+        "duration" => strval($duration),
+        "date_start" => $start->format(),
+        "date_end" => $duration->getDest()->format(),
+      ];
+    });
+  }
+
+  const UPDATE_SUB = -1, UPDATE_SET = 0, UPDATE_ADD = 1;
+
+  /**
+   * mettre à jour la durée de validité du fichier
+   *
+   * XXX UPDATE_SET n'est pas implémenté
+   */
+  function updateDuration($nduration, int $action=self::UPDATE_ADD): void {
+    if ($this->readonly) return;
+    $this->action(function () use ($nduration, $action) {
+      if (!$this->isValid()) return;
+      $duration = $this->duration;
+      if ($action < 0) $duration->subDuration($nduration);
+      elseif ($action > 0) $duration->addDuration($nduration);
+    }, true);
+  }
+
+  /** supprimer les fichiers s'ils ont expiré */
+  function deleteExpired(bool $force=false): bool {
+    if ($this->readonly) return false;
+    return $this->action(function () use ($force) {
+      if ($force || $this->shouldUpdate()) {
+        $this->unlinkFiles();
+        return true;
+      }
+      return false;
+    }, true);
+  }
+}
diff --git a/php/src/cache/CacheManager.php b/php/src/cache/CacheManager.php
new file mode 100644
index 0000000..2b134c9
--- /dev/null
+++ b/php/src/cache/CacheManager.php
@@ -0,0 +1,68 @@
+shouldCaches = [];
+    $this->defaultCache = true;
+    $this->includes = $includes;
+    $this->excludes = $excludes;
+  }
+
+  /**
+   * @var array tableau {id => shouldCache} indiquant si l'élément id doit être
+   * mis en cache
+   */
+  protected array $shouldCaches;
+
+  /**
+   * @var bool valeur par défaut de shouldCache si la valeur n'est pas trouvée
+   * dans $shouldCache
+   */
+  protected bool $defaultCache;
+
+  /**
+   * @var array|null groupes à toujours inclure dans le cache. pour les
+   * identifiants de ces groupe, {@link self::shouldCache()} retourne toujours
+   * true.
+   *
+   * $excludes est prioritaire par rapport à $includes
+   */
+  protected ?array $includes;
+
+  /**
+   * @var array|null groupes à exclure de la mise en cache. la mise en cache est
+   * toujours calculée pour les identifiants de ces groupes.
+   */
+  protected ?array $excludes;
+
+  function setNoCache(bool $noCache=true, bool $reset=true): self {
+    if ($reset) $this->shouldCaches = [];
+    $this->defaultCache = !$noCache;
+    return $this;
+  }
+
+  function shouldCache(string $id, ?string $groupId=null, bool $reset=true): bool {
+    if ($groupId !== null) {
+      $includes = $this->includes;
+      $shouldInclude = $includes !== null && in_array($groupId, $includes);
+      $excludes = $this->excludes;
+      $shouldExclude = $excludes !== null && in_array($groupId, $excludes);
+      if ($shouldInclude && !$shouldExclude) return true;
+    }
+    $cacheId = "$groupId-$id";
+    $shouldCache = cl::get($this->shouldCaches, $cacheId, $this->defaultCache);
+    $this->shouldCaches[$cacheId] = $reset?: $shouldCache;
+    return $shouldCache;
+  }
+}
diff --git a/php/src/cache/CursorCacheData.php b/php/src/cache/CursorCacheData.php
new file mode 100644
index 0000000..c0b402d
--- /dev/null
+++ b/php/src/cache/CursorCacheData.php
@@ -0,0 +1,40 @@
+initStorage(cache::storage());
+    $this->channel = $channel;
+  }
+
+  function isExternal(): bool {
+    return true;
+  }
+
+  function setDatafile(?string $basefile): void {
+  }
+
+  protected CursorChannel $channel;
+
+  function exists(): bool {
+    return $this->channel->count() > 0;
+  }
+
+  function load() {
+    return $this->channel;
+  }
+
+  function save($data) {
+    if (!is_iterable($data)) $data = [$data];
+    $this->channel->rechargeAll($data);
+    return $this->channel;
+  }
+
+  function delete() {
+    $this->channel->delete(null);
+  }
+}
diff --git a/php/src/cache/CursorChannel.php b/php/src/cache/CursorChannel.php
new file mode 100644
index 0000000..1af52b1
--- /dev/null
+++ b/php/src/cache/CursorChannel.php
@@ -0,0 +1,127 @@
+initStorage($storage);
+    if ($rows !== null) $channel->rechargeAll($rows);
+    return $channel;
+  }
+
+  const NAME = "cursor";
+  const TABLE_NAME = "cursor";
+
+  const COLUMN_DEFINITIONS = [
+    "group_id_" => "varchar(32) not null", // groupe de curseur
+    "id_" => "varchar(128) not null", // nom du curseur
+    "key_index_" => "integer not null",
+    "key_" => "varchar(128) not null",
+    "search_" => "varchar(255)",
+
+    "primary key (group_id_, id_, key_index_)",
+  ];
+
+  const ADD_COLUMNS = null;
+
+  protected function COLUMN_DEFINITIONS(): ?array {
+    return cl::merge(self::COLUMN_DEFINITIONS, static::ADD_COLUMNS);
+  }
+
+  /**
+   * @param array|string $cursorId
+   */
+  function __construct($cursorId) {
+    parent::__construct();
+    cache::verifix_id($cursorId);
+    [
+      "group_id" => $this->groupId,
+      "id" => $this->id,
+    ] = $cursorId;
+  }
+
+  protected string $groupId;
+
+  protected string $id;
+
+  function getCursorId(): array {
+    return [
+      "group_id" => $this->groupId,
+      "id" => $this->id,
+    ];
+  }
+
+  function getBaseFilter(): ?array {
+    return [
+      "group_id_" => $this->groupId,
+      "id_" => $this->id,
+    ];
+  }
+
+  protected int $index = 0;
+
+  protected function getSearch($item): ?string {
+    $search = cl::filter_n(cl::with($item));
+    $search = implode(" ", $search);
+    return substr($search, 0, 255);
+  }
+
+  function getItemValues($item, $key=null): ?array {
+    $index = $this->index++;
+    $key = $key ?? $index;
+    $key = substr(strval($key), 0, 128);
+    $addColumns = static::ADD_COLUMNS ?? [];
+    $addColumns = cl::select($item,
+      array_filter(array_keys($addColumns), function ($key) {
+        return is_string($key);
+      }));
+    return cl::merge($addColumns, [
+      "group_id_" => $this->groupId,
+      "id_" => $this->id,
+      "key_index_" => $index,
+      "key_" => $key,
+      "search_" => $this->getSearch($item),
+    ]);
+  }
+
+  function reset(bool $recreate=false): void {
+    $this->index = 0;
+    parent::reset($recreate);
+  }
+
+  function chargeAll(?iterable $items, $func=null, ?array $args=null): int {
+    if ($items === null) return 0;
+    $count = 0;
+    if ($func !== null) $func = func::with($func, $args)->bind($this);
+    foreach ($items as $key => $item) {
+      $count += $this->charge($item, $func, [$key]);
+    }
+    return $count;
+  }
+
+  function rechargeAll(?iterable $items): self {
+    $this->delete(null);
+    $this->index = 0;
+    $this->chargeAll($items);
+    return $this;
+  }
+
+  function getIterator(): Traversable {
+    $rows = $this->dbAll([
+      "cols" => ["key_", "item__"],
+      "where" => $this->getBaseFilter(),
+    ]);
+    foreach ($rows as $row) {
+      $key = $row["key_"];
+      $item = $this->unserialize($row["item__"]);
+      yield $key => $item;
+    }
+  }
+}
diff --git a/php/src/cache/DataCacheData.php b/php/src/cache/DataCacheData.php
new file mode 100644
index 0000000..fa85ce6
--- /dev/null
+++ b/php/src/cache/DataCacheData.php
@@ -0,0 +1,62 @@
+setDatafile($basefile);
+  }
+
+  function compute() {
+    $data = parent::compute();
+    if ($data instanceof Traversable) $data = cl::all($data);
+    return $data;
+  }
+
+  function isExternal(): bool {
+    return false;
+  }
+
+  protected string $datafile;
+
+  function setDatafile(?string $basefile): void {
+    if ($basefile === null) {
+      $basedir = ".";
+      $basename = "";
+    } else {
+      $basedir = path::dirname($basefile);
+      $basename = path::filename($basefile);
+    }
+    $this->datafile = "$basedir/.$basename.{$this->name}".cache::EXT;
+  }
+
+  function exists(): bool {
+    return file_exists($this->datafile);
+  }
+
+  function load() {
+    return file::reader($this->datafile)->unserialize();
+  }
+
+  function save($data) {
+    file::writer($this->datafile)->serialize($data);
+    return $data;
+  }
+
+  function delete(): void {
+    @unlink($this->datafile);
+  }
+}
diff --git a/php/src/cache/TODO.md b/php/src/cache/TODO.md
new file mode 100644
index 0000000..e8c41c0
--- /dev/null
+++ b/php/src/cache/TODO.md
@@ -0,0 +1,6 @@
+# nulib\cache
+
+* [ ] CacheChannel: stocker aussi la clé primaire, ce qui permet de récupérer
+  la donnée correspondante dans la source?
+
+-*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary
\ No newline at end of file
diff --git a/php/src/cache/cache.php b/php/src/cache/cache.php
new file mode 100644
index 0000000..ed599fa
--- /dev/null
+++ b/php/src/cache/cache.php
@@ -0,0 +1,93 @@
+getVarfile("cache.db");
+  }
+
+  protected static ?CapacitorStorage $storage = null;
+
+  static function storage(): CapacitorStorage {
+    return self::$storage ??= new SqliteStorage(self::dbfile());
+  }
+
+  static function set_storage(CapacitorStorage $storage): CapacitorStorage {
+    return self::$storage = $storage;
+  }
+
+  protected static ?CacheManager $manager = null;
+
+  static function manager(): CacheManager {
+    return self::$manager ??= new CacheManager();
+  }
+
+  static function set_manager(CacheManager $manager): CacheManager {
+    return self::$manager = $manager;
+  }
+
+  static function nc(bool $noCache=true, bool $reset=false): void {
+    self::manager()->setNoCache($noCache, $reset);
+  }
+
+  protected static function should_cache(string $id, ?string $groupId=null, bool $reset=true): bool {
+    return self::manager()->shouldCache($id, $groupId, $reset);
+  }
+
+  static function verifix_id(&$cacheId): void {
+    $cacheId ??= utils::uuidgen();
+    if (is_array($cacheId)) {
+      $keys = array_keys($cacheId);
+      if (array_key_exists("id", $cacheId)) $idKey = "id";
+      else $idKey = $keys[0] ?? null;
+      $id = strval($cacheId[$idKey] ?? "");
+      if (array_key_exists("group_id", $cacheId)) $groupIdKey = "group_id";
+      else $groupIdKey = $keys[1] ?? null;
+      $groupId = strval($cacheId[$groupIdKey] ?? "");
+    } else {
+      $id = strval($cacheId);
+      $groupId = "";
+    }
+    # si le groupe ou le nom sont trop grand, en faire un hash
+    if (strlen($groupId) > 32) $groupId = md5($groupId);
+    if (strlen($id) > 128) $id = substr($id, 0, 128 - 32).md5($id);
+    $cacheId = ["group_id" => $groupId, "id" => $id];
+  }
+
+  private static function new(array $cacheId, ?string $suffix, $data, ?array $params=null): CacheFile {
+    $file = $cacheId["group_id"];
+    if ($file) $file .= "_";
+    $file .= $cacheId["id"];
+    $file .= $suffix;
+    return new CacheFile($file, $data, $params);
+  }
+
+  static function cache($dataId, $data, ?array $params=null): CacheFile {
+    self::verifix_id($dataId);
+    return self::new($dataId, null, $data, $params);
+  }
+
+  static function get($dataId, $data, ?array $params=null) {
+    self::verifix_id($dataId);
+    $noCache = !self::should_cache($dataId["id"], $dataId["group_id"]);
+    $cache = self::new($dataId, null, $data, $params);
+    return $cache->get(null, $noCache);
+  }
+
+  static function all($cursorId, $rows, ?array $params=null): ?iterable {
+    self::verifix_id($cursorId);
+    $noCache = !self::should_cache($cursorId["id"], $cursorId["group_id"]);
+    $cache = self::new($cursorId, "_rows", new CursorCacheData($cursorId, $rows), $params);
+    return $cache->get(null, $noCache);
+  }
+}
diff --git a/php/src/cl.php b/php/src/cl.php
index f0919ea..2e57a87 100644
--- a/php/src/cl.php
+++ b/php/src/cl.php
@@ -848,7 +848,7 @@ class cl {
   static final function any_not_same(?array $array, $value): bool { return self::any_if($array, cv::Fnot_same($value)); }
 
   #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-  
+
   static final function filter_if(?array $array, callable $cond): ?array {
     if ($array === null) return null;
     $filtered = [];
@@ -923,4 +923,48 @@ class cl {
     A::usort($array, $keys, $assoc);
     return $array;
   }
+
+  #############################################################################
+
+  /**
+   * Extraire d'un tableau les clés séquentielles et les clés associatives
+   *
+   * Retourner une liste [$list, $assoc] où $list est un tableau avec uniquement
+   * les valeurs des clés séquentielles et $assoc est un tableau avec uniquement
+   * les valeurs des clés associatives. S'il n'existe aucune clé séquentielle
+   * (resp. aucune clé associative), $list (resp. $assoc) vaut null.
+   *
+   * Par exemple: split_assoc(["a", "b" => "c"]) retourne [["a"], ["b" => "c"]]
+   */
+  static final function split_assoc(?array $array): array {
+    $list = null;
+    $assoc = null;
+    if ($array !== null) {
+      $i = 0;
+      foreach ($array as $key => $value) {
+        if ($key === $i) {
+          $list[] = $value;
+          $i++;
+        } else {
+          $assoc[$key] = $value;
+        }
+      }
+    }
+    return [$list, $assoc];
+  }
+
+  /**
+   * Joindre en un seul tableau un tableau avec des clés séquentielles et un
+   * tableau avec des clés associatives.
+   *
+   * Si $list_first==true, les clés séquentielles arrivent d'abord, ensuite les
+   * clés associatives. Sinon, ce sont les clés associatives qui arrivent d'abord
+   */
+  static final function merge_assoc(?array &$array, ?array $list, ?array $assoc, bool $list_first=false): void {
+    if ($list === null && $assoc === null) $array = [];
+    elseif ($list === null) $array = $assoc;
+    elseif ($assoc === null) $array = $list;
+    elseif ($list_first) $array = array_merge($list, $assoc);
+    else $array = array_merge($assoc, $list);
+  }
 }
diff --git a/php/src/cv.php b/php/src/cv.php
index ac3dbac..89b81ac 100644
--- a/php/src/cv.php
+++ b/php/src/cv.php
@@ -29,7 +29,7 @@ class cv {
   static final function t($value): bool {
     return $value || $value === "0";
   }
-  
+
   /** tester si $value est fausse (cela n'inclue pas la chaine "0") */
   static final function f($value): bool {
     return !$value && $value !== "0";
@@ -166,6 +166,12 @@ class cv {
 
   #############################################################################
 
+  /** retourner $value si elle est non nulle, lancer une exception sinon */
+  static final function not_null($value, ?string $kind=null) {
+    if ($value !== null) return $value;
+    throw exceptions::null_value($kind);
+  }
+
   /** vérifier si $value est un booléen, sinon retourner null */
   static final function check_bool($value): ?bool {
     return is_bool($value)? $value: null;
@@ -192,11 +198,11 @@ class cv {
    *
    * lever une exception si $value n'est d'aucun de ces types
    */
-  static final function check_key($value, ?string $prefix=null, bool $throw_exception=true): array {
+  static final function check_key($value, ?string $kind=null, bool $throwException=true): array {
     $index = is_int($value)? $value : null;
     $key = is_string($value)? $value : null;
-    if ($index === null && $key === null && $throw_exception) {
-      throw ValueException::invalid_kind($value, "key", $prefix);
+    if ($index === null && $key === null && $throwException) {
+      throw exceptions::invalid_type($value, $kind, "key");
     } else {
       return [$index, $key];
     }
@@ -208,12 +214,12 @@ class cv {
    *
    * @throws ValueException si $value n'est d'aucun de ces types
    */
-  static final function check_bsa($value, ?string $prefix=null, bool $throw_exception=true): array {
+  static final function check_bsa($value, ?string $kind=null, bool $throwException=true): array {
     $bool = is_bool($value)? $value : null;
     $scalar = !is_bool($value) && is_scalar($value)? $value : null;
     $array = is_array($value)? $value : null;
-    if ($bool === null && $scalar === null && $array === null && $throw_exception) {
-      throw ValueException::invalid_kind($value, "value", $prefix);
+    if ($bool === null && $scalar === null && $array === null && $throwException) {
+      throw exceptions::invalid_type($value, $kind, ["bool", "scalar", "array"]);
     } else {
       return [$bool, $scalar, $array];
     }
diff --git a/php/src/db/Capacitor.php b/php/src/db/Capacitor.php
index 8fb2403..1ed0db7 100644
--- a/php/src/db/Capacitor.php
+++ b/php/src/db/Capacitor.php
@@ -1,10 +1,9 @@
 subChannels[] = $channel;
         } else {
-          throw ValueException::invalid_type($channel, CapacitorChannel::class);
+          throw exceptions::invalid_type($channel, "channel", CapacitorChannel::class);
         }
       }
     }
diff --git a/php/src/db/CapacitorStorage.php b/php/src/db/CapacitorStorage.php
index dbc7f87..b812408 100644
--- a/php/src/db/CapacitorStorage.php
+++ b/php/src/db/CapacitorStorage.php
@@ -5,8 +5,8 @@ use nulib\A;
 use nulib\cl;
 use nulib\cv;
 use nulib\db\_private\_migration;
+use nulib\exceptions;
 use nulib\php\func;
-use nulib\ValueException;
 use Traversable;
 
 /**
@@ -17,7 +17,7 @@ abstract class CapacitorStorage {
   abstract function db(): IDatabase;
 
   function ensureLive(): self {
-    $this->db()->ensure();
+    $this->db()->ensureLive();
     return $this;
   }
 
@@ -596,7 +596,7 @@ abstract class CapacitorStorage {
    * si $filter n'est pas un tableau, il est transformé en ["id_" => $filter]
    */
   function _one(CapacitorChannel $channel, $filter, ?array $mergeQuery=null): ?array {
-    if ($filter === null) throw ValueException::null("filter");
+    if ($filter === null) throw exceptions::null_value("filter");
     $this->_create($channel);
     $this->verifixFilter($channel, $filter);
     $raw = $this->db()->one(cl::merge([
diff --git a/php/src/db/IDatabase.php b/php/src/db/IDatabase.php
index 32a0013..5ca2e54 100644
--- a/php/src/db/IDatabase.php
+++ b/php/src/db/IDatabase.php
@@ -17,7 +17,7 @@ interface IDatabase extends ITransactor {
    * transactions en cours sont perdues. cette méthode est donc prévue pour
    * vérifier la validité de la connexion avant de lancer une transaction
    */
-  function ensure(): self;
+  function ensureLive(): self;
 
   /**
    * - si c'est un insert, retourner l'identifiant autogénéré de la ligne
diff --git a/php/src/db/TODO.md b/php/src/db/TODO.md
index c7004a6..1f03e6d 100644
--- a/php/src/db/TODO.md
+++ b/php/src/db/TODO.md
@@ -1,7 +1,29 @@
 # db/Capacitor
 
-* charge() permet de spécifier la clé associée avec la valeur chargée, et
-  discharge() retourne les valeurs avec la clé primaire
-* chargeAll() (ou peut-être chargeFrom()) permet de charger depuis un iterable
+charge() permet de spécifier la clé associée avec la valeur chargée, et
+discharge() retourne les valeurs avec la clé primaire
+
+---
+
+chargeAll() (ou peut-être chargeFrom()) permet de charger depuis un iterable
+
+---
+
+rendre obsolète la classe Capacitor: ne garder que CapacitorChannel et
+CapacitorStorage
+
+---
+
+constante de classe AUTO_MIGRATE valant par défaut null
+
+false: ne jamais faire de migration: assumer que la table existe avec les bonnes
+colonnes
+
+true: toujours chercher à faire la migration
+
+null: calculer la valeur en fonction du profil courant: true pour devel, false
+sinon
+
+
 
 -*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary
\ No newline at end of file
diff --git a/php/src/db/_private/_base.php b/php/src/db/_private/_base.php
index 2ca42f9..8325bae 100644
--- a/php/src/db/_private/_base.php
+++ b/php/src/db/_private/_base.php
@@ -1,14 +1,14 @@
  "create", "type" => "ddl"];
@@ -28,7 +28,7 @@ abstract class _base extends _common {
         $sql = _generic::parse($sql, $bindings);
         $meta = ["isa" => "generic", "type" => null];
       } else {
-        throw ValueException::invalid_kind($sql, "query");
+        throw exceptions::invalid_value($sql, "cette requête sql");
       }
     } else {
       if (!is_string($sql)) $sql = strval($sql);
diff --git a/php/src/db/_private/_common.php b/php/src/db/_private/_common.php
index 575a53b..69b93ab 100644
--- a/php/src/db/_private/_common.php
+++ b/php/src/db/_private/_common.php
@@ -2,8 +2,8 @@
 namespace nulib\db\_private;
 
 use nulib\cl;
+use nulib\exceptions;
 use nulib\str;
-use nulib\ValueException;
 
 class _common {
   protected static function consume(string $pattern, string &$string, ?array &$ms=null): bool {
@@ -249,7 +249,7 @@ class _common {
   protected static function check_eof(string $tmpsql, string $usersql): void {
     self::consume(';\s*', $tmpsql);
     if ($tmpsql) {
-      throw new ValueException("unexpected value at end: $usersql");
+      throw exceptions::invalid_value($usersql, "cette requête sql");
     }
   }
 }
diff --git a/php/src/db/_private/_insert.php b/php/src/db/_private/_insert.php
index eb54980..a4fe118 100644
--- a/php/src/db/_private/_insert.php
+++ b/php/src/db/_private/_insert.php
@@ -2,7 +2,7 @@
 namespace nulib\db\_private;
 
 use nulib\cl;
-use nulib\ValueException;
+use nulib\exceptions;
 
 class _insert extends _common {
   const SCHEMA = [
@@ -44,7 +44,7 @@ class _insert extends _common {
     } elseif ($into !== null) {
       $sql[] = $into;
     } else {
-      throw new ValueException("expected table name: $usersql");
+      throw exceptions::invalid_value($usersql, "cette requête sql", "il faut spécifier la table");
     }
 
     ## cols & values
diff --git a/php/src/db/_private/_select.php b/php/src/db/_private/_select.php
index 0dc0f0b..4fdc3ff 100644
--- a/php/src/db/_private/_select.php
+++ b/php/src/db/_private/_select.php
@@ -2,8 +2,8 @@
 namespace nulib\db\_private;
 
 use nulib\cl;
+use nulib\exceptions;
 use nulib\str;
-use nulib\ValueException;
 
 class _select extends _common {
   const SCHEMA = [
@@ -101,7 +101,7 @@ class _select extends _common {
       $sql[] = "from";
       $sql[] = $from;
     } else {
-      throw new ValueException("expected table name: $usersql");
+      throw exceptions::invalid_value($usersql, "cette requête sql", "il faut spécifier la table");
     }
 
     ## where
diff --git a/php/src/db/mysql/Mysql.php b/php/src/db/mysql/Mysql.php
index f0a0e75..52b93d6 100644
--- a/php/src/db/mysql/Mysql.php
+++ b/php/src/db/mysql/Mysql.php
@@ -6,11 +6,22 @@ use nulib\db\pdo\Pdo;
 class Mysql extends Pdo {
   const PREFIX = "mysql";
 
+  static function config_setTimeout(self $pdo): void {
+    $pdo->_exec("SET session wait_timeout=28800");
+    $pdo->_exec("SET session interactive_timeout=28800");
+  }
+  const CONFIG_setTimeout = [self::class, "config_setTimeout"];
+
   static function config_unbufferedQueries(self $mysql): void {
     $mysql->db->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
   }
   const CONFIG_unbufferedQueries = [self::class, "config_unbufferedQueries"];
 
+  const DEFAULT_CONFIG = [
+    ...parent::DEFAULT_CONFIG,
+    self::CONFIG_setTimeout,
+  ];
+
   function getDbname(): ?string {
     $url = $this->dbconn["name"] ?? null;
     if ($url !== null && preg_match('/^mysql(?::|.*;)dbname=([^;]+)/i', $url, $ms)) {
diff --git a/php/src/db/pdo/Pdo.php b/php/src/db/pdo/Pdo.php
index 10da1c6..a12be17 100644
--- a/php/src/db/pdo/Pdo.php
+++ b/php/src/db/pdo/Pdo.php
@@ -6,8 +6,8 @@ use nulib\db\_private\_config;
 use nulib\db\_private\Tvalues;
 use nulib\db\IDatabase;
 use nulib\db\ITransactor;
+use nulib\exceptions;
 use nulib\php\func;
-use nulib\ValueException;
 
 class Pdo implements IDatabase {
   use Tvalues;
@@ -28,6 +28,7 @@ class Pdo implements IDatabase {
         "options" => $pdo->options,
         "config" => $pdo->config,
         "migration" => $pdo->migration,
+        "autocheck" => $pdo->autocheck,
       ], $params));
     } else {
       return new static($pdo, $params);
@@ -41,7 +42,7 @@ class Pdo implements IDatabase {
   const CONFIG_errmodeException_lowerCase = [self::class, "config_errmodeException_lowerCase"];
 
   protected const OPTIONS = [
-    \PDO::ATTR_PERSISTENT => true,
+    \PDO::ATTR_PERSISTENT => false,
   ];
 
   protected const DEFAULT_CONFIG = [
@@ -52,6 +53,10 @@ class Pdo implements IDatabase {
 
   protected const MIGRATION = null;
 
+  protected const AUTOCHECK = true;
+
+  protected const AUTOOPEN = true;
+
   const dbconn_SCHEMA = [
     "name" => "string",
     "user" => "?string",
@@ -64,7 +69,8 @@ class Pdo implements IDatabase {
     "replace_config" => ["?array|callable"],
     "config" => ["?array|callable"],
     "migration" => ["?array|string|callable"],
-    "auto_open" => ["bool", true],
+    "autocheck" => ["bool", self::AUTOCHECK],
+    "autoopen" => ["bool", self::AUTOOPEN],
   ];
 
   function __construct($dbconn=null, ?array $params=null) {
@@ -96,8 +102,8 @@ class Pdo implements IDatabase {
     # migrations
     $this->migration = $params["migration"] ?? static::MIGRATION;
     #
-    $defaultAutoOpen = self::params_SCHEMA["auto_open"][1];
-    if ($params["auto_open"] ?? $defaultAutoOpen) {
+    $this->autocheck = $params["autocheck"] ?? static::AUTOCHECK;
+    if ($params["autoopen"] ?? static::AUTOOPEN) {
       $this->open();
     }
   }
@@ -113,6 +119,8 @@ class Pdo implements IDatabase {
   /** @var array|string|callable */
   protected $migration;
 
+  protected bool $autocheck;
+
   protected ?\PDO $db = null;
 
   function getSql($query, ?array $params=null): string {
@@ -163,7 +171,7 @@ class Pdo implements IDatabase {
 
   const SQL_CHECK_LIVE = "select 1";
 
-  function ensure(): self {
+  function ensureLive(): self {
     try {
       $this->_query(static::SQL_CHECK_LIVE);
     } catch (\PDOException $e) {
@@ -195,7 +203,7 @@ class Pdo implements IDatabase {
         $this->transactors[] = $transactor;
         $transactor->willUpdate();
       } else {
-        throw ValueException::invalid_type($transactor, ITransactor::class);
+        throw exceptions::invalid_type($transactor, "transactor", ITransactor::class);
       }
     }
     return $this;
@@ -206,6 +214,9 @@ class Pdo implements IDatabase {
   }
 
   function beginTransaction(?callable $func=null, bool $commit=true): void {
+    # s'assurer que la connexion à la BDD est active avant de commencer une
+    # transaction
+    if ($this->autocheck) $this->ensureLive();
     $this->db()->beginTransaction();
     if ($this->transactors !== null) {
       foreach ($this->transactors as $transactor) {
diff --git a/php/src/db/pgsql/Pgsql.php b/php/src/db/pgsql/Pgsql.php
index ca9b7e9..72e2ef8 100644
--- a/php/src/db/pgsql/Pgsql.php
+++ b/php/src/db/pgsql/Pgsql.php
@@ -6,8 +6,8 @@ use nulib\db\_private\_config;
 use nulib\db\_private\Tvalues;
 use nulib\db\IDatabase;
 use nulib\db\ITransactor;
+use nulib\exceptions;
 use nulib\php\func;
-use nulib\ValueException;
 
 class Pgsql implements IDatabase {
   use Tvalues;
@@ -34,7 +34,6 @@ class Pgsql implements IDatabase {
     }
   }
 
-
   protected const OPTIONS = [
     # XXX désactiver les connexions persistantes par défaut
     # pour réactiver par défaut, il faudrait vérifier la connexion à chaque fois
@@ -49,13 +48,18 @@ class Pgsql implements IDatabase {
 
   const MIGRATION = null;
 
+  protected const AUTOCHECK = true;
+
+  protected const AUTOOPEN = true;
+
   const params_SCHEMA = [
     "dbconn" => ["array"],
     "options" => ["?array|callable"],
     "replace_config" => ["?array|callable"],
     "config" => ["?array|callable"],
     "migration" => ["?array|string|callable"],
-    "auto_open" => ["bool", true],
+    "autocheck" => ["bool", self::AUTOCHECK],
+    "autoopen" => ["bool", self::AUTOOPEN],
   ];
 
   const dbconn_SCHEMA = [
@@ -113,8 +117,8 @@ class Pgsql implements IDatabase {
     # migrations
     $this->migration = $params["migration"] ?? static::MIGRATION;
     #
-    $defaultAutoOpen = self::params_SCHEMA["auto_open"][1];
-    if ($params["auto_open"] ?? $defaultAutoOpen) {
+    $this->autocheck = $params["autocheck"] ?? static::AUTOCHECK;
+    if ($params["autoopen"] ?? static::AUTOOPEN) {
       $this->open();
     }
   }
@@ -130,6 +134,8 @@ class Pgsql implements IDatabase {
   /** @var array|string|callable */
   protected $migration;
 
+  protected bool $autocheck;
+
   /** @var resource */
   protected $db = null;
 
@@ -209,7 +215,7 @@ class Pgsql implements IDatabase {
 
   const SQL_CHECK_LIVE = "select 1";
 
-  function ensure(): self {
+  function ensureLive(): self {
     try {
       $this->_query(static::SQL_CHECK_LIVE);
     } catch (\PDOException $e) {
@@ -247,7 +253,7 @@ class Pgsql implements IDatabase {
         $this->transactors[] = $transactor;
         $transactor->willUpdate();
       } else {
-        throw ValueException::invalid_type($transactor, ITransactor::class);
+        throw exceptions::invalid_type($transactor, "transactor", ITransactor::class);
       }
     }
     return $this;
@@ -267,6 +273,9 @@ class Pgsql implements IDatabase {
   }
 
   function beginTransaction(?callable $func=null, bool $commit=true): void {
+    # s'assurer que la connexion à la BDD est active avant de commencer une
+    # transaction
+    if ($this->autocheck) $this->ensureLive();
     $this->_exec("begin");
     if ($this->transactors !== null) {
       foreach ($this->transactors as $transactor) {
diff --git a/php/src/db/sqlite/Sqlite.php b/php/src/db/sqlite/Sqlite.php
index 1d52f2c..7876c35 100644
--- a/php/src/db/sqlite/Sqlite.php
+++ b/php/src/db/sqlite/Sqlite.php
@@ -7,8 +7,8 @@ use nulib\db\_private\_config;
 use nulib\db\_private\Tvalues;
 use nulib\db\IDatabase;
 use nulib\db\ITransactor;
+use nulib\exceptions;
 use nulib\php\func;
-use nulib\ValueException;
 use SQLite3;
 use SQLite3Result;
 use SQLite3Stmt;
@@ -80,6 +80,10 @@ class Sqlite implements IDatabase {
 
   const MIGRATION = null;
 
+  protected const AUTOCHECK = true;
+
+  protected const AUTOOPEN = true;
+
   const params_SCHEMA = [
     "file" => ["string", ""],
     "flags" => ["int", SQLITE3_OPEN_READWRITE + SQLITE3_OPEN_CREATE],
@@ -88,7 +92,8 @@ class Sqlite implements IDatabase {
     "replace_config" => ["?array|callable"],
     "config" => ["?array|callable"],
     "migration" => ["?array|string|callable"],
-    "auto_open" => ["bool", true],
+    "autocheck" => ["bool", self::AUTOCHECK],
+    "autoopen" => ["bool", self::AUTOOPEN],
   ];
 
   function __construct(?string $file=null, ?array $params=null) {
@@ -117,9 +122,9 @@ class Sqlite implements IDatabase {
     # migrations
     $this->migration = $params["migration"] ?? static::MIGRATION;
     #
-    $defaultAutoOpen = self::params_SCHEMA["auto_open"][1];
     $this->inTransaction = false;
-    if ($params["auto_open"] ?? $defaultAutoOpen) {
+    $this->autocheck = $params["autocheck"] ?? static::AUTOCHECK;
+    if ($params["autoopen"] ?? static::AUTOOPEN) {
       $this->open();
     }
   }
@@ -147,6 +152,8 @@ class Sqlite implements IDatabase {
   /** @var array|string|callable */
   protected $migration;
 
+  protected bool $autocheck;
+
   /** @var SQLite3 */
   protected $db;
 
@@ -208,7 +215,7 @@ class Sqlite implements IDatabase {
 
   const SQL_CHECK_LIVE = "select 1";
 
-  function ensure(): self {
+  function ensureLive(): self {
     try {
       $this->_query(static::SQL_CHECK_LIVE);
     } catch (\PDOException $e) {
@@ -247,7 +254,7 @@ class Sqlite implements IDatabase {
         $this->transactors[] = $transactor;
         $transactor->willUpdate();
       } else {
-        throw ValueException::invalid_type($transactor, ITransactor::class);
+        throw exceptions::invalid_type($transactor, "transactor", ITransactor::class);
       }
     }
     return $this;
@@ -259,6 +266,9 @@ class Sqlite implements IDatabase {
   }
 
   function beginTransaction(?callable $func=null, bool $commit=true): void {
+    # s'assurer que la connexion à la BDD est active avant de commencer une
+    # transaction
+    if ($this->autocheck) $this->ensureLive();
     $this->db()->exec("begin");
     $this->inTransaction = true;
     if ($this->transactors !== null) {
diff --git a/php/src/exceptions.php b/php/src/exceptions.php
new file mode 100644
index 0000000..a6b1e88
--- /dev/null
+++ b/php/src/exceptions.php
@@ -0,0 +1,253 @@
+getUserMessage();
+    elseif ($e instanceof ExceptionShadow) $userMessage = $e->getUserMessage();
+    else return null;
+    if ($userMessage === null) return null;
+    else return c::to_string($userMessage);
+  }
+
+  /** @param Throwable|ExceptionShadow $e */
+  public static function get_tech_message($e): ?string {
+    if ($e instanceof UserException) $techMessage = $e->getTechMessage();
+    elseif ($e instanceof ExceptionShadow) $techMessage = $e->getTechMessage();
+    else return null;
+    if ($techMessage === null) return null;
+    else return c::to_string($techMessage);
+  }
+
+  /** @param Throwable|ExceptionShadow $e */
+  public static function get_message($e): string {
+    if ($e instanceof UserException) $userMessage = $e->getUserMessage();
+    elseif ($e instanceof ExceptionShadow) $userMessage = $e->getUserMessage();
+    else return $e->getMessage();
+    return c::to_string($userMessage);
+  }
+
+  /** @param Throwable|ExceptionShadow $e */
+  public static final function get_summary($e, bool $includePrevious = true): string {
+    $parts = [];
+    $first = true;
+    while ($e !== null) {
+      $message = self::get_message($e);
+      if (!$message) $message = "(no message)";
+      $techMessage = self::get_tech_message($e);
+      if ($techMessage) $message .= " |$techMessage|";
+      if ($first) $first = false;
+      else $parts[] = ", caused by ";
+      if ($e instanceof ExceptionShadow) $class = $e->getClass();
+      else $class = get_class($e);
+      $parts[] = "$class: $message";
+      $e = $includePrevious ? $e->getPrevious() : null;
+    }
+    return implode("", $parts);
+  }
+
+  /** @param Throwable|ExceptionShadow $e */
+  public static final function get_traceback($e): string {
+    $tbs = [];
+    $previous = false;
+    while ($e !== null) {
+      if (!$previous) {
+        $efile = $e->getFile();
+        $eline = $e->getLine();
+        $tbs[] = "at $efile($eline)";
+      } else {
+        $tbs[] = "~~ caused by: " . self::get_summary($e, false);
+      }
+      $tbs[] = $e->getTraceAsString();
+      $e = $e->getPrevious();
+      $previous = true;
+      #XXX il faudrait ne pas réinclure les lignes communes aux exceptions qui
+      # ont déjà été affichées
+    }
+    return implode("\n", $tbs);
+  }
+
+  #############################################################################
+
+  const EXCEPTION = ValueException::class;
+
+  const WORD = "la valeur#s";
+
+  protected static Word $word;
+
+  protected static function word(): Word {
+    return self::$word ??= new Word(static::WORD);
+  }
+
+  static function value($value): string {
+    if (is_object($value)) {
+      return "<".get_class($value).">";
+    } elseif (is_array($value)) {
+      $values = $value;
+      $parts = [];
+      $index = 0;
+      foreach ($values as $key => $value) {
+        if ($key === $index) {
+          $index++;
+          $parts[] = self::value($value);
+        } else {
+          $parts[] = "$key=>".self::value($value);
+        }
+      }
+      return "[".implode(", ", $parts)."]";
+    } elseif (is_string($value)) {
+      return $value;
+    } else {
+      return var_export($value, true);
+    }
+  }
+
+  static function generic($value, ?string $kind, ?string $cause, ?string $reason=null, ?Throwable $previous=null): UserException {
+    $msg = "";
+    if ($value !== null) {
+      $msg .= self::value($value);
+      $msg .= ": ";
+    }
+    $kind ??= self::word()->_ce();
+    $msg .= $kind;
+    $cause ??= "est invalide";
+    if ($cause) $msg .= " $cause";
+    if ($reason) $msg .= ": $reason";
+    $code = $previous !== null? $previous->getCode(): 0;
+    $class = static::EXCEPTION;
+    return new $class($msg, $code, $previous);
+  }
+
+  /**
+   * indiquer qu'une valeur est invalide pour une raison générique
+   */
+  static function invalid_value($value, ?string $kind=null, ?string $reason=null, ?Throwable $previous=null): UserException {
+    return self::generic($value, $kind, null, $reason, $previous);
+  }
+
+  /**
+   * spécialisation de {@link self::invalid_value()} qui permet d'indiquer les
+   * types attendus
+   */
+  static function invalid_type($value, ?string $kind=null, $expectedTypes=null, ?Throwable $previous=null): UserException {
+    if ($kind !== null) $pronom = "il";
+    else $pronom = self::word()->pronom();
+    $expectedTypes = cl::withn($expectedTypes);
+    if (!$expectedTypes) {
+      $reason = null;
+    } elseif (count($expectedTypes) == 1) {
+      $reason = "$pronom doit être du type suivant: ";
+    } else {
+      $reason = "$pronom doit être d'un des types suivants: ";
+    }
+    $reason .= implode(", ", $expectedTypes);
+    return self::invalid_value($value, $kind, $reason, $previous);
+  }
+
+  static function invalid_format($value, ?string $kind=null, $expectedFormats=null, ?Throwable $previous=null): UserException {
+    if ($kind !== null) $pronom = "il";
+    else $pronom = self::word()->pronom();
+    $expectedFormats = cl::withn($expectedFormats);
+    if (!$expectedFormats) {
+      $reason = null;
+    } elseif (count($expectedFormats) == 1) {
+      $reason = "$pronom doit être au format suivant: ";
+    } else {
+      $reason = "$pronom doit être dans l'un des formats suivants: ";
+    }
+    $reason .= implode(", ", $expectedFormats);
+    return self::invalid_value($value, $kind, $reason, $previous);
+  }
+
+  static function forbidden_value($value, ?string $kind=null, $allowedValues=null, ?Throwable $previous=null): UserException {
+    if ($kind !== null) $pronom = "il";
+    else $pronom = self::word()->pronom();
+    $allowedValues = cl::withn($allowedValues);
+    if (!$allowedValues) $reason = null;
+    else $reason = "$pronom doit faire partie de cette liste: ";
+    $reason .= implode(", ", $allowedValues);
+    return self::invalid_value($value, $kind, $reason, $previous);
+  }
+
+  static function out_of_range($value, ?string $kind=null, ?int $min=null, ?int $max=null, ?Throwable $previous=null): UserException {
+    if ($kind !== null) {
+      $pronom = "il";
+      $compris = "compris";
+      $superieur = "supérieur";
+      $inferieur = "inférieur";
+    } else {
+      $word = self::word();
+      $pronom = $word->pronom();
+      $compris = $word->isFeminin()? "comprise": "compris";
+      $superieur = $word->isFeminin()? "supérieure": "supérieur";
+      $inferieur = $word->isFeminin()? "inférieure": "inférieur";
+    }
+    if ($min !== null && $max !== null) {
+      $reason = "$pronom doit être $compris entre $min et $max";
+    } else if ($min !== null) {
+      $reason = "$pronom doit être $superieur à $min";
+    } elseif ($max !== null) {
+      $reason = "$pronom doit être $inferieur à $max";
+    } else {
+      $reason = null;
+    }
+    return self::invalid_value($value, $kind, $reason, $previous);
+  }
+
+  static function null_value(?string $kind=null, ?string $reason=null, ?Throwable $previous=null): UserException {
+    if ($kind !== null) $nul = "null";
+    else $nul = self::word()->isFeminin()? "nulle": "nul";
+    return self::generic(null, $kind, "ne doit pas être $nul", $reason, $previous);
+  }
+
+  static function missing_value_message(?int $amount=null, ?string $kind=null): string {
+    $message = "il manque ";
+    if ($kind !== null) {
+      if ($amount !== null) $message = "$amount $kind";
+      else $message = $kind;
+    } else {
+      if ($amount !== null) $message .= self::word()->q($amount);
+      else $message .= self::word()->_un();
+    }
+    return $message;
+  }
+
+  /**
+   * indiquer qu'une valeur est manquante
+   */
+  static function missing_value(?int $amount=null, ?string $kind=null, ?string $reason=null, ?Throwable $previous=null): UserException {
+    $reason ??= self::missing_value_message($amount, $kind);
+    $class = static::EXCEPTION;
+    return new $class($reason, null, $previous);
+  }
+
+  static function unexpected_value_message(?int $amount=null, ?string $kind=null): string {
+    if ($amount !== null) {
+      if ($kind !== null) $kind = "$amount $kind";
+      else $kind = self::word()->q($amount);
+      $message = "il y a $kind en trop";
+    } else {
+      if ($kind !== null) $kind = "de $kind";
+      else $kind = self::word()->_de(2);
+      $message = "il y a trop $kind";
+    }
+    return $message;
+  }
+
+  /**
+   * indiquer qu'une valeur est en trop
+   */
+  static function unexpected_value(?int $amount=null, ?string $kind=null, ?string $reason=null, ?Throwable $previous=null): UserException {
+    $reason ??= self::unexpected_value_message($amount, $kind);
+    $class = static::EXCEPTION;
+    return new $class($reason, null, $previous);
+  }
+}
diff --git a/php/src/file.php b/php/src/file.php
index a8175d4..1045293 100644
--- a/php/src/file.php
+++ b/php/src/file.php
@@ -57,9 +57,9 @@ class file {
     }
     return $file;
   }
-  
-  static function writer($output, ?string $mode="w+b", ?callable $func=null): FileWriter {
-    $file = new FileWriter(self::fix_dash($output), $mode);
+
+  static function writer($output, ?callable $func=null): FileWriter {
+    $file = new FileWriter(self::fix_dash($output), "w+b");
     if ($func !== null) {
       try {
         $func($file);
diff --git a/php/src/file/SharedFile.php b/php/src/file/SharedFile.php
index c14001e..70456e5 100644
--- a/php/src/file/SharedFile.php
+++ b/php/src/file/SharedFile.php
@@ -1,7 +1,7 @@
 fd = $fd;
     $this->close = $close;
     $this->throwOnError = $throwOnError ?? static::THROW_ON_ERROR;
diff --git a/php/src/file/csv/CsvReader.php b/php/src/file/csv/CsvReader.php
index 317fda0..3dbdd39 100644
--- a/php/src/file/csv/CsvReader.php
+++ b/php/src/file/csv/CsvReader.php
@@ -2,7 +2,6 @@
 namespace nulib\file\csv;
 
 use nulib\file;
-use nulib\file\_IFile;
 use nulib\file\FileReader;
 use nulib\file\IReader;
 use nulib\file\tab\AbstractReader;
diff --git a/php/src/file/csv/csv_flavours.php b/php/src/file/csv/csv_flavours.php
index 4bc7bd9..bb00150 100644
--- a/php/src/file/csv/csv_flavours.php
+++ b/php/src/file/csv/csv_flavours.php
@@ -2,7 +2,7 @@
 namespace nulib\file\csv;
 
 use nulib\cl;
-use nulib\ref\file\csv\ref_csv;
+use nulib\ref\ref_csv;
 use nulib\str;
 
 class csv_flavours {
@@ -17,13 +17,13 @@ class csv_flavours {
     "dumb," => ref_csv::DUMB_OO_FLAVOUR,
     "dumb" => ref_csv::DUMB_FLAVOUR,
   ];
-  
+
   const ENCODINGS = [
     ref_csv::OO_FLAVOUR => ref_csv::OO_ENCODING,
     ref_csv::XL_FLAVOUR => ref_csv::XL_ENCODING,
     ref_csv::DUMB_FLAVOUR => ref_csv::DUMB_ENCODING,
   ];
-  
+
   static final function verifix(?string $flavour): ?string {
     if ($flavour === null) return null;
     $lflavour = strtolower($flavour);
@@ -41,7 +41,7 @@ class csv_flavours {
     elseif ($flavour == ref_csv::XL_FLAVOUR) return ref_csv::MSEXCEL;
     else return $flavour;
   }
-  
+
   static final function get_params(string $flavour): array {
     return [$flavour[0], $flavour[1], $flavour[2]];
   }
diff --git a/php/src/file/tab/TAbstractBuilder.php b/php/src/file/tab/TAbstractBuilder.php
index f12e1e0..f43e604 100644
--- a/php/src/file/tab/TAbstractBuilder.php
+++ b/php/src/file/tab/TAbstractBuilder.php
@@ -2,10 +2,10 @@
 namespace nulib\file\tab;
 
 use nulib\cl;
+use nulib\exceptions;
 use nulib\file\csv\CsvBuilder;
 use nulib\file\web\Upload;
 use nulib\os\path;
-use nulib\ValueException;
 
 trait TAbstractBuilder {
   /** @param Upload|string|array $builder */
@@ -32,7 +32,7 @@ trait TAbstractBuilder {
     } elseif (is_array($builder)) {
       $params = cl::merge($builder, $params);
     } elseif ($builder !== null) {
-      throw ValueException::invalid_type($builder, self::class);
+      throw exceptions::invalid_type($builder, "builder", self::class);
     }
 
     $output = $params["output"] ?? null;
diff --git a/php/src/file/tab/TAbstractReader.php b/php/src/file/tab/TAbstractReader.php
index f6037c7..23ab697 100644
--- a/php/src/file/tab/TAbstractReader.php
+++ b/php/src/file/tab/TAbstractReader.php
@@ -2,10 +2,10 @@
 namespace nulib\file\tab;
 
 use nulib\cl;
+use nulib\exceptions;
 use nulib\file\csv\CsvReader;
 use nulib\file\web\Upload;
 use nulib\os\path;
-use nulib\ValueException;
 
 trait TAbstractReader {
   /** @param Upload|string|array $reader */
@@ -31,7 +31,7 @@ trait TAbstractReader {
     } elseif (is_array($reader)) {
       $params = cl::merge($reader, $params);
     } elseif ($reader !== null) {
-      throw ValueException::invalid_type($reader, self::class);
+      throw exceptions::invalid_type($reader, "reader", self::class);
     }
 
     $input = $params["input"] ?? null;
diff --git a/php/src/mail/MailTemplate.php b/php/src/mail/MailTemplate.php
new file mode 100644
index 0000000..59e3bae
--- /dev/null
+++ b/php/src/mail/MailTemplate.php
@@ -0,0 +1,119 @@
+ "string",
+    "body" => "string",
+    "exprs" => "array",
+  ];
+
+  function __construct(array $mail) {
+    $tsubject = $mail["subject"] ?? null;
+    $tbody = $mail["body"] ?? null;
+    $texprs = $mail["exprs"] ?? [];
+
+    $this->el = new ExpressionLanguage();
+    $this->subject = cv::not_null($tsubject, "subject");
+    $this->body = cv::not_null($tbody, "body");
+    $exprs = [];
+    # Commencer par extraire les expressions de la forme {name}
+    if (preg_match_all('/\{([a-zA-Z_][a-zA-Z0-9_.-]*)}/', $this->body, $mss, PREG_SET_ORDER)) {
+      foreach ($mss as $ms) {
+        $key = $ms[0];
+        $expr = str_replace("'", "\\'", $ms[1]);
+        $expr = "_helper.value('$expr')";
+        $exprs[$key] = $expr;
+      }
+    }
+    $index = 0;
+    foreach ($texprs as $key => $expr) {
+      $prefix = null;
+      $orig = $expr;
+      if (preg_match('/^\[([^]]*)]/', $expr, $ms)) {
+        # un préfixe spécifié de la forme [prefix]expr permet de reconnaitre les
+        # formes spéciales de expr (+, *, .) qui sont précédées de prefix
+        # exemple: [https://]+app.url permettra d'utiliser un texte markdown
+        # de la forme  qui est correctement reconnu comme un
+        # url
+        $prefix = $ms[1];
+        $expr = substr($expr, strlen($ms[0]));
+      }
+      $mapKey = false;
+      if (str::del_prefix($expr, "+")) {
+        # config
+        $mapKey = "$prefix+$expr";
+        $expr = str_replace("'", "\\'", $expr);
+        $expr = "_helper.config('$expr')";
+      } elseif (str::del_prefix($expr, "*")) {
+        # session
+        $mapKey = "$prefix*$expr";
+        $expr = str_replace("'", "\\'", $expr);
+        $expr = "_helper.session('$expr')";
+      } elseif (str::del_prefix($expr, ".")) {
+        # session
+        $mapKey = "$prefix.$expr";
+        $expr = str_replace("'", "\\'", $expr);
+        $expr = "_helper.value('$expr')";
+      } elseif ($prefix !== null) {
+        # sinon remettre le préfixe
+        $expr = $orig;
+      }
+
+      if ($key === $index) {
+        $index++;
+        if ($mapKey !== false) {
+          $exprs[$mapKey] = $expr;
+        } else {
+          # clé normale: la correspondance est en minuscule
+          $exprs[$expr] = strtolower($expr);
+        }
+      } else {
+        $exprs[$key] = $expr;
+      }
+    }
+    uksort($exprs, function ($a, $b) {
+      return -cv::complen($a, $b);
+    });
+    $this->exprs = $exprs;
+  }
+
+  /** @var ExpressionLanguage */
+  protected $el;
+
+  protected $subject;
+
+  protected $body;
+
+  protected $exprs;
+
+  protected function _eval(string $template, ?array $data): string {
+    if ($data === null) return $template;
+    $el = $this->el;
+    foreach ($this->exprs as $key => $expr) {
+      $value = $el->evaluate($expr, $data);
+      if (is_array($value)) $value = str::join(" ", $value);
+      elseif (!is_string($value)) $value = strval($value);
+      $template = str_replace($key, $value, $template);
+    }
+    return $template;
+  }
+
+  function eval(?array $data, $convertMd=true): array {
+    if ($data !== null) {
+      $data["_helper"] = new MailTemplateHelper($data);
+    }
+    $subject = $this->_eval($this->subject, $data);
+    $body = $this->body;
+    if ($convertMd) $body = mdc::convert($body);
+    $body = $this->_eval($body, $data);
+    return [
+      "subject" => $subject,
+      "body" => $body,
+    ];
+  }
+}
diff --git a/php/src/mail/MailTemplateHelper.php b/php/src/mail/MailTemplateHelper.php
new file mode 100644
index 0000000..37d68d9
--- /dev/null
+++ b/php/src/mail/MailTemplateHelper.php
@@ -0,0 +1,24 @@
+data = $data;
+  }
+
+  function value(string $pkey) {
+    return cl::pget($this->data, $pkey);
+  }
+
+  function config(string $pkey) {
+    return config::get($pkey);
+  }
+
+  function session(string $pkey) {
+    return session::pget($pkey);
+  }
+}
diff --git a/php/src/mail/MailerException.php b/php/src/mail/MailerException.php
new file mode 100644
index 0000000..79e9261
--- /dev/null
+++ b/php/src/mail/MailerException.php
@@ -0,0 +1,7 @@
+ ["string", "smtp"],
+    "debug" => ["int", SMTP::DEBUG_OFF],
+    "host" => ["?string", "smtp.univ.run"],
+    "port" => ["?int", 25],
+    "auth" => "?bool",
+    "username" => "?string",
+    "password" => "?string",
+    "secure" => "?string",
+  ];
+
+  static function resolve_params(?array $params=null): array {
+    $envParams = [
+      "backend" => cv::vn(getenv("NULIB_MAIL_BACKEND")),
+      "debug" => cv::vn(getenv("NULIB_MAIL_DEBUG")),
+      "host" => cv::vn(getenv("NULIB_MAIL_HOST")),
+      "port" => cv::vn(getenv("NULIB_MAIL_PORT")),
+      "auth" => cv::vn(getenv("NULIB_MAIL_AUTH")),
+      "username" => cv::vn(getenv("NULIB_MAIL_USERNAME")),
+      "password" => cv::vn(getenv("NULIB_MAIL_PASSWORD")),
+      "secure" => cv::vn(getenv("NULIB_MAIL_SECURE")),
+    ];
+    $configParams = config::k("mailer");
+    foreach (array_keys(self::SCHEMA) as $key) {
+      $params[$key] ??= $envParams[$key] ?? null;
+      $params[$key] ??= $configParams[$key] ?? null;
+    }
+    return $params;
+  }
+
+  static function get(?array $params=null, ?bool $exceptions=null): PHPMailer {
+    $params = self::resolve_params($params);
+
+    $mailer = new PHPMailer($exceptions);
+    $mailer->setLanguage("fr");
+    $mailer->CharSet = PHPMailer::CHARSET_UTF8;
+    # backend
+    $backend = $params["backend"] ?? "smtp";
+    switch ($backend) {
+    case "smtp":
+      # host, port
+      $host = $params["host"] ?? null;
+      $port = $params["port"] ?? 25;
+      if ($host === null) {
+        throw exceptions::null_value("host");
+      }
+      msg::debug("new PHPMailer using SMTP to $host:$port");
+      $mailer->isSMTP();
+      $mailer->Host = $host;
+      $mailer->Port = $port;
+      break;
+    case "phpmail":
+      msg::debug("new PHPMailer using PHPmail");
+      $mailer->isMail();
+      break;
+    case "sendmail":
+      msg::debug("new PHPMailer using sendmail");
+      $mailer->isSendmail();
+      break;
+    default:
+      throw exceptions::forbidden_value($backend, "backend", ["smtp", "phpmail", "sendmail"]);
+    }
+    # debug
+    $debug = $params["debug"] ?? SMTP::DEBUG_OFF;
+    if (is_int($debug)) {
+      if ($debug < SMTP::DEBUG_OFF) $debug = SMTP::DEBUG_OFF;
+      elseif ($debug > SMTP::DEBUG_LOWLEVEL) $debug = SMTP::DEBUG_LOWLEVEL;
+    } elseif (!self::is_bool($debug)) {
+      throw exceptions::invalid_type($debug, "debug", ["int", "bool"]);
+    }
+    $mailer->SMTPDebug = $debug;
+    # auth, username, password
+    $username = $params["username"] ?? null;
+    $username ??= cv::vn(getenv("NULIB_MAIL_USERNAME"));
+    $password = $params["password"] ?? null;
+    $password ??= cv::vn(getenv("NULIB_MAIL_PASSWORD"));
+    $auth = $params["auth"] ?? null;
+    $auth ??= cv::vn(getenv("NULIB_MAIL_AUTH"));
+    $auth ??= $username !== null && $password !== null;
+    $mailer->SMTPAuth = self::get_bool($auth);
+    $mailer->Username = $username;
+    $mailer->Password = $password;
+    # secure
+    $secure = $params["secure"] ?? null;
+    $secure ??= cv::vn(getenv("NULIB_MAIL_SECURE"));
+    $secure ??= false;
+    if (self::is_bool($secure)) {
+      if (!$secure) {
+        $mailer->SMTPSecure = "";
+        $mailer->SMTPAutoTLS = false;
+      }
+    } else {
+      switch ($secure) {
+      case PHPMailer::ENCRYPTION_SMTPS:
+      case PHPMailer::ENCRYPTION_STARTTLS:
+        $mailer->SMTPSecure = $secure;
+        break;
+      default:
+        throw exceptions::forbidden_value($secure, "secure", [
+          PHPMailer::ENCRYPTION_SMTPS,
+          PHPMailer::ENCRYPTION_STARTTLS,
+        ]);
+      }
+    }
+
+    return $mailer;
+  }
+
+  static function build($to, string $subject, string $body, $cc=null, $bcc=null, ?string $from=null, ?PHPMailer $mailer=null): PHPMailer {
+    if ($mailer === null) $mailer = self::get();
+    $mailer->clearAllRecipients();
+
+    if ($from === null) $from = static::FROM;
+    $mailer->setFrom($from);
+    foreach (cl::with($to) as $tos) {
+      foreach (preg_split('/\s*[,;]\s*/', trim($tos)) as $to) {
+        $mailer->addAddress($to);
+      }
+    }
+    foreach (cl::with($cc) as $ccs) {
+      foreach (preg_split('/\s*[,;]\s*/', trim($ccs)) as $cc) {
+        $mailer->addCC($cc);
+      }
+    }
+    foreach (cl::with($bcc) as $bccs) {
+      foreach (preg_split('/\s*[,;]\s*/', trim($bccs)) as $bcc) {
+        $mailer->addBCC($bcc);
+      }
+    }
+    $mailer->isHTML();
+    $mailer->Subject = $subject;
+    $mailer->Body = $body;
+    return $mailer;
+  }
+
+  static function _send(PHPMailer $mailer): void {
+    $tos = [];
+    foreach ($mailer->getToAddresses() as $to) {
+      $tos[] = $to[0];
+    }
+    $tos = str::join(",", $tos);
+    msg::debug("Sending to $tos");
+    if (!$mailer->send()) {
+      throw new MailerException("erreur d'envoi du mail", $mailer->ErrorInfo);
+    }
+  }
+
+  static function send($to, string $subject, string $body, $cc=null, $bcc=null, ?string $from=null, ?PHPMailer $mailer=null): void {
+    self::_send(self::build($to, $subject, $body, $cc, $bcc, $from, $mailer));
+  }
+
+  static function tsend(array $template, array $data, $to, $cc=null, $bcc=null, ?string $from=null): void {
+    $template = new MailTemplate($template);
+    $mail = $template->eval($data);
+    self::send($to, $mail["subject"], $mail["body"], $cc, $bcc, $from);
+  }
+}
diff --git a/php/src/mail/mdc.php b/php/src/mail/mdc.php
new file mode 100644
index 0000000..204264e
--- /dev/null
+++ b/php/src/mail/mdc.php
@@ -0,0 +1,22 @@
+ false,
+      ]);
+    }
+    return self::$mdc;
+  }
+
+  static function convert(string $text): string {
+    return self::mdc()->convert($text);
+  }
+}
diff --git a/php/src/os/sh.php b/php/src/os/sh.php
index a18f012..802ca36 100644
--- a/php/src/os/sh.php
+++ b/php/src/os/sh.php
@@ -1,7 +1,7 @@
  string|list]
+    ne pas faire cette transformation si le tableau est associatif
+  * un trait Tlogger permet de spécifier le cas échéant comment mettre en forme
+    une donnée structurée --> il permet de calculer les valeurs de user_message
+    et tech_message
+
 -*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary
\ No newline at end of file
diff --git a/php/src/output/_TMessenger.php b/php/src/output/_TMessenger.php
new file mode 100644
index 0000000..912dc0a
--- /dev/null
+++ b/php/src/output/_TMessenger.php
@@ -0,0 +1,67 @@
+addMessenger($msg);
+    } else {
+      self::$msg = new ProxyMessenger(self::$msg);
+      self::$msg->addMessenger($msg);
+    }
+    return $msg;
+  }
+
+  static function get(): IMessenger {
+    return self::$msg ??= new NullMessenger();
+  }
+
+  static function set_verbosity(string $verbosity): void {
+    $msg = self::get();
+    switch ($verbosity) {
+    case "Q":
+    case "silent":
+      $msg->resetParams([
+        "min_level" => self::NONE,
+      ]);
+      break;
+    case "q":
+    case "quiet":
+      $msg->resetParams([
+        "min_level" => self::MAJOR,
+      ]);
+      break;
+    case "n":
+    case "normal":
+      $msg->resetParams([
+        "min_level" => self::NORMAL,
+      ]);
+      break;
+    case "v":
+    case "verbose":
+      $msg->resetParams([
+        "min_level" => self::MINOR,
+      ]);
+      break;
+    case "D":
+    case "debug":
+      app::set_debug();
+      $msg->resetParams([
+        "min_level" => self::DEBUG,
+      ]);
+      break;
+    default:
+      throw exceptions::forbidden_value($verbosity, "verbosity", ["silent", "quiet", "normal", "verbose", "debug"]);
+    }
+  }
+}
diff --git a/php/src/output/_messenger.php b/php/src/output/_messenger.php
index 1226c24..bf5d4ca 100644
--- a/php/src/output/_messenger.php
+++ b/php/src/output/_messenger.php
@@ -1,44 +1,16 @@
 clone($params);
   }
 
-  static final function __callStatic($name, $args) {
-    $name = str::us2camel($name);
-    call_user_func_array([static::get(), $name], $args);
-  }
-
   #############################################################################
 
   const DEBUG = IMessenger::DEBUG;
diff --git a/php/src/output/con.php b/php/src/output/con.php
new file mode 100644
index 0000000..6937328
--- /dev/null
+++ b/php/src/output/con.php
@@ -0,0 +1,25 @@
+resetParams([
+      "color" => $color,
+    ]);
+  }
+}
diff --git a/php/src/output/console.php b/php/src/output/console.php
deleted file mode 100644
index 0ef8c81..0000000
--- a/php/src/output/console.php
+++ /dev/null
@@ -1,28 +0,0 @@
-isEmpty()) {
+      $msg->addMessenger(new LogMessenger([
+        "min_level" => msg::MINOR,
+      ]));
+    }
+    return $msg;
+  }
+
+  static function set_output(string $logfile): void {
+    self::ensure_log()->resetParams([
+      "output" => $logfile,
+    ]);
   }
 }
diff --git a/php/src/output/msg.php b/php/src/output/msg.php
index d180d18..9bd4eee 100644
--- a/php/src/output/msg.php
+++ b/php/src/output/msg.php
@@ -1,67 +1,18 @@
 resetParams($params);
-    return self::$out;
-  }
-
-  static function write(...$values): void { self::$out->write(...$values); }
-  static function print(...$values): void { self::$out->print(...$values); }
-
-  static function iwrite(int $indentLevel, ...$values): void { self::$out->iwrite($indentLevel, ...$values); }
-  static function iprint(int $indentLevel, ...$values): void { self::$out->iprint($indentLevel, ...$values); }
-}
-out::reset();
diff --git a/php/src/output/say.php b/php/src/output/say.php
index 9d8b6d0..f5d5583 100644
--- a/php/src/output/say.php
+++ b/php/src/output/say.php
@@ -1,28 +1,17 @@
  $max_level) {
+      throw new Exception("$level: level not allowed here");
+    }
+    return $level;
+  }
+
+  /** @var StdOutput la sortie standard */
+  protected StdOutput $out;
+
+  /** @var int level par défaut dans lequel les messages sont affichés */
+  protected int $defaultLevel;
+
+  /** @var int level minimum que doivent avoir les messages pour être affichés */
+  protected int $minLevel;
+
+  /** @var bool faut-il ajouter la date à chaque ligne? */
+  protected bool $addDate;
+
+  /** @var string format de la date */
+  protected string $dateFormat;
+
+  /** @var bool faut-il afficher les ids (p=id t=id a=id) */
+  protected bool $showIds;
+
+  /** @var ?string identifiant de ce messenger, à ajouter à chaque ligne */
+  protected ?string $id;
+
+  protected int $lastTitleId = 1;
+
+  protected abstract function title__getId(): ?int;
+
+  protected int $lastActionId = 1;
+
+  protected abstract function action__getId(): ?int;
+
+  protected function getLinePrefix(): ?string {
+    $linePrefix = null;
+    if ($this->addDate) {
+      $date = date_create()->format($this->dateFormat);
+      $linePrefix .= "$date ";
+    }
+    if ($this->showIds) {
+      if ($this->id !== null) $linePrefix .= "p=$this->id ";
+      $titleId = $this->title__getId();
+      if ($titleId !== null) $linePrefix .= "t=$titleId ";
+      $actionId = $this->action__getId();
+      if ($actionId !== null) $linePrefix .= "a=$actionId ";
+    }
+    return $linePrefix;
+  }
+
+  protected function decrLevel(int $level, int $amount=-1): int {
+    $level += $amount;
+    if ($level < self::MIN_LEVEL) $level = self::MIN_LEVEL;
+    return $level;
+  }
+
+  protected function checkLevel(?int &$level): bool {
+    if ($level === null) $level = $this->defaultLevel;
+    elseif ($level < 0) $level = $this->decrLevel($this->defaultLevel, $level);
+    return $level >= $this->minLevel;
+  }
+
+  protected function _printTitle(
+    int $level, string $type, ?string $linePrefix, int $indentLevel,
+    StdOutput $out, $content
+  ): void {
+    $prefixes = self::GENERIC_PREFIXES[$level][$type];
+    if ($prefixes[0]) $out->print();
+    $content = cl::with($content);
+    if ($out->isColor()) {
+      $before = $prefixes[2];
+      $prefix = $prefixes[3];
+      $prefix2 = $prefix !== null? "$prefix ": null;
+      $suffix = $prefixes[4];
+      $suffix2 = $suffix !== null? " $suffix": null;
+      $after = $prefixes[5];
+
+      $lines = $out->getLines(false, ...$content);
+      $maxlen = 0;
+      foreach ($lines as &$content) {
+        $line = $out->filterColors($content);
+        $len = mb_strlen($line);
+        if ($len > $maxlen) $maxlen = $len;
+        $content = [$content, $len];
+      }; unset($content);
+      if ($before !== null) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $out->iprint($indentLevel, $prefix, substr($before, 1), str_repeat($before[0], $maxlen), $suffix);
+      }
+      foreach ($lines as [$content, $len]) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $padding = $len < $maxlen? str_repeat(" ", $maxlen - $len): null;
+        $out->iprint($indentLevel, $prefix2, $content, $padding, $suffix2);
+      }
+      if ($after !== null) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $out->iprint($indentLevel, $prefix, substr($after, 1), str_repeat($after[0], $maxlen), $suffix);
+      }
+    } else {
+      $prefix = $prefixes[1];
+      if ($prefix !== null) $prefix .= " ";
+      $prefix2 = str_repeat(" ", mb_strlen($prefix));
+      $lines = $out->getLines(false, ...$content);
+      foreach ($lines as $content) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $out->iprint($indentLevel, $prefix, $content);
+        $prefix = $prefix2;
+      }
+    }
+  }
+
+  protected abstract function action__flush(bool $endAction=false, ?int $overrideLevel=null): void;
+
+  protected function _printAction(
+    int $level, ?string $linePrefix, int $indentLevel,
+    StdOutput $out,
+    bool $printContent, $content,
+    bool $printResult, ?bool $rsuccess, $rcontent
+  ): void {
+    $color = $out->isColor();
+    if ($rsuccess === true) $type = "success";
+    elseif ($rsuccess === false) $type = "failure";
+    else $type = "done";
+    $rprefixes = self::RESULT_PREFIXES[$type];
+    if ($color) {
+      $rprefix = $rprefixes[1];
+      $rprefix2 = null;
+      if ($rprefix !== null) {
+        $rprefix .= " ";
+        $rprefix2 = $out->filterColors($out->filterContent($rprefix));
+        $rprefix2 = str_repeat(" ", mb_strlen($rprefix2));
+      }
+    } else {
+      $rprefix = $rprefixes[0];
+      if ($rprefix !== null) $rprefix .= " ";
+      $rprefix2 = str_repeat(" ", mb_strlen($rprefix));
+    }
+    if ($printContent && $printResult) {
+      A::ensure_array($content);
+      if ($rcontent) {
+        $content[] = ": ";
+        $content[] = $rcontent;
+      }
+      $lines = $out->getLines(false, ...$content);
+      foreach ($lines as $content) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $out->iprint($indentLevel, $rprefix, $content);
+        $rprefix = $rprefix2;
+      }
+    } elseif ($printContent) {
+      $prefixes = self::GENERIC_PREFIXES[$level]["step"];
+      if ($color) {
+        $prefix = $prefixes[1];
+        if ($prefix !== null) $prefix .= " ";
+        $prefix2 = $out->filterColors($out->filterContent($prefix));
+        $prefix2 = str_repeat(" ", mb_strlen($prefix2));
+        $suffix = $prefixes[2];
+      } else {
+        $prefix = $prefixes[0];
+        if ($prefix !== null) $prefix .= " ";
+        $prefix2 = str_repeat(" ", mb_strlen($prefix));
+        $suffix = null;
+      }
+      A::ensure_array($content);
+      $content[] = ":";
+      $lines = $out->getLines(false, ...$content);
+      foreach ($lines as $content) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $out->iprint($indentLevel, $prefix, $content, $suffix);
+        $prefix = $prefix2;
+      }
+    } elseif ($printResult) {
+      if (!$rcontent) {
+        if ($type === "success") $rcontent = $color? "succès": "";
+        elseif ($type === "failure") $rcontent = $color? "échec": "";
+        elseif ($type === "done") $rcontent = "fait";
+      }
+      $rprefix = " $rprefix";
+      $rprefix2 = " $rprefix2";
+      $lines = $out->getLines(false, $rcontent);
+      foreach ($lines as $rcontent) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $out->iprint($indentLevel, $rprefix, $rcontent);
+        $rprefix = $rprefix2;
+      }
+    }
+  }
+
+  protected function _printGeneric(
+    int $level, string $type, ?string $linePrefix, int $indentLevel,
+    StdOutput $out, $content
+  ): void {
+    $prefixes = self::GENERIC_PREFIXES[$level][$type];
+    $content = cl::with($content);
+    if ($out->isColor()) {
+      $prefix = $prefixes[1];
+      $prefix2 = null;
+      if ($prefix !== null) {
+        $prefix .= " ";
+        $prefix2 = $out->filterColors($out->filterContent($prefix));
+        $prefix2 = str_repeat(" ", mb_strlen($prefix2));
+      }
+      $suffix = $prefixes[2];
+      $lines = $out->getLines(false, ...$content);
+      foreach ($lines as $content) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $out->iprint($indentLevel, $prefix, $content, $suffix);
+        $prefix = $prefix2;
+      }
+    } else {
+      $prefix = $prefixes[0];
+      if ($prefix !== null) $prefix .= " ";
+      $prefix2 = str_repeat(" ", mb_strlen($prefix));
+      $lines = $out->getLines(false, ...$content);
+      foreach ($lines as $content) {
+        if ($linePrefix !== null) $out->write($linePrefix);
+        $out->iprint($indentLevel, $prefix, $content);
+        $prefix = $prefix2;
+      }
+    }
+  }
+
+  protected function _printGenericOrException(
+    ?int $level, string $type, int $indentLevel,
+    StdOutput $out, $content
+  ): void {
+    $linePrefix = $this->getLinePrefix();
+    # si $content contient des exceptions, les afficher avec un level moindre
+    $exceptions = null;
+    if (is_array($content)) {
+      $valueContent = null;
+      foreach ($content as $value) {
+        if ($value instanceof Throwable || $value instanceof ExceptionShadow) {
+          $exceptions[] = $value;
+        } else {
+          $valueContent[] = $value;
+        }
+      }
+      if ($valueContent === null) $content = null;
+      elseif (count($valueContent) == 1) $content = $valueContent[0];
+      else $content = $valueContent;
+    } elseif ($content instanceof Throwable || $content instanceof ExceptionShadow) {
+      $exceptions[] = $content;
+      $content = null;
+    }
+
+    $flushActions = true;
+    $showContent = $this->checkLevel($level);
+    if ($content !== null && $showContent) {
+      $this->action__flush(); $flushActions = false;
+      $this->_printGeneric($level, $type, $linePrefix, $indentLevel, $out, $content);
+    }
+    if ($exceptions !== null) {
+      $level1 = $this->decrLevel($level);
+      $showTraceback = $this->checkLevel($level1);
+      foreach ($exceptions as $exception) {
+        # tout d'abord message
+        $message = exceptions::get_message($exception);
+        if ($showContent) {
+          if ($flushActions) { $this->action__flush(); $flushActions = false; }
+          $this->_printGeneric($level, $type, $linePrefix, $indentLevel, $out, $message);
+        }
+        # puis summary et traceback
+        if ($showTraceback) {
+          if ($flushActions) { $this->action__flush(); $flushActions = false; }
+          $summary = exceptions::get_summary($exception, false);
+          $this->_printGeneric($level1, $type, $linePrefix, $indentLevel, $out, $summary);
+          $traceback = exceptions::get_traceback($exception);
+          $this->_printGeneric($level1, $type, $linePrefix, $indentLevel, $out, $traceback);
+        }
+      }
+    }
+  }
+}
diff --git a/php/src/output/std/ConsoleMessenger.php b/php/src/output/std/ConsoleMessenger.php
new file mode 100644
index 0000000..1cb8bcf
--- /dev/null
+++ b/php/src/output/std/ConsoleMessenger.php
@@ -0,0 +1,473 @@
+ $color,
+      "indent" => $indent,
+    ];
+    if ($output !== null) {
+      $this->err = $this->out = new StdOutput($output, $params);
+    } else {
+      $this->out = new StdOutput(STDOUT, $params);
+      $this->err = new StdOutput(STDERR, $params);
+    }
+    $this->defaultLevel = $defaultLevel;
+    $this->minLevel = $minLevel;
+    $this->addDate = $addDate;
+    $this->dateFormat = $dateFormat;
+    $this->id = $id;
+    $this->showIds = $showIds;
+    $this->inSection = false;
+    $this->section = null;
+    $this->titles = [];
+    $this->actions = [];
+  }
+
+  function resetParams(?array $params=null): void {
+    $output = $params["output"] ?? null;
+    $color = $params["color"] ?? null;
+    $indent = $params["indent"] ?? null;
+
+    $defaultLevel = $params["default_level"] ?? null;
+    if ($defaultLevel !== null) $defaultLevel = self::verifix_level($defaultLevel);
+
+    $debug = $params["debug"] ?? null;
+    $minLevel = $params["min_level"] ?? null;
+    if ($debug !== null) $minLevel ??= self::DEBUG;
+    $minLevel ??= $params["verbosity"] ?? null; # alias
+    if ($minLevel !== null) $minLevel = self::verifix_level($minLevel, self::NONE);
+
+    $addDate = $params["add_date"] ?? null;
+    $dateFormat = $params["date_format"] ?? null;
+    $id = $params["id"] ?? null;
+
+    $params = [
+      "output" => $output,
+      "color" => $color,
+      "indent" => $indent,
+    ];
+    if ($this->out === $this->err) {
+      $this->out->resetParams($params);
+    } else {
+      # NB: si initialement [output] était null, et qu'on spécifie une valeur
+      # [output], alors les deux instances $out et $err sont mis à jour
+      # séparément avec la même valeur de output
+      # de plus, on ne peut plus revenir à la situation initiale avec une
+      # destination différente pour $out et $err
+      $this->out->resetParams($params);
+      $this->err->resetParams($params);
+    }
+    if ($defaultLevel !== null) $this->defaultLevel = $defaultLevel;
+    if ($minLevel !== null) $this->minLevel = $minLevel;
+    if ($addDate !== null) $this->addDate = boolval($addDate);
+    if ($dateFormat !== null) $this->dateFormat = $dateFormat;
+    if ($id !== null) $this->id = $id;
+  }
+
+  function clone(?array $params=null): IMessenger {
+    $clone = clone $this;
+    if ($params !== null) $clone->resetParams($params);
+    #XXX faut-il marquer la section et les titres du clone à "print" => false?
+    # ou en faire des références au parent?
+    # dans tous les cas, on considère qu'il n'y a pas d'actions en cours, et on
+    # ne doit pas dépiler avec end() plus que l'état que l'on a eu lors du clone
+    return $clone;
+  }
+
+  /** @var StdOutput la sortie d'erreur */
+  protected StdOutput $err;
+
+  /** @var bool est-on dans une section? */
+  protected bool $inSection;
+
+  /** @var array section qui est en attente d'affichage */
+  protected ?array $section;
+
+  protected function section__end(): void {
+    while ($this->actions) $this->adone();
+    while ($this->titles) $this->title__end();
+    $this->inSection = false;
+    $this->section = null;
+  }
+
+  function section__afterFunc(): void {
+    $this->section__end();
+  }
+
+  function section($content, ?callable $func=null, ?int $level=null): void {
+    $this->section__end();
+    $this->inSection = true;
+    if (!$this->checkLevel($level)) return;
+    $this->section = [
+      "msg_level" => $level,
+      "line_prefix" => $this->getLinePrefix(),
+      "content" => $content,
+      "print_content" => true,
+    ];
+    if ($func !== null) {
+      try {
+        $func($this);
+      } finally {
+        $this->section__afterFunc();
+      }
+    }
+  }
+
+  protected function printSection() {
+    $section =& $this->section;
+    if ($section !== null && $section["print_content"]) {
+      $this->_printTitle(
+        $section["msg_level"], "section", $section["line_prefix"], 0,
+        $this->err, $section["content"]);
+      $section["print_content"] = false;
+    }
+  }
+
+  protected function getIndentLevel(bool $withActions=true): int {
+    $indentLevel = count($this->titles) - 1;
+    if ($indentLevel < 0) $indentLevel = 0;
+    if ($withActions) {
+      foreach ($this->actions as $action) {
+        if ($action["msg_level"] < $this->minLevel) continue;
+        $indentLevel++;
+      }
+    }
+    return $indentLevel;
+  }
+
+  protected array $titles;
+
+  protected function title__last(): ?array {
+    $last = end($this->titles);
+    return $last !== false? $last: null;
+  }
+
+  function title__getMarks(): array {
+    return [count($this->titles)];
+  }
+
+  protected function title__getId(): ?int {
+    return $this->title__last()["id"] ?? null;
+  }
+
+  protected function title__end(?int $until=null): void {
+    $title = $this->title__last();
+    if ($title !== null) {
+      $until ??= $title["max_title_level"];
+      $until ??= $this->title__getMarks()[0] - 1;
+      while (count($this->titles) > $until) {
+        array_pop($this->titles);
+      }
+    }
+  }
+
+  protected function title__flush(): void {
+    $this->printSection();
+    $err = $this->err;
+    $indentLevel = 0;
+    foreach ($this->titles as &$title) {
+      if ($title["print_content"]) {
+        $this->_printTitle(
+          $title["msg_level"], "title", $title["line_prefix"], $indentLevel,
+          $err, $title["content"]);
+        $title["print_content"] = false;
+      }
+      if ($title["print_descs"]) {
+        foreach ($title["descs"] as $desc) {
+          $this->_printGeneric(
+            $desc["msg_level"], "desc", $desc["line_prefix"], $indentLevel,
+            $err, $desc["content"]);
+        }
+        $title["descs"] = [];
+        $title["print_descs"] = false;
+      }
+      $indentLevel++;
+    }; unset($title);
+  }
+
+  protected function &title__ref(): ?array {
+    return $this->titles[array_key_last($this->titles)];
+  }
+
+  function title__beforeFunc(array $marks): void {
+    $title =& $this->title__ref();
+    $title["max_title_level"] = $marks[0] + 1;
+  }
+
+  function title__afterFunc(array $marks): void {
+    $title =& $this->title__ref();
+    $title["max_title_level"] = null;
+    $this->title__end($marks[0]);
+  }
+
+  function title($content, ?callable $func=null, ?int $level=null): void {
+    if (!$this->checkLevel($level)) return;
+    $marks = $this->title__getMarks();
+    // faire en deux temps pour linePrefix soit à jour
+    $this->titles[] = ["id" => $this->lastTitleId++];
+    A::merge($this->title__ref(), [
+      "title_level" => $marks[0],
+      "max_title_level" => null,
+      "msg_level" => $level,
+      "line_prefix" => $this->getLinePrefix(),
+      "content" => $content,
+      "print_content" => true,
+      "descs" => [],
+      "print_descs" => false,
+    ]);
+    if ($func !== null) {
+      try {
+        $this->title__beforeFunc($marks);
+        $func($this);
+      } finally {
+        $this->title__afterFunc($marks);
+      }
+    }
+  }
+
+  function desc($content, ?int $level=null): void {
+    if (!$this->checkLevel($level)) return;
+    $desc = [
+      "msg_level" => $level,
+      "line_prefix" => $this->getLinePrefix(),
+      "content" => $content,
+    ];
+    $title = $this->title__last();
+    if ($title !== null) {
+      $title =& $this->title__ref();
+      $title["descs"][] = $desc;
+      $title["print_descs"] = true;
+    } else {
+      # pas de titre en cours
+      $this->_printGeneric(
+        $desc["msg_level"], "desc", $desc["line_prefix"], 0,
+        $this->err, $desc["content"]);
+    }
+  }
+
+  protected array $actions;
+
+  protected function action__last(): ?array {
+    $last = end($this->actions);
+    return $last !== false? $last: null;
+  }
+
+  function action__getMarks(): array {
+    return [count($this->actions)];
+  }
+
+  protected function action__getId(): ?int {
+    return $this->action__last()["id"] ?? null;
+  }
+
+  protected function action__end(?int $until=null): void {
+    $action = $this->action__last();
+    if ($action !== null) {
+      $until ??= $action["max_action_level"];
+      $until ??= $this->action__getMarks()[0] - 1;
+      while (count($this->actions) > $until) {
+        array_pop($this->actions);
+      }
+    }
+  }
+
+  protected function action__flush(bool $endAction=false, ?int $overrideLevel=null): void {
+    $this->title__flush();
+    $err = $this->err;
+    $indentLevel = $this->getIndentLevel(false);
+    $lastIndex = array_key_last($this->actions);
+    $index = 0;
+    foreach ($this->actions as &$action) {
+      $mergeResult = $index++ == $lastIndex && $endAction;
+      $level = $overrideLevel?? $action["msg_level"];
+      $linePrefix = $action["line_prefix"];
+      $content = $action["content"];
+      $printContent = $action["print_content"];
+      $rsuccess = $action["result_success"];
+      $rcontent = $action["result_content"];
+      if ($level < $this->minLevel) continue;
+      if ($mergeResult) {
+        if (time() - $action["timestamp"] <= 2) {
+          $this->_printAction(
+            $level, $linePrefix, $indentLevel,
+            $err,
+            $printContent, $content,
+            true, $rsuccess, $rcontent);
+        } else {
+          # si l'action a pris plus de 2 secondes, ne pas fusionner pour que
+          # l'on voit le temps que ça a pris
+          $this->_printAction(
+            $level, $linePrefix, $indentLevel,
+            $err,
+            $printContent, $content,
+            false, null, null);
+          # recalculer une nouvelle ligne de préfixe pour le résultat
+          $linePrefix = $this->getLinePrefix();
+          $this->_printAction(
+            $level, $linePrefix, $indentLevel,
+            $err,
+            false, null,
+            true, $rsuccess, $rcontent);
+        }
+        $action["action_aresult"] = true;
+      } elseif ($printContent) {
+        $this->_printAction(
+          $level, $linePrefix, $indentLevel,
+          $err,
+          $printContent, $content,
+          false, $rsuccess, $rcontent);
+        $action["print_content"] = false;
+      }
+      $indentLevel++;
+    }; unset($action);
+    if ($endAction) $this->action__end();
+  }
+
+  protected function &action__ref(): ?array {
+    return $this->actions[array_key_last($this->actions)];
+  }
+
+  function action__beforeFunc(array $marks): void {
+    $action =& $this->action__ref();
+    $action["max_action_level"] = $marks[0] + 1;
+  }
+
+  function action__afterFunc(array $marks, $result): void {
+    $action =& $this->action__ref();
+    $aresult = $action["action_aresult"] ?? false;
+    if (!$aresult) $this->aresult($result);
+    $action["max_action_level"] = null;
+    $this->action__end($marks[0]);
+  }
+
+  function action($content, ?callable $func=null, ?int $level=null): void {
+    $this->checkLevel($level);
+    $marks = $this->action__getMarks();
+    // faire en deux temps pour linePrefix soit à jour
+    $this->actions[] = ["id" => $this->lastActionId++];
+    A::merge($this->action__ref(), [
+      "action_level" => $marks[0],
+      "max_action_level" => null,
+      "action_aresult" => false,
+      "timestamp" => time(),
+      "msg_level" => $level,
+      "line_prefix" => $this->getLinePrefix(),
+      "content" => $content,
+      "print_content" => true,
+      "result_success" => null,
+      "result_content" => null,
+    ]);
+    if ($func !== null) {
+      try {
+        $result = null;
+        $this->action__beforeFunc($marks);
+        $result = $func($this);
+      } catch (Exception $e) {
+        $this->afailure($e);
+        throw $e;
+      } finally {
+        $this->action__afterFunc($marks, $result);
+      }
+    }
+  }
+
+  function step($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "step", $this->getIndentLevel(),
+      $this->err, $content);
+  }
+
+  function asuccess($content=null, ?int $overrideLevel=null): void {
+    if (!$this->actions) $this->action(null);
+    $action =& $this->action__ref();
+    $action["result_success"] = true;
+    $action["result_content"] = $content;
+    $this->action__flush(true, $overrideLevel);
+  }
+
+  function afailure($content=null, ?int $overrideLevel=null): void {
+    if (!$this->actions) $this->action(null);
+    $action =& $this->action__ref();
+    $action["result_success"] = false;
+    $action["result_content"] = $content;
+    $this->action__flush(true, $overrideLevel);
+  }
+
+  function adone($content=null, ?int $overrideLevel=null): void {
+    if (!$this->actions) $this->action(null);
+    $action =& $this->action__ref();
+    $action["result_success"] = null;
+    $action["result_content"] = $content;
+    $this->action__flush(true, $overrideLevel);
+  }
+
+  function aresult($result=null, ?int $overrideLevel=null): void {
+    if (!$this->actions) $this->action(null);
+    if ($result === true) $this->asuccess(null, $overrideLevel);
+    elseif ($result === false) $this->afailure(null, $overrideLevel);
+    elseif ($result instanceof Exception) $this->afailure($result, $overrideLevel);
+    else $this->adone($result, $overrideLevel);
+  }
+
+  function print($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "print", $this->getIndentLevel(),
+      $this->out, $content);
+  }
+
+  function info($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "info", $this->getIndentLevel(),
+      $this->err, $content);
+  }
+
+  function note($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "note", $this->getIndentLevel(),
+      $this->err, $content);
+  }
+
+  function warning($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "warning", $this->getIndentLevel(),
+      $this->err, $content);
+  }
+
+  function error($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "error", $this->getIndentLevel(),
+      $this->err, $content);
+  }
+
+  function end(bool $all=false): void {
+    if ($all) $this->section__afterFunc();
+    elseif ($this->actions) $this->action__end();
+    elseif ($this->titles) $this->title__end();
+    else $this->section__afterFunc();
+  }
+}
diff --git a/php/src/output/std/LogMessenger.php b/php/src/output/std/LogMessenger.php
new file mode 100644
index 0000000..3bf3391
--- /dev/null
+++ b/php/src/output/std/LogMessenger.php
@@ -0,0 +1,350 @@
+out = new StdOutput($output ?? STDERR, [
+      "color" => $color,
+      "indent" => $indent,
+    ]);
+    $this->defaultLevel = $defaultLevel;
+    $this->minLevel = $minLevel;
+    $this->addDate = $addDate;
+    $this->dateFormat = $dateFormat;
+    $this->id = $id;
+    $this->showIds = $showIds;
+    $this->titles = [];
+    $this->actions = [];
+  }
+
+  function resetParams(?array $params=null): void {
+    $output = $params["output"] ?? null;
+    $color = $params["color"] ?? null;
+    $indent = $params["indent"] ?? null;
+
+    $defaultLevel = $params["default_level"] ?? null;
+    if ($defaultLevel !== null) $defaultLevel = self::verifix_level($defaultLevel);
+
+    $debug = $params["debug"] ?? null;
+    $minLevel = $params["min_level"] ?? null;
+    if ($debug !== null) $minLevel ??= self::DEBUG;
+    $minLevel ??= $params["verbosity"] ?? null; # alias
+    if ($minLevel !== null) $minLevel = self::verifix_level($minLevel, self::NONE);
+
+    $addDate = $params["add_date"] ?? null;
+    $dateFormat = $params["date_format"] ?? null;
+    $id = $params["id"] ?? null;
+
+    $this->out->resetParams([
+      "output" => $output,
+      "color" => $color,
+      "indent" => $indent,
+    ]);
+    if ($defaultLevel !== null) $this->defaultLevel = $defaultLevel;
+    if ($minLevel !== null) $this->minLevel = $minLevel;
+    if ($addDate !== null) $this->addDate = boolval($addDate);
+    if ($dateFormat !== null) $this->dateFormat = $dateFormat;
+    if ($id !== null) $this->id = $id;
+  }
+
+  function clone(?array $params=null): IMessenger {
+    $clone = clone $this;
+    if ($params !== null) $clone->resetParams($params);
+    return $clone;
+  }
+
+  protected function section__end(): void {
+    $this->end(true);
+  }
+
+  function section__afterFunc(): void {
+    $this->section__end();
+  }
+
+  function section($content, ?callable $func=null, ?int $level=null): void {
+    $this->section__end();
+    if (!$this->checkLevel($level)) return;
+    $this->_printTitle(
+      $level, "section", $this->getLinePrefix(), 0,
+      $this->out, $content);
+    if ($func !== null) {
+      try {
+        $func($this);
+      } finally {
+        $this->section__afterFunc();
+      }
+    }
+  }
+
+  protected array $titles;
+
+  protected function title__last(): ?array {
+    $last = end($this->titles);
+    return $last !== false? $last: null;
+  }
+
+  function title__getMarks(): array {
+    return [count($this->titles)];
+  }
+
+  protected function title__getId(): ?int {
+    return $this->title__last()["id"] ?? null;
+  }
+
+  protected function title__end(?int $until=null): void {
+    $title = $this->title__last();
+    if ($title !== null) {
+      $until ??= $title["max_title_level"];
+      $until ??= $this->title__getMarks()[0] - 1;
+      while (count($this->titles) > $until) {
+        array_pop($this->titles);
+      }
+    }
+  }
+
+  protected function &title__ref(): ?array {
+    return $this->titles[array_key_last($this->titles)];
+  }
+
+  function title__beforeFunc(array $marks): void {
+    $title =& $this->title__ref();
+    $title["max_title_level"] = $marks[0] + 1;
+  }
+
+  function title__afterFunc(array $marks): void {
+    $title =& $this->title__ref();
+    $title["max_title_level"] = null;
+    $this->title__end($marks[0]);
+  }
+
+  function title($content, ?callable $func=null, ?int $level=null): void {
+    if (!$this->checkLevel($level)) return;
+    $marks = $this->title__getMarks();
+    $this->titles[] = [
+      "id" => $this->lastTitleId++,
+      "title_level" => $marks[0],
+      "max_title_level" => null,
+    ];
+    $this->_printTitle(
+      $level, "title", $this->getLinePrefix(), $marks[0],
+      $this->out, $content);
+    if ($func !== null) {
+      try {
+        $this->title__beforeFunc($marks);
+        $func($this);
+      } finally {
+        $this->title__afterFunc($marks);
+      }
+    }
+  }
+
+  function desc($content, ?int $level=null): void {
+    if (!$this->checkLevel($level)) return;
+    $titleLevel = $this->title__last()["title_level"] ?? 0;
+    $this->_printGeneric(
+      $level, "desc", $this->getLinePrefix(), $titleLevel,
+      $this->out, $content);
+  }
+
+  protected array $actions;
+
+  protected function action__last(): ?array {
+    $last = end($this->actions);
+    return $last !== false? $last: null;
+  }
+
+  function action__getMarks(): array {
+    return [count($this->actions)];
+  }
+
+  protected function action__getId(): ?int {
+    return $this->action__last()["id"] ?? null;
+  }
+
+  protected function action__end(?int $until=null): void {
+    $action = $this->action__last();
+    if ($action !== null) {
+      $until ??= $action["max_action_level"];
+      $until ??= $this->action__getMarks()[0] - 1;
+      while (count($this->actions) > $until) {
+        array_pop($this->actions);
+      }
+    }
+  }
+
+  protected function action__flush(bool $endAction=false, ?int $overrideLevel=null): void {
+  }
+
+  protected function &action__ref(): ?array {
+    return $this->actions[array_key_last($this->actions)];
+  }
+
+  function action__beforeFunc(array $marks): void {
+    $action =& $this->action__ref();
+    $action["max_action_level"] = $marks[0] + 1;
+  }
+
+  function action__afterFunc(array $marks, $result): void {
+    $action =& $this->action__ref();
+    $aresult = $action["action_aresult"] ?? false;
+    if (!$aresult) $this->aresult($result);
+    $action["max_action_level"] = null;
+    $this->action__end($marks[0]);
+  }
+
+  function action($content, ?callable $func=null, ?int $level=null): void {
+    $this->checkLevel($level);
+    $marks = $this->action__getMarks();
+    $this->actions[] = [
+      "id" => $this->lastActionId++,
+      "action_level" => $marks[0],
+      "max_action_level" => null,
+      "action_aresult" => false,
+      "msg_level" => $level
+    ];
+    $this->_printAction(
+      $level, $this->getLinePrefix(), $marks[0],
+      $this->out,
+      true, $content,
+      false, null, null);
+    if ($func !== null) {
+      try {
+        $result = null;
+        $this->action__beforeFunc($marks);
+        $result = $func($this);
+      } catch (Exception $e) {
+        $this->afailure($e);
+        throw $e;
+      } finally {
+        $this->action__afterFunc($marks, $result);
+      }
+    }
+  }
+
+  function step($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "step", $this->getIndentLevel(),
+      $this->out, $content);
+  }
+
+  function asuccess($content=null, ?int $overrideLevel=null): void {
+    if ($this->action__getMarks()[0] == 0) $this->action(null);
+    $action =& $this->action__ref();
+    $level = $overrideLevel ?? $action["msg_level"];
+    $this->_printAction(
+      $level, $this->getLinePrefix(), $action["action_level"],
+      $this->out,
+      false, null,
+      true, true, $content);
+    $action["action_aresult"] = true;
+    $this->action__end();
+  }
+
+  function afailure($content=null, ?int $overrideLevel=null): void {
+    if ($this->action__getMarks()[0] == 0) $this->action(null);
+    $action =& $this->action__ref();
+    $level = $overrideLevel ?? $action["msg_level"];
+    $this->_printAction(
+      $level, $this->getLinePrefix(), $action["action_level"],
+      $this->out,
+      false, null,
+      true, false, $content);
+    $action["action_aresult"] = true;
+    $this->action__end();
+  }
+
+  function adone($content=null, ?int $overrideLevel=null): void {
+    if ($this->action__getMarks()[0] == 0) $this->action(null);
+    $action =& $this->action__ref();
+    $level = $overrideLevel ?? $action["msg_level"];
+    $this->_printAction(
+      $level, $this->getLinePrefix(), $action["action_level"],
+      $this->out,
+      false, null,
+      true, null, $content);
+    $action["action_aresult"] = true;
+    $this->action__end();
+  }
+
+  function aresult($result=null, ?int $overrideLevel=null): void {
+    if ($this->action__getMarks()[0] == 0) $this->action(null);
+    if ($result === true) $this->asuccess(null, $overrideLevel);
+    elseif ($result === false) $this->afailure(null, $overrideLevel);
+    elseif ($result instanceof Exception) $this->afailure($result, $overrideLevel);
+    else $this->adone($result, $overrideLevel);
+  }
+
+  protected function getIndentLevel(bool $withActions=true): int {
+    $indentLevel = count($this->titles) - 1;
+    if ($indentLevel < 0) $indentLevel = 0;
+    if ($withActions) $indentLevel += count($this->actions);
+    return $indentLevel;
+  }
+
+  function print($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "print", $this->getIndentLevel(),
+      $this->out, $content);
+  }
+
+  function info($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "info", $this->getIndentLevel(),
+      $this->out, $content);
+  }
+
+  function note($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "note", $this->getIndentLevel(),
+      $this->out, $content);
+  }
+
+  function warning($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "warning", $this->getIndentLevel(),
+      $this->out, $content);
+  }
+
+  function error($content, ?int $level=null): void {
+    $this->_printGenericOrException(
+      $level, "error", $this->getIndentLevel(),
+      $this->out, $content);
+  }
+
+  function end(bool $all=false): void {
+    if ($all) {
+      while ($this->actions) $this->adone();
+      while ($this->titles) $this->title__end();
+    } elseif ($this->actions) {
+      $this->action__end();
+    } elseif ($this->titles) {
+      $this->title__end();
+    }
+  }
+}
diff --git a/php/src/output/std/NullMessenger.php b/php/src/output/std/NullMessenger.php
new file mode 100644
index 0000000..0218ebb
--- /dev/null
+++ b/php/src/output/std/NullMessenger.php
@@ -0,0 +1,61 @@
+msgs = [];
     foreach ($msgs as $msg) {
       if ($msg !== null) $this->msgs[] = $msg;
     }
   }
 
-  /** @var IMessenger[] */
-  protected $msgs;
+  /** @var _IMessenger[] */
+  protected ?array $msgs = [];
+
+  function isEmpty(): bool {
+    return !$this->msgs;
+  }
+
+  function addMessenger(IMessenger $msg): self {
+    $this->msgs[] = $msg;
+    return $this;
+  }
+
+  function resetParams(?array $params=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->resetParams($params);
+    }
+  }
 
-  function resetParams(?array $params=null): void { foreach ($this->msgs as $msg) { $msg->resetParams($params); } }
   function clone(?array $params=null): self {
     $clone = clone $this;
     foreach ($clone->msgs as &$msg) {
@@ -30,92 +41,180 @@ class ProxyMessenger implements IMessenger {
     }; unset($msg);
     return $clone;
   }
+
+  function section__afterFunc(): void {
+    foreach ($this->msgs as $msg) {
+      if ($msg instanceof _IMessenger) {
+        $msg->section__afterFunc();
+      }
+    }
+  }
+
   function section($content, ?callable $func=null, ?int $level=null): void {
-    $useFunc = false;
     foreach ($this->msgs as $msg) {
       $msg->section($content, null, $level);
-      if ($msg instanceof _IMessenger) $useFunc = true;
     }
-    if ($useFunc && $func !== null) {
+    if ($func !== null) {
       try {
         $func($this);
       } finally {
-        /** @var _IMessenger $msg */
-        foreach ($this->msgs as $msg) {
-          $msg->_endSection();
-        }
+        $this->section__afterFunc();
       }
     }
   }
-  function title($content, ?callable $func=null, ?int $level=null): void {
-    $useFunc = false;
-    $untils = [];
-    foreach ($this->msgs as $msg) {
+
+  function title__getMarks(): array {
+    $marks = [];
+    foreach ($this->msgs as $key => $msg) {
       if ($msg instanceof _IMessenger) {
-        $useFunc = true;
-        $untils[] = $msg->_getTitleMark();
+        $marks[$key] = $msg->title__getMarks();
       }
+    }
+    return $marks;
+  }
+
+  function title__beforeFunc(array $marks): void {
+    foreach ($this->msgs as $key => $msg) {
+      if ($msg instanceof _IMessenger) {
+        $msg->title__beforeFunc($marks[$key]);
+      }
+    }
+  }
+
+  function title__afterFunc(array $marks): void {
+    foreach ($this->msgs as $key => $msg) {
+      if ($msg instanceof _IMessenger) {
+        $msg->title__afterFunc($marks[$key]);
+      }
+    }
+  }
+
+  function title($content, ?callable $func=null, ?int $level=null): void {
+    $marks = $this->title__getMarks();
+    foreach ($this->msgs as $msg) {
       $msg->title($content, null, $level);
     }
-    if ($useFunc && $func !== null) {
+    if ($func !== null) {
       try {
+        $this->title__beforeFunc($marks);
         $func($this);
       } finally {
-        /** @var _IMessenger $msg */
-        $index = 0;
-        foreach ($this->msgs as $msg) {
-          if ($msg instanceof _IMessenger) {
-            $msg->_endTitle($untils[$index++]);
-          }
-        }
+        $this->title__afterFunc($marks);
       }
     }
   }
-  function desc($content, ?int $level=null): void { foreach ($this->msgs as $msg) { $msg->desc($content, $level); } }
-  function action($content, ?callable $func=null, ?int $level=null): void {
-    $useFunc = false;
-    $untils = [];
+
+  function desc($content, ?int $level=null): void {
     foreach ($this->msgs as $msg) {
+      $msg->desc($content, $level);
+    }
+  }
+
+  function action__getMarks(): array {
+    $marks = [];
+    foreach ($this->msgs as $key => $msg) {
       if ($msg instanceof _IMessenger) {
-        $useFunc = true;
-        $untils[] = $msg->_getActionMark();
+        $marks[$key] = $msg->action__getMarks();
       }
+    }
+    return $marks;
+  }
+
+  function action__beforeFunc(array $marks): void {
+    foreach ($this->msgs as $key => $msg) {
+      if ($msg instanceof _IMessenger) {
+        $msg->action__beforeFunc($marks[$key]);
+      }
+    }
+  }
+
+  function action__afterFunc(array $marks, $result): void {
+    foreach ($this->msgs as $key => $msg) {
+      if ($msg instanceof _IMessenger) {
+        $msg->action__afterFunc($marks[$key], $result);
+      }
+    }
+  }
+
+  function action($content, ?callable $func=null, ?int $level=null): void {
+    $marks = $this->action__getMarks();
+    foreach ($this->msgs as $msg) {
       $msg->action($content, null, $level);
     }
-    if ($useFunc && $func !== null) {
+    if ($func !== null) {
       try {
+        $result = null;
+        $this->action__beforeFunc($marks);
         $result = $func($this);
-        /** @var _IMessenger $msg */
-        $index = 0;
-        foreach ($this->msgs as $msg) {
-          if ($msg->_getActionMark() > $untils[$index++]) {
-            $msg->aresult($result);
-          }
-        }
-      } catch (Exception $e) {
-        /** @var _IMessenger $msg */
-        foreach ($this->msgs as $msg) {
-          $msg->afailure($e);
-        }
-        throw $e;
       } finally {
-        /** @var _IMessenger $msg */
-        $index = 0;
-        foreach ($this->msgs as $msg) {
-          $msg->_endAction($untils[$index++]);
-        }
+        $this->action__afterFunc($marks, $result);
       }
     }
   }
-  function step($content, ?int $level=null): void { foreach ($this->msgs as $msg) { $msg->step($content, $level); } }
-  function asuccess($content=null, ?int $overrideLevel=null): void { foreach ($this->msgs as $msg) { $msg->asuccess($content, $overrideLevel); } }
-  function afailure($content=null, ?int $overrideLevel=null): void { foreach ($this->msgs as $msg) { $msg->afailure($content, $overrideLevel); } }
-  function adone($content=null, ?int $overrideLevel=null): void { foreach ($this->msgs as $msg) { $msg->adone($content, $overrideLevel); } }
-  function aresult($result=null, ?int $overrideLevel=null): void { foreach ($this->msgs as $msg) { $msg->aresult($result, $overrideLevel); } }
-  function print($content, ?int $level=null): void { foreach ($this->msgs as $msg) { $msg->print($content, $level); } }
-  function info($content, ?int $level=null): void { foreach ($this->msgs as $msg) { $msg->info($content, $level); } }
-  function note($content, ?int $level=null): void { foreach ($this->msgs as $msg) { $msg->note($content, $level); } }
-  function warning($content, ?int $level=null): void { foreach ($this->msgs as $msg) { $msg->warning($content, $level); } }
-  function error($content, ?int $level=null): void { foreach ($this->msgs as $msg) { $msg->error($content, $level); } }
-  function end(bool $all=false): void { foreach ($this->msgs as $msg) { $msg->end($all); } }
+
+  function step($content, ?int $level=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->step($content, $level);
+    }
+  }
+
+  function asuccess($content=null, ?int $overrideLevel=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->asuccess($content, $overrideLevel);
+    }
+  }
+
+  function afailure($content=null, ?int $overrideLevel=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->afailure($content, $overrideLevel);
+    }
+  }
+
+  function adone($content=null, ?int $overrideLevel=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->adone($content, $overrideLevel);
+    }
+  }
+
+  function aresult($result=null, ?int $overrideLevel=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->aresult($result, $overrideLevel);
+    }
+  }
+
+  function print($content, ?int $level=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->print($content, $level);
+    }
+  }
+
+  function info($content, ?int $level=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->info($content, $level);
+    }
+  }
+
+  function note($content, ?int $level=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->note($content, $level);
+    }
+  }
+
+  function warning($content, ?int $level=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->warning($content, $level);
+    }
+  }
+
+  function error($content, ?int $level=null): void {
+    foreach ($this->msgs as $msg) {
+      $msg->error($content, $level);
+    }
+  }
+
+  function end(bool $all=false): void {
+    foreach ($this->msgs as $msg) {
+      $msg->end($all);
+    }
+  }
 }
diff --git a/php/src/output/std/StdMessenger.php b/php/src/output/std/StdMessenger.php
deleted file mode 100644
index 3892a0d..0000000
--- a/php/src/output/std/StdMessenger.php
+++ /dev/null
@@ -1,722 +0,0 @@
- self::DEBUG,
-    "minor" => self::MINOR, "verbose" => self::MINOR,
-    "normal" => self::NORMAL,
-    "major" => self::MAJOR, "quiet" => self::MAJOR,
-    "none" => self::NONE, "silent" => self::NONE,
-  ];
-
-  protected static function verifix_level($level, int $max_level=self::MAX_LEVEL): int {
-    if (!in_array($level, self::VALID_LEVELS, true)) {
-      $level = cl::get(self::LEVEL_MAP, $level, $level);
-    }
-    if (!in_array($level, self::VALID_LEVELS, true)) {
-      throw new Exception("$level: invalid level");
-    }
-    if ($level > $max_level) {
-      throw new Exception("$level: level not allowed here");
-    }
-    return $level;
-  }
-
-  const GENERIC_PREFIXES = [
-    self::MAJOR => [
-      "section" => [true, "SECTION!", "===", "=", "=", "==="],
-      "title" => [false, "TITLE!", null, "T", "", "==="],
-      "desc" => ["DESC!", ">", ""],
-      "error" => ["CRIT.ERROR!", "E!", ""],
-      "warning" => ["CRIT.WARNING!", "W!", ""],
-      "note" => ["ATTENTION!", "N!", ""],
-      "info" => ["IMPORTANT!", "N!", ""],
-      "step" => ["*", ".", ""],
-      "print" => [null, null, null],
-    ],
-    self::NORMAL => [
-      "section" => [true, "SECTION:", "---", "-", "-", "---"],
-      "title" => [false, "TITLE:", null, "T", "", "---"],
-      "desc" => ["DESC:", ">", ""],
-      "error" => ["ERROR:", "E", ""],
-      "warning" => ["WARNING:", "W", ""],
-      "note" => ["NOTE:", "N", ""],
-      "info" => ["INFO:", "I", ""],
-      "step" => ["*", ".", ""],
-      "print" => [null, null, null],
-    ],
-    self::MINOR => [
-      "section" => [true, "section", null, ">>", "<<", null],
-      "title" => [false, "title", null, "t", "", null],
-      "desc" => ["desc", ">", ""],
-      "error" => ["error", "E", ""],
-      "warning" => ["warning", "W", ""],
-      "note" => ["note", "N", ""],
-      "info" => ["info", "I", ""],
-      "step" => ["*", ".", ""],
-      "print" => [null, null, null],
-    ],
-    self::DEBUG => [
-      "section" => [true, "section", null, ">>", "<<", null],
-      "title" => [false, "title", null, "t", "", null],
-      "desc" => ["desc", ">", ""],
-      "error" => ["debugE", "e", ""],
-      "warning" => ["debugW", "w", ""],
-      "note" => ["debugN", "i", ""],
-      "info" => ["debug", "D", ""],
-      "step" => ["*", ".", ""],
-      "print" => [null, null, null],
-    ],
-  ];
-
-  const RESULT_PREFIXES = [
-    "failure" => ["(FAILURE)", "✘"],
-    "success" => ["(SUCCESS)", "✔"],
-    "done" => [null, null],
-  ];
-
-  function __construct(?array $params=null) {
-    $output = cl::get($params, "output");
-    $color = cl::get($params, "color");
-    $indent = cl::get($params, "indent", static::INDENT);
-
-    $defaultLevel = cl::get($params, "default_level");
-    if ($defaultLevel === null) $defaultLevel = self::NORMAL;
-    $defaultLevel = self::verifix_level($defaultLevel);
-
-    $debug = boolval(cl::get($params, "debug"));
-    $minLevel = cl::get($params, "min_level");
-    if ($minLevel === null && $debug) $minLevel = self::DEBUG;
-    if ($minLevel === null) $minLevel = cl::get($params, "verbosity"); # alias
-    if ($minLevel === null) $minLevel = self::NORMAL;
-    $minLevel = self::verifix_level($minLevel, self::NONE);
-
-    $addDate = boolval(cl::get($params, "add_date"));
-    $dateFormat = cl::get($params, "date_format", static::DATE_FORMAT);
-    $id = cl::get($params, "id");
-
-    $params = [
-      "color" => $color,
-      "indent" => $indent,
-    ];
-    if ($output !== null) {
-      $this->err = $this->out = new StdOutput($output, $params);
-    } else {
-      $this->out = new StdOutput(STDOUT, $params);
-      $this->err = new StdOutput(STDERR, $params);
-    }
-    $this->defaultLevel = $defaultLevel;
-    $this->minLevel = $minLevel;
-    $this->addDate = $addDate;
-    $this->dateFormat = $dateFormat;
-    $this->id = $id;
-    $this->inSection = false;
-    $this->titles = [];
-    $this->actions = [];
-  }
-
-  function resetParams(?array $params=null): void {
-    $output = cl::get($params, "output");
-    $color = cl::get($params, "color");
-    $indent = cl::get($params, "indent");
-
-    $defaultLevel = cl::get($params, "default_level");
-    if ($defaultLevel !== null) $defaultLevel = self::verifix_level($defaultLevel);
-
-    $debug = cl::get($params, "debug");
-    $minLevel = cl::get($params, "min_level");
-    if ($minLevel === null && $debug !== null) $minLevel = $debug? self::DEBUG: self::NORMAL;
-    if ($minLevel === null) $minLevel = cl::get($params, "verbosity"); # alias
-    if ($minLevel !== null) $minLevel = self::verifix_level($minLevel, self::NONE);
-
-    $addDate = cl::get($params, "add_date");
-    $dateFormat = cl::get($params, "date_format");
-    $id = cl::get($params, "id");
-
-    $params = [
-      "output" => $output,
-      "color" => $color,
-      "indent" => $indent,
-    ];
-    if ($this->out === $this->err) {
-      $this->out->resetParams($params);
-    } else {
-      # NB: si initialement [output] était null, et qu'on spécifie une valeur
-      # [output], alors les deux instances $out et $err sont mis à jour
-      # séparément avec la même valeur de output
-      # de plus, on ne peut plus revenir à la situation initiale avec une
-      # destination différente pour $out et $err
-      $this->out->resetParams($params);
-      $this->err->resetParams($params);
-    }
-    if ($defaultLevel !== null) $this->defaultLevel = $defaultLevel;
-    if ($minLevel !== null) $this->minLevel = $minLevel;
-    if ($addDate !== null) $this->addDate = boolval($addDate);
-    if ($dateFormat !== null) $this->dateFormat = $dateFormat;
-    if ($id !== null) $this->id = $id;
-  }
-
-  function clone(?array $params=null): IMessenger {
-    $clone = clone $this;
-    if ($params !== null) $clone->resetParams($params);
-    #XXX faut-il marquer la section et les titres du clone à "print" => false?
-    # ou en faire des références au parent?
-    # dans tous les cas, on considère qu'il n'y a pas d'actions en cours, et on
-    # ne doit pas dépiler avec end() plus que l'état que l'on a eu lors du clone
-    return $clone;
-  }
-
-  /** @var StdOutput la sortie standard */
-  protected $out;
-
-  /** @var StdOutput la sortie d'erreur */
-  protected $err;
-
-  /** @var int level par défaut dans lequel les messages sont affichés */
-  protected $defaultLevel;
-
-  /** @var int level minimum que doivent avoir les messages pour être affichés */
-  protected $minLevel;
-
-  /** @var bool faut-il ajouter la date à chaque ligne? */
-  protected $addDate;
-
-  /** @var string format de la date */
-  protected $dateFormat;
-
-  /** @var ?string identifiant de ce messenger, à ajouter à chaque ligne */
-  protected $id;
-
-  protected function getLinePrefix(): ?string {
-    $linePrefix = null;
-    if ($this->addDate) {
-      $date = date_create()->format($this->dateFormat);
-      $linePrefix .= "$date ";
-    }
-    if ($this->id !== null) {
-      $linePrefix .= "$this->id ";
-    }
-    return $linePrefix;
-  }
-
-  protected function decrLevel(int $level, int $amount=-1): int {
-    $level += $amount;
-    if ($level < self::MIN_LEVEL) $level = self::MIN_LEVEL;
-    return $level;
-  }
-
-  protected function checkLevel(?int &$level): bool {
-    if ($level === null) $level = $this->defaultLevel;
-    elseif ($level < 0) $level = $this->decrLevel($this->defaultLevel, $level);
-    return $level >= $this->minLevel;
-  }
-
-  protected function getIndentLevel(bool $withActions=true): int {
-    $indentLevel = count($this->titles) - 1;
-    if ($indentLevel < 0) $indentLevel = 0;
-    if ($withActions) {
-      foreach ($this->actions as $action) {
-        if ($action["level"] < $this->minLevel) continue;
-        $indentLevel++;
-      }
-    }
-    return $indentLevel;
-  }
-
-  protected function _printTitle(?string $linePrefix, int $level,
-                                 string $type, $content,
-                                 int $indentLevel, StdOutput $out): void {
-    $prefixes = self::GENERIC_PREFIXES[$level][$type];
-    if ($prefixes[0]) $out->print();
-    $content = cl::with($content);
-    if ($out->isColor()) {
-      $before = $prefixes[2];
-      $prefix = $prefixes[3];
-      $prefix2 = $prefix !== null? "$prefix ": null;
-      $suffix = $prefixes[4];
-      $suffix2 = $suffix !== null? " $suffix": null;
-      $after = $prefixes[5];
-
-      $lines = $out->getLines(false, ...$content);
-      $maxlen = 0;
-      foreach ($lines as &$content) {
-        $line = $out->filterColors($content);
-        $len = mb_strlen($line);
-        if ($len > $maxlen) $maxlen = $len;
-        $content = [$content, $len];
-      }; unset($content);
-      if ($before !== null) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $out->iprint($indentLevel, $prefix, substr($before, 1), str_repeat($before[0], $maxlen), $suffix);
-      }
-      foreach ($lines as [$content, $len]) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $padding = $len < $maxlen? str_repeat(" ", $maxlen - $len): null;
-        $out->iprint($indentLevel, $prefix2, $content, $padding, $suffix2);
-      }
-      if ($after !== null) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $out->iprint($indentLevel, $prefix, substr($after, 1), str_repeat($after[0], $maxlen), $suffix);
-      }
-    } else {
-      $prefix = $prefixes[1];
-      if ($prefix !== null) $prefix .= " ";
-      $prefix2 = str_repeat(" ", mb_strlen($prefix));
-      $lines = $out->getLines(false, ...$content);
-      foreach ($lines as $content) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $out->iprint($indentLevel, $prefix, $content);
-        $prefix = $prefix2;
-      }
-    }
-  }
-
-  protected function _printAction(?string $linePrefix, int $level,
-                                  bool $printContent, $content,
-                                  bool $printResult, ?bool $rsuccess, $rcontent,
-                                  int $indentLevel, StdOutput $out): void {
-    $color = $out->isColor();
-    if ($rsuccess === true) $type = "success";
-    elseif ($rsuccess === false) $type = "failure";
-    else $type = "done";
-    $rprefixes = self::RESULT_PREFIXES[$type];
-    if ($color) {
-      $rprefix = $rprefixes[1];
-      $rprefix2 = null;
-      if ($rprefix !== null) {
-        $rprefix .= " ";
-        $rprefix2 = $out->filterColors($out->filterContent($rprefix));
-        $rprefix2 = str_repeat(" ", mb_strlen($rprefix2));
-      }
-    } else {
-      $rprefix = $rprefixes[0];
-      if ($rprefix !== null) $rprefix .= " ";
-      $rprefix2 = str_repeat(" ", mb_strlen($rprefix));
-    }
-    if ($printContent && $printResult) {
-      A::ensure_array($content);
-      if ($rcontent) {
-        $content[] = ": ";
-        $content[] = $rcontent;
-      }
-      $lines = $out->getLines(false, ...$content);
-      foreach ($lines as $content) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $out->iprint($indentLevel, $rprefix, $content);
-        $rprefix = $rprefix2;
-      }
-    } elseif ($printContent) {
-      $prefixes = self::GENERIC_PREFIXES[$level]["step"];
-      if ($color) {
-        $prefix = $prefixes[1];
-        if ($prefix !== null) $prefix .= " ";
-        $prefix2 = $out->filterColors($out->filterContent($prefix));
-        $prefix2 = str_repeat(" ", mb_strlen($prefix2));
-        $suffix = $prefixes[2];
-      } else {
-        $prefix = $prefixes[0];
-        if ($prefix !== null) $prefix .= " ";
-        $prefix2 = str_repeat(" ", mb_strlen($prefix));
-        $suffix = null;
-      }
-      A::ensure_array($content);
-      $content[] = ":";
-      $lines = $out->getLines(false, ...$content);
-      foreach ($lines as $content) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $out->iprint($indentLevel, $prefix, $content, $suffix);
-        $prefix = $prefix2;
-      }
-    } elseif ($printResult) {
-      if (!$rcontent) {
-        if ($type === "success") $rcontent = $color? "succès": "";
-        elseif ($type === "failure") $rcontent = $color? "échec": "";
-        elseif ($type === "done") $rcontent = "fait";
-      }
-      $rprefix = " $rprefix";
-      $rprefix2 = " $rprefix2";
-      $lines = $out->getLines(false, $rcontent);
-      foreach ($lines as $rcontent) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $out->iprint($indentLevel, $rprefix, $rcontent);
-        $rprefix = $rprefix2;
-      }
-    }
-  }
-
-  protected function _printGeneric(?string $linePrefix, int $level,
-                                   string $type, $content,
-                                   int $indentLevel, StdOutput $out): void {
-    $prefixes = self::GENERIC_PREFIXES[$level][$type];
-    $content = cl::with($content);
-    if ($out->isColor()) {
-      $prefix = $prefixes[1];
-      $prefix2 = null;
-      if ($prefix !== null) {
-        $prefix .= " ";
-        $prefix2 = $out->filterColors($out->filterContent($prefix));
-        $prefix2 = str_repeat(" ", mb_strlen($prefix2));
-      }
-      $suffix = $prefixes[2];
-      $lines = $out->getLines(false, ...$content);
-      foreach ($lines as $content) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $out->iprint($indentLevel, $prefix, $content, $suffix);
-        $prefix = $prefix2;
-      }
-    } else {
-      $prefix = $prefixes[0];
-      if ($prefix !== null) $prefix .= " ";
-      $prefix2 = str_repeat(" ", mb_strlen($prefix));
-      $lines = $out->getLines(false, ...$content);
-      foreach ($lines as $content) {
-        if ($linePrefix !== null) $out->write($linePrefix);
-        $out->iprint($indentLevel, $prefix, $content);
-        $prefix = $prefix2;
-      }
-    }
-  }
-
-  protected function _printGenericOrException(?int $level, string $type, $content, int $indentLevel, StdOutput $out): void {
-    $linePrefix = $this->getLinePrefix();
-    # si $content contient des exceptions, les afficher avec un level moindre
-    $exceptions = null;
-    if (is_array($content)) {
-      $valueContent = null;
-      foreach ($content as $value) {
-        if ($value instanceof Throwable || $value instanceof ExceptionShadow) {
-          $exceptions[] = $value;
-        } else {
-          $valueContent[] = $value;
-        }
-      }
-      if ($valueContent === null) $content = null;
-      elseif (count($valueContent) == 1) $content = $valueContent[0];
-      else $content = $valueContent;
-    } elseif ($content instanceof Throwable || $content instanceof ExceptionShadow) {
-      $exceptions[] = $content;
-      $content = null;
-    }
-
-    $printActions = true;
-    $showContent = $this->checkLevel($level);
-    if ($content !== null && $showContent) {
-      $this->printActions(); $printActions = false;
-      $this->_printGeneric($linePrefix, $level, $type, $content, $indentLevel, $out);
-    }
-    if ($exceptions !== null) {
-      $level1 = $this->decrLevel($level);
-      $showTraceback = $this->checkLevel($level1);
-      foreach ($exceptions as $exception) {
-        # tout d'abord userMessage
-        if ($exception instanceof UserException) {
-          $userMessage = UserException::get_user_message($exception);
-          $showSummary = true;
-        } else {
-          $userMessage = UserException::get_summary($exception);
-          $showSummary = false;
-        }
-        if ($userMessage !== null && $showContent) {
-          if ($printActions) { $this->printActions(); $printActions = false; }
-          $this->_printGeneric($linePrefix, $level, $type, $userMessage, $indentLevel, $out);
-        }
-        # puis summary et traceback
-        if ($showTraceback) {
-          if ($printActions) { $this->printActions(); $printActions = false; }
-          if ($showSummary) {
-            $summary = UserException::get_summary($exception);
-            $this->_printGeneric($linePrefix, $level1, $type, $summary, $indentLevel, $out);
-          }
-          $traceback = UserException::get_traceback($exception);
-          $this->_printGeneric($linePrefix, $level1, $type, $traceback, $indentLevel, $out);
-        }
-      }
-    }
-  }
-
-  /** @var bool est-on dans une section? */
-  protected $inSection;
-
-  /** @var array section qui est en attente d'affichage */
-  protected $section;
-
-  function section($content, ?callable $func=null, ?int $level=null): void {
-    $this->_endSection();
-    $this->inSection = true;
-    if (!$this->checkLevel($level)) return;
-    $this->section = [
-      "line_prefix" => $this->getLinePrefix(),
-      "level" => $level,
-      "content" => $content,
-      "print_content" => true,
-    ];
-    if ($func !== null) {
-      try {
-        $func($this);
-      } finally {
-        $this->_endSection();
-      }
-    }
-  }
-
-  protected function printSection() {
-    $section =& $this->section;
-    if ($section !== null && $section["print_content"]) {
-      $this->_printTitle(
-        $section["line_prefix"], $section["level"],
-        "section", $section["content"],
-        0, $this->err);
-      $section["print_content"] = false;
-    }
-  }
-
-  function _endSection(): void {
-    $this->inSection = false;
-    $this->section = null;
-  }
-
-  /** @var array */
-  protected $titles;
-
-  /** @var array */
-  protected $title;
-
-  function _getTitleMark(): int {
-    return count($this->titles);
-  }
-
-  function title($content, ?callable $func=null, ?int $level=null): void {
-    if (!$this->checkLevel($level)) return;
-    $until = $this->_getTitleMark();
-    $this->titles[] = [
-      "line_prefix" => $this->getLinePrefix(),
-      "level" => $level,
-      "content" => $content,
-      "print_content" => true,
-      "descs" => [],
-      "print_descs" => false,
-    ];
-    $this->title =& $this->titles[$until];
-    if ($func !== null) {
-      try {
-        $func($this);
-      } finally {
-        $this->_endTitle($until);
-      }
-    }
-  }
-
-  function desc($content, ?int $level=null): void {
-    if (!$this->checkLevel($level)) return;
-    $title =& $this->title;
-    $title["descs"][] = [
-      "line_prefix" => $this->getLinePrefix(),
-      "level" => $level,
-      "content" => $content,
-    ];
-    $title["print_descs"] = true;
-  }
-
-  protected function printTitles(): void {
-    $this->printSection();
-    $err = $this->err;
-    $indentLevel = 0;
-    foreach ($this->titles as &$title) {
-      if ($title["print_content"]) {
-        $this->_printTitle(
-          $title["line_prefix"], $title["level"],
-          "title", $title["content"],
-          $indentLevel, $err);
-        $title["print_content"] = false;
-      }
-      if ($title["print_descs"]) {
-        foreach ($title["descs"] as $desc) {
-          $this->_printGeneric(
-            $desc["line_prefix"], $desc["level"],
-            "desc", $desc["content"],
-            $indentLevel, $err);
-        }
-        $title["descs"] = [];
-        $title["print_descs"] = false;
-      }
-      $indentLevel++;
-    }; unset($title);
-  }
-
-  function _endTitle(?int $until=null): void {
-    if ($until === null) $until = $this->_getTitleMark() - 1;
-    while (count($this->titles) > $until) {
-      array_pop($this->titles);
-    }
-    if ($this->titles) {
-      $this->title =& $this->titles[count($this->titles) - 1];
-    } else {
-      $this->titles = [];
-      unset($this->title);
-    }
-  }
-
-  /** @var array */
-  protected $actions;
-  
-  /** @var array */
-  protected $action;
-
-  function _getActionMark(): int {
-    return count($this->actions);
-  }
-
-  function action($content, ?callable $func=null, ?int $level=null): void {
-    $this->checkLevel($level);
-    $until = $this->_getActionMark();
-    $this->actions[] = [
-      "line_prefix" => $this->getLinePrefix(),
-      "level" => $level,
-      "content" => $content,
-      "print_content" => true,
-      "result_success" => null,
-      "result_content" => null,
-    ];
-    $this->action =& $this->actions[$until];
-    if ($func !== null) {
-      try {
-        $result = $func($this);
-        if ($this->_getActionMark() > $until) {
-          $this->aresult($result);
-        }
-      } catch (Exception $e) {
-        $this->afailure($e);
-        throw $e;
-      } finally {
-        $this->_endAction($until);
-      }
-    }
-  }
-  
-  function printActions(bool $endAction=false, ?int $overrideLevel=null): void {
-    $this->printTitles();
-    $err = $this->err;
-    $indentLevel = $this->getIndentLevel(false);
-    $lastIndex = count($this->actions) - 1;
-    $index = 0;
-    foreach ($this->actions as &$action) {
-      $mergeResult = $index++ == $lastIndex && $endAction;
-      $linePrefix = $action["line_prefix"];
-      $level = $overrideLevel?? $action["level"];
-      $content = $action["content"];
-      $printContent = $action["print_content"];
-      $rsuccess = $action["result_success"];
-      $rcontent = $action["result_content"];
-      if ($level < $this->minLevel) continue;
-      if ($mergeResult) {
-        $this->_printAction(
-          $linePrefix, $level,
-          $printContent, $content,
-          true, $rsuccess, $rcontent,
-          $indentLevel, $err);
-      } elseif ($printContent) {
-        $this->_printAction(
-          $linePrefix, $level,
-          $printContent, $content,
-          false, $rsuccess, $rcontent,
-          $indentLevel, $err);
-        $action["print_content"] = false;
-      }
-      $indentLevel++;
-    }; unset($action);
-    if ($endAction) $this->_endAction();
-  }
-
-  function step($content, ?int $level=null): void {
-    $this->_printGenericOrException($level, "step", $content, $this->getIndentLevel(), $this->err);
-  }
-
-  function asuccess($content=null, ?int $overrideLevel=null): void {
-    if (!$this->actions) $this->action(null);
-    $this->action["result_success"] = true;
-    $this->action["result_content"] = $content;
-    $this->printActions(true, $overrideLevel);
-  }
-
-  function afailure($content=null, ?int $overrideLevel=null): void {
-    if (!$this->actions) $this->action(null);
-    $this->action["result_success"] = false;
-    $this->action["result_content"] = $content;
-    $this->printActions(true, $overrideLevel);
-  }
-
-  function adone($content=null, ?int $overrideLevel=null): void {
-    if (!$this->actions) $this->action(null);
-    $this->action["result_success"] = null;
-    $this->action["result_content"] = $content;
-    $this->printActions(true, $overrideLevel);
-  }
-
-  function aresult($result=null, ?int $overrideLevel=null): void {
-    if (!$this->actions) $this->action(null);
-    if ($result === true) $this->asuccess(null, $overrideLevel);
-    elseif ($result === false) $this->afailure(null, $overrideLevel);
-    elseif ($result instanceof Exception) $this->afailure($result, $overrideLevel);
-    else $this->adone($result, $overrideLevel);
-  }
-
-  function _endAction(?int $until=null): void {
-    if ($until === null) $until = $this->_getActionMark() - 1;
-    while (count($this->actions) > $until) {
-      array_pop($this->actions);
-    }
-    if ($this->actions) {
-      $this->action =& $this->actions[count($this->actions) - 1];
-    } else {
-      $this->actions = [];
-      unset($this->action);
-    }
-  }
-
-  function print($content, ?int $level=null): void {
-    $this->_printGenericOrException($level, "print", $content, $this->getIndentLevel(), $this->out);
-  }
-
-  function info($content, ?int $level=null): void {
-    $this->_printGenericOrException($level, "info", $content, $this->getIndentLevel(), $this->err);
-  }
-
-  function note($content, ?int $level=null): void {
-    $this->_printGenericOrException($level, "note", $content, $this->getIndentLevel(), $this->err);
-  }
-
-  function warning($content, ?int $level=null): void {
-    $this->_printGenericOrException($level, "warning", $content, $this->getIndentLevel(), $this->err);
-  }
-
-  function error($content, ?int $level=null): void {
-    $this->_printGenericOrException($level, "error", $content, $this->getIndentLevel(), $this->err);
-  }
-
-  function end(bool $all=false): void {
-    if ($all) {
-      while ($this->actions) $this->adone();
-      while ($this->titles) $this->_endTitle();
-      $this->_endSection();
-    } elseif ($this->actions) {
-      $this->_endAction();
-    } elseif ($this->titles) {
-      $this->_endTitle();
-    } else {
-      $this->_endSection();
-    }
-  }
-}
diff --git a/php/src/output/std/StdOutput.php b/php/src/output/std/StdOutput.php
index c30c466..945b9b3 100644
--- a/php/src/output/std/StdOutput.php
+++ b/php/src/output/std/StdOutput.php
@@ -79,12 +79,12 @@ class StdOutput {
   }
 
   function resetParams(?array $params=null): void {
-    $output = cl::get($params, "output");
+    $output = $params["output"] ?? null;
     $maskErrors = null;
-    $color = cl::get($params, "color");
-    $filterTags = cl::get($params, "filter_tags");
-    $indent = cl::get($params, "indent");
-    $flush = cl::get($params, "flush");
+    $color = $params["color"] ?? null;
+    $filterTags = $params["filter_tags"] ?? null;
+    $indent = $params["indent"] ?? null;
+    $flush = $params["flush"] ?? null;
 
     if ($output instanceof Stream) $output = $output->getResource();
     if ($output !== null) {
@@ -105,14 +105,14 @@ class StdOutput {
           else $message = "$output: open error";
           throw new Exception($message);
         }
-        if ($flush === null) $flush = true;
+        $flush ??= true;
       } else {
         $outf = $output;
       }
       $this->outf = $outf;
       $this->maskErrors = $maskErrors;
-      if ($color === null) $color = stream_isatty($outf);
-      if ($flush === null) $flush = false;
+      $color ??= stream_isatty($outf);
+      $flush ??= false;
     }
     if ($color !== null) $this->color = boolval($color);
     if ($filterTags !== null) $this->filterTags = boolval($filterTags);
@@ -124,23 +124,23 @@ class StdOutput {
   protected $outf;
 
   /** @var bool faut-il masquer les erreurs d'écriture? */
-  protected $maskErrors;
+  protected ?bool $maskErrors;
 
   /** @var bool faut-il autoriser la sortie en couleur? */
-  protected $color;
+  protected bool $color = false;
 
   function isColor(): bool {
     return $this->color;
   }
 
   /** @var bool faut-il enlever les tags dans la sortie? */
-  protected $filterTags;
+  protected bool $filterTags = true;
 
   /** @var string indentation unitaire */
-  protected $indent;
+  protected string $indent = "  ";
 
   /** @var bool faut-il flush le fichier après l'écriture de chaque ligne */
-  protected $flush;
+  protected bool $flush = true;
 
   function isatty(): bool {
     return stream_isatty($this->outf);
@@ -167,6 +167,7 @@ class StdOutput {
     $text .= "m";
     return $text;
   }
+
   function filterContent(string $text): string {
     # couleur au début
     $text = preg_replace_callback('/]*)>/', [self::class, "replace_colors"], $text);
@@ -178,6 +179,7 @@ class StdOutput {
     }
     return $text;
   }
+
   function filterColors(string $text): string {
     return preg_replace('/\x1B\[.*?m/', "", $text);
   }
diff --git a/php/src/output/std/_IMessenger.php b/php/src/output/std/_IMessenger.php
index 9b54b59..86d84cb 100644
--- a/php/src/output/std/_IMessenger.php
+++ b/php/src/output/std/_IMessenger.php
@@ -7,13 +7,86 @@ use nulib\output\IMessenger;
  * Interface _IMessenger: méthodes privées de IMessenger
  */
 interface _IMessenger extends IMessenger {
-  function _endSection(): void;
+  const INDENT = "  ";
 
-  function _getTitleMark(): int;
+  const DATE_FORMAT = 'Y-m-d\TH:i:s.u';
 
-  function _endTitle(?int $until=null): void;
+  const VALID_LEVELS = [self::DEBUG, self::MINOR, self::NORMAL, self::MAJOR, self::NONE];
 
-  function _getActionMark(): int;
+  const LEVEL_MAP = [
+    "debug" => self::DEBUG,
+    "minor" => self::MINOR, "verbose" => self::MINOR,
+    "normal" => self::NORMAL,
+    "major" => self::MAJOR, "quiet" => self::MAJOR,
+    "none" => self::NONE, "silent" => self::NONE,
+  ];
 
-  function _endAction(?int $until=null): void;
+  const GENERIC_PREFIXES = [
+    self::MAJOR => [
+      "section" => [true, "SECTION!", "===", "=", "=", "==="],
+      "title" => [false, "TITLE!", null, "T", "", "==="],
+      "desc" => ["DESC!", ">", ""],
+      "error" => ["CRIT.ERROR!", "E!", ""],
+      "warning" => ["CRIT.WARNING!", "W!", ""],
+      "note" => ["ATTENTION!", "N!", ""],
+      "info" => ["IMPORTANT!", "N!", ""],
+      "step" => ["*", ".", ""],
+      "print" => [null, null, null],
+    ],
+    self::NORMAL => [
+      "section" => [true, "SECTION:", "---", "-", "-", "---"],
+      "title" => [false, "TITLE:", null, "T", "", "---"],
+      "desc" => ["DESC:", ">", ""],
+      "error" => ["ERROR:", "E", ""],
+      "warning" => ["WARNING:", "W", ""],
+      "note" => ["NOTE:", "N", ""],
+      "info" => ["INFO:", "I", ""],
+      "step" => ["*", ".", ""],
+      "print" => [null, null, null],
+    ],
+    self::MINOR => [
+      "section" => [true, "section", null, ">>", "<<", null],
+      "title" => [false, "title", null, "t", "", null],
+      "desc" => ["desc", ">", ""],
+      "error" => ["error", "E", ""],
+      "warning" => ["warning", "W", ""],
+      "note" => ["note", "N", ""],
+      "info" => ["info", "I", ""],
+      "step" => ["*", ".", ""],
+      "print" => [null, null, null],
+    ],
+    self::DEBUG => [
+      "section" => [true, "section", null, ">>", "<<", null],
+      "title" => [false, "title", null, "t", "", null],
+      "desc" => ["desc", ">", ""],
+      "error" => ["debugE", "e", ""],
+      "warning" => ["debugW", "w", ""],
+      "note" => ["debugN", "i", ""],
+      "info" => ["debug", "D", ""],
+      "step" => ["*", ".", ""],
+      "print" => [null, null, null],
+    ],
+  ];
+
+  const RESULT_PREFIXES = [
+    "failure" => ["(FAILURE)", "✘"],
+    "success" => ["(SUCCESS)", "✔"],
+    "done" => [null, null],
+  ];
+
+  function section__afterFunc(): void;
+
+  /** @return int[] */
+  function title__getMarks(): array;
+  /** @param int[] $marks */
+  function title__beforeFunc(array $marks): void;
+  /** @param int[] $marks */
+  function title__afterFunc(array $marks): void;
+
+  /** @return int[] */
+  function action__getMarks(): array;
+  /** @param int[] $marks */
+  function action__beforeFunc(array $marks): void;
+  /** @param int[] $marks */
+  function action__afterFunc(array $marks, $result): void;
 }
diff --git a/php/src/output/web.php b/php/src/output/web.php
new file mode 100644
index 0000000..c3b6e4b
--- /dev/null
+++ b/php/src/output/web.php
@@ -0,0 +1,15 @@
+getMethods() as $m) {
+      if (($m->getModifiers() & $mask) != $expected) continue;
+      $name = $m->getName();
+      if (substr($name, 0, $length) != $prefix) continue;
+      if (!self::match_name($name, $includes, $excludes)) continue;
+      $method = [$class_or_object, $name];
+      $methods[] = self::with($method, $args);
+    }
+    return $methods;
+  }
+
+  /**
+   * Appeler toutes les méthodes publiques de $object_or_class et retourner un
+   * tableau [$method_name => $return_value] des valeurs de retour.
+   */
+  static function call_all($class_or_object, ?array $params=null) {
+    $methods = self::get_all($class_or_object, $params);
+    $values = [];
+    foreach ($methods as $method) {
+      $values[$method->getName()] = $method->invoke();
+    }
+    return $values;
+  }
+
   #############################################################################
 
   protected function __construct(int $type, $func, ?array $args=null, bool $bound=false, ?string $reason=null) {
@@ -598,6 +680,10 @@ class func {
 
   protected ?array $func;
 
+  function getName(): ?string {
+    return $this->func[1] ?? null;
+  }
+
   protected bool $bound;
 
   protected ?string $reason;
@@ -687,7 +773,7 @@ class func {
     if (is_object($object) && !($this->flags & self::FLAG_STATIC)) {
       if (is_object($c)) $c = get_class($c);
       if (is_string($c) && !($object instanceof $c)) {
-        throw ValueException::invalid_type($object, $c);
+        throw exceptions::invalid_type($object, "object", $c);
       }
       $this->object = $object;
       $this->bound = true;
diff --git a/php/src/php/time/Date.php b/php/src/php/time/Date.php
index c681e68..69a14c8 100644
--- a/php/src/php/time/Date.php
+++ b/php/src/php/time/Date.php
@@ -1,7 +1,7 @@
 setTime(0, 0);
+  protected function fix(DateTimeInterface $datetime): \DateTimeInterface {
+    return $datetime->setTime(0, 0);
+  }
+
+  /** @return MutableDate|self */
+  function clone(bool $mutable=false): DateTimeInterface {
+    if ($mutable) return new MutableDate($this);
+    else return clone $this;
   }
 
   function format($format=self::DEFAULT_FORMAT): string {
diff --git a/php/src/php/time/DateTime.php b/php/src/php/time/DateTime.php
index 438b614..264e945 100644
--- a/php/src/php/time/DateTime.php
+++ b/php/src/php/time/DateTime.php
@@ -1,10 +1,10 @@
 format("H,i,s"));
-    return $h * 3600 + $m * 60 + $s;
-  }
-
-  static function _YmdHMSZ_format(\DateTime $datetime): string {
-    $YmdHMS = $datetime->format("Ymd\\THis");
-    $Z = $datetime->format("P");
-    if ($Z === "+00:00") $Z = "Z";
-    return "$YmdHMS$Z";
-  }
+class DateTime extends \DateTimeImmutable {
+  use _TDateTime;
 
   const DEFAULT_FORMAT = "d/m/Y H:i:s";
-  const INT_FORMATS = [
-    "year" => "Y",
-    "month" => "m",
-    "day" => "d",
-    "hour" => "H",
-    "minute" => "i",
-    "second" => "s",
-    "wday" => "N",
-    "wnum" => "W",
-    "nbsecs" => [self::class, "_nbsecs_format"],
-  ];
-  const STRING_FORMATS = [
-    "timezone" => "P",
-    "datetime" => "d/m/Y H:i:s",
-    "date" => "d/m/Y",
-    "Ymd" => "Ymd",
-    "YmdHMS" => "Ymd\\THis",
-    "YmdHMSZ" => [self::class, "_YmdHMSZ_format"],
-  ];
 
   /**
-   * corriger une année à deux chiffres qui est située dans le passé et
-   * retourner l'année à 4 chiffres.
+   * $datetime est une spécification de date, avec ou sans fuseau horaire
    *
-   * par exemple, si l'année courante est 2019, alors:
-   * - fix_past_year('18') === '2018'
-   * - fix_past_year('19') === '1919'
-   * - fix_past_year('20') === '1920'
-   */
-  static function fix_past_year(int $year): int {
-    if ($year < 100) {
-      $y = getdate(); $y = $y["year"];
-      $r = $y % 100;
-      $c = $y - $r;
-      if ($year >= $r) $year += $c - 100;
-      else $year += $c;
-    }
-    return $year;
-  }
-
-  /**
-   * corriger une année à deux chiffres et retourner l'année à 4 chiffres.
-   * l'année charnière entre année passée et année future est 70
+   * si $datetime ne contient pas de fuseau horaire, elle est réputée être dans
+   * le fuseau $timezone, qui est le fuseau local par défaut
    *
-   * par exemple, si l'année courante est 2019, alors:
-   * - fix_past_year('18') === '2018'
-   * - fix_past_year('19') === '2019'
-   * - fix_past_year('20') === '2020'
-   * - fix_past_year('69') === '2069'
-   * - fix_past_year('70') === '1970'
-   * - fix_past_year('71') === '1971'
+   * si $datetime contient un fuseau horaire et si $forceTimezone est vrai,
+   * alors $datetime est réexprimée dans le fuseau $timezone.
+   * si $timezone est null alors $forceTimezone vaut vrai par défaut.
+   *
+   * datetime         | timezone | forceTimezone | résultat
+   * -----------------|----------|---------------|---------
+   * datetime         | any      | any           | datetime+localtz
+   * datetime+origtz  | null     | null          | datetime+localtz
+   * datetime+origtz  | null     | true          | datetime+localtz
+   * datetime+origtz  | null     | false         | datetime+origtz
+   * datetime+origtz  | newtz    | null          | datetime+origtz
+   * datetime+origtz  | newtz    | false         | datetime+origtz
+   * datetime+origtz  | newtz    | true          | datetime+newtz
    */
-  static function fix_any_year(int $year): int {
-    if ($year < 100) {
-      $y = intval(date("Y"));
-      $r = $y % 100;
-      $c = $y - $r;
-      if ($year >= 70) $year += $c - 100;
-      else $year += $c;
-    }
-    return $year;
-  }
-
-  static function fix_z(?string $Z): ?string {
-    $Z = strtoupper($Z);
-    str::del_prefix($Z, "+");
-    if (preg_match('/^\d{4}$/', $Z)) {
-      $Z = substr($Z, 0, 2).":".substr($Z, 2);
-    }
-    if ($Z === "Z" || $Z === "UTC" || $Z === "00:00") return "UTC";
-    return "GMT+$Z";
-  }
-
-  function __construct($datetime="now", DateTimeZone $timezone=null, ?bool $forceLocalTimezone=null) {
-    $forceLocalTimezone ??= $timezone === null;
-    if ($forceLocalTimezone) {
-      $setTimezone = $timezone;
+  function __construct($datetime=null, DateTimeZone $timezone=null, ?bool $forceTimezone=null) {
+    $resetTimezone = null;
+    $forceTimezone ??= $timezone === null;
+    if ($forceTimezone) {
+      $resetTimezone = $timezone ?? new DateTimeZone(date_default_timezone_get());
       $timezone = null;
     }
 
     $datetime ??= "now";
-    if ($datetime instanceof \DateTimeInterface) {
-      $timezone ??= $datetime->getTimezone();
-      parent::__construct();
-      $this->setTimestamp($datetime->getTimestamp());
-      $this->setTimezone($timezone);
+    if ($datetime instanceof DateTimeImmutable) {
+      $datetime = \DateTime::createFromImmutable($datetime);
+    } elseif ($datetime instanceof \DateTime) {
+      $datetime = clone $datetime;
+      #XXX sous PHP 8, remplacer les deux commandes ci-dessus par
+      # DateTime::createFromInterface
 
     } elseif (is_int($datetime)) {
-      parent::__construct("now", $timezone);
-      $this->setTimestamp($datetime);
+      $timestamp = $datetime;
+      $datetime = new \DateTime("now", $timezone);
+      $datetime->setTimestamp($timestamp);
 
     } elseif (is_string($datetime)) {
       $Y = $H = $Z = null;
-      if (preg_match(self::DMY_PATTERN, $datetime, $ms)) {
+      if (preg_match(_utils::DMY_PATTERN, $datetime, $ms)) {
         $Y = $ms[3] ?? null;
-        if ($Y !== null) $Y = self::fix_any_year(intval($Y));
+        if ($Y !== null) $Y = _utils::fix_any_year(intval($Y));
         else $Y = intval(date("Y"));
         $m = intval($ms[2]);
         $d = intval($ms[1]);
-      } elseif (preg_match(self::YMD_PATTERN, $datetime, $ms)) {
+      } elseif (preg_match(_utils::YMD_PATTERN, $datetime, $ms)) {
         $Y = $ms[1];
-        if (strlen($Y) == 2) $Y = self::fix_any_year(intval($ms[1]));
+        if (strlen($Y) == 2) $Y = _utils::fix_any_year(intval($ms[1]));
         else $Y = intval($Y);
         $m = intval($ms[2]);
         $d = intval($ms[3]);
-      } elseif (preg_match(self::DMYHIS_PATTERN, $datetime, $ms)) {
+      } elseif (preg_match(_utils::DMYHIS_PATTERN, $datetime, $ms)) {
         $Y = $ms[3];
-        if ($Y !== null) $Y = self::fix_any_year(intval($Y));
+        if ($Y !== null) $Y = _utils::fix_any_year(intval($Y));
         else $Y = intval(date("Y"));
         $m = intval($ms[2]);
         $d = intval($ms[1]);
         $H = intval($ms[4]);
         $M = intval($ms[5]);
         $S = intval($ms[6] ?? 0);
-      } elseif (preg_match(self::YMDHISZ_PATTERN, $datetime, $ms)) {
+      } elseif (preg_match(_utils::YMDHISZ_PATTERN, $datetime, $ms)) {
         $Y = $ms[1];
-        if (strlen($Y) == 2) $Y = self::fix_any_year(intval($ms[1]));
+        if (strlen($Y) == 2) $Y = _utils::fix_any_year(intval($ms[1]));
         else $Y = intval($Y);
         $m = intval($ms[2]);
         $d = intval($ms[3]);
@@ -281,73 +107,61 @@ class DateTime extends \DateTime {
       if ($Y !== null) {
         if ($H === null) $datetime = sprintf("%04d-%02d-%02d", $Y, $m, $d);
         else $datetime = sprintf("%04d-%02d-%02d %02d:%02d:%02d", $Y, $m, $d, $H, $M, $S);
-        if ($Z !== null) $timezone ??= new DateTimeZone(self::fix_z($Z));
+        if ($Z !== null) $timezone = new DateTimeZone(_utils::fix_z($Z));
       }
-      parent::__construct($datetime, $timezone);
+      $datetime = new \DateTime($datetime, $timezone);
 
-    } elseif (is_array($datetime) && ($datetime = self::parse_array($datetime)) !== null) {
-      $setTimezone = $timezone;
-      $timezone = null;
+    } elseif (is_array($datetime) && ($datetime = _utils::parse_array($datetime)) !== null) {
       [$Y, $m, $d, $H, $M, $S, $Z] = $datetime;
       if ($H === null && $M === null && $S === null) {
         $datetime = sprintf("%04d-%02d-%02d", $Y, $m, $d);
       } else {
         $datetime = sprintf("%04d-%02d-%02d %02d:%02d:%02d", $Y, $m, $d, $H ?? 0, $M ?? 0, $S ?? 0);
       }
-      if ($Z !== null) $timezone ??= new DateTimeZone(self::fix_z($Z));
-      parent::__construct($datetime, $timezone);
+      if ($Z !== null) $timezone = new DateTimeZone(_utils::fix_z($Z));
+      $datetime = new \DateTime($datetime, $timezone);
+    }
 
-    } else {
+    if ($datetime instanceof DateTimeInterface) {
+      if ($resetTimezone !== null) $datetime->setTimezone($resetTimezone);
+      $datetime = $this->fix($datetime);
+      parent::__construct($datetime->format("Y-m-d\\TH:i:s.uP"));
+    }  else {
       throw new InvalidArgumentException("datetime must be a string or an instance of DateTimeInterface");
     }
-
-    if ($forceLocalTimezone) {
-      $setTimezone ??= new DateTimeZone(date_default_timezone_get());
-      $this->setTimezone($setTimezone);
-    }
   }
 
-  function clone(): self {
-    return clone $this;
+  protected function fix(DateTimeInterface $datetime): DateTimeInterface {
+    return $datetime;
   }
 
-  function diff($target, $absolute=false): DateInterval {
-    return new DateInterval(parent::diff($target, $absolute));
-  }
-
-  function format($format=self::DEFAULT_FORMAT): string {
-    if (array_key_exists($format, self::INT_FORMATS)) {
-      $format = self::INT_FORMATS[$format];
-    } elseif (array_key_exists($format, self::STRING_FORMATS)) {
-      $format = self::STRING_FORMATS[$format];
-    }
-    if (is_callable($format)) return $format($this);
-    else return \DateTime::format($format);
+  /** @return MutableDateTime|self */
+  function clone(bool $mutable=false): DateTimeInterface {
+    if ($mutable) return new MutableDateTime($this);
+    else return clone $this;
   }
 
   /**
    * modifier cet objet pour que l'heure soit à 00:00:00 ce qui le rend propice
    * à l'utilisation comme borne inférieure d'une période
    */
-  function wrapStartOfDay(): self {
-    $this->setTime(0, 0);
-    return $this;
+  function getStartOfDay(): self {
+    return new static($this->setTime(0, 0));
   }
 
   /**
    * modifier cet objet pour que l'heure soit à 23:59:59.999999 ce qui le rend
    * propice à l'utilisation comme borne supérieure d'une période
    */
-  function wrapEndOfDay(): self {
-    $this->setTime(23, 59, 59, 999999);
-    return $this;
+  function getEndOfDay(): self {
+    return new static($this->setTime(23, 59, 59, 999999));
   }
 
   function getPrevDay(int $nbDays=1, bool $skipWeekend=false): self {
     if ($nbDays == 1 && $skipWeekend && $this->wday == 1) {
-      $nbdays = 3;
+      $nbDays = 3;
     }
-    return static::with($this->sub(new \DateInterval("P${nbDays}D")));
+    return new static($this->sub(new \DateInterval("P${nbDays}D")));
   }
 
   function getNextDay(int $nbDays=1, bool $skipWeekend=false): self {
@@ -355,35 +169,6 @@ class DateTime extends \DateTime {
       $wday = $this->wday;
       if ($wday > 5) $nbDays = 8 - $this->wday;
     }
-    return static::with($this->add(new \DateInterval("P${nbDays}D")));
-  }
-
-  function getElapsedAt(?DateTime $now=null, ?int $resolution=null): string {
-    return Elapsed::format_at($this, $now, $resolution);
-  }
-
-  function getElapsedSince(?DateTime $now=null, ?int $resolution=null): string {
-    return Elapsed::format_since($this, $now, $resolution);
-  }
-
-  function getElapsedDelay(?DateTime $now=null, ?int $resolution=null): string {
-    return Elapsed::format_delay($this, $now, $resolution);
-  }
-
-  function __toString(): string {
-    return $this->format();
-  }
-
-  function __get($name) {
-    if (array_key_exists($name, self::INT_FORMATS)) {
-      $format = self::INT_FORMATS[$name];
-      if (is_callable($format)) return intval($format($this));
-      else return intval($this->format($format));
-    } elseif (array_key_exists($name, self::STRING_FORMATS)) {
-      $format = self::STRING_FORMATS[$name];
-      if (is_callable($format)) return $format($this);
-      else return $this->format($format);
-    }
-    throw new InvalidArgumentException("Unknown property $name");
+    return new static($this->add(new \DateInterval("P${nbDays}D")));
   }
 }
diff --git a/php/src/php/time/Delay.php b/php/src/php/time/Delay.php
index e773efa..15aa96f 100644
--- a/php/src/php/time/Delay.php
+++ b/php/src/php/time/Delay.php
@@ -3,7 +3,6 @@ namespace nulib\php\time;
 
 use DateTimeInterface;
 use InvalidArgumentException;
-use nulib\ValueException;
 
 /**
  * Class Delay: une durée jusqu'à un moment destination. le moment destination
@@ -31,6 +30,11 @@ class Delay {
     else return new static($delay, $from);
   }
 
+  /**
+   * pour une durée infinie, l'intervalle est toujours de 1000 ans dans le futur
+   */
+  const INF_INTERVAL = "P1000Y";
+
   /** valeurs par défaut de x et y pour les unités supportées */
   const DEFAULTS = [
     "w" => [0, 5],
@@ -40,8 +44,7 @@ class Delay {
     "s" => [1, 0],
   ];
 
-  static function compute_dest(int $x, string $u, ?int $y, ?DateTimeInterface $from): array {
-    $dest = DateTime::with($from)->clone();
+  protected static function compute_dest(int $x, string $u, ?int $y, MutableDateTime $dest): array {
     $yu = null;
     switch ($u) {
     case "w":
@@ -90,10 +93,10 @@ class Delay {
   }
 
   function __construct($delay, ?DateTimeInterface $from=null) {
-    if ($from === null) $from = new DateTime();
-    if ($delay === "INF") {
-      $dest = DateTime::with($from)->clone();
-      $dest->add(new DateInterval("P9999Y"));
+    $from = MutableDateTime::with($from)->clone(true);
+    if ($delay === null || $delay === "INF") {
+      # $dest === null signifie un délai infini
+      $dest = null;
       $repr = "INF";
     } elseif (is_int($delay)) {
       [$dest, $repr] = self::compute_dest($delay, "s", null, $from);
@@ -117,37 +120,53 @@ class Delay {
   }
 
   function __clone() {
-    $this->dest = clone $this->dest;
+    if ($this->dest !== null) {
+      $this->dest = clone $this->dest;
+    }
   }
 
   function __serialize(): array {
-    return [$this->dest, $this->repr];
+    $dest = $this->dest;
+    if ($dest !== null) $dest = $dest->clone();
+    return [$dest, $this->repr];
   }
   function __unserialize(array $data): void {
-    [$this->dest, $this->repr] = $data;
+    [$dest, $this->repr] = $data;
+    if ($dest !== null) $dest = $dest->clone(true);
+    $this->dest = $dest;
   }
 
-  /** @var DateTime */
-  protected $dest;
+  protected ?MutableDateTime $dest;
 
   function getDest(): DateTime {
-    return $this->dest;
+    $dest = $this->dest;
+    if ($dest === null) {
+      $dest = new MutableDateTime();
+      $dest->add(new \DateInterval(self::INF_INTERVAL));
+    }
+    return $dest->clone();
   }
 
-  function addDuration($duration) {
-    if (is_numeric($duration) && $duration < 0) {
-      $this->dest->sub(DateInterval::with(-$duration));
-    } else {
-      $this->dest->add(DateInterval::with($duration));
+  function addDuration($duration): self {
+    if ($this->dest !== null) {
+      if (is_numeric($duration) && $duration < 0) {
+        $this->dest->sub(DateInterval::with(-$duration));
+      } else {
+        $this->dest->add(DateInterval::with($duration));
+      }
     }
+    return $this;
   }
 
-  function subDuration($duration) {
-    if (is_numeric($duration) && $duration < 0) {
-      $this->dest->add(DateInterval::with(-$duration));
-    } else {
-      $this->dest->sub(DateInterval::with($duration));
+  function subDuration($duration): self {
+    if ($this->dest !== null) {
+      if (is_numeric($duration) && $duration < 0) {
+        $this->dest->add(DateInterval::with(-$duration));
+      } else {
+        $this->dest->sub(DateInterval::with($duration));
+      }
     }
+    return $this;
   }
 
   /** @var string */
@@ -157,23 +176,20 @@ class Delay {
     return $this->repr;
   }
 
-  protected function _getDiff(?DateTimeInterface $now=null): \DateInterval {
-    if ($now === null) $now = new DateTime();
-    return $this->dest->diff($now);
-  }
-
-  /** retourner true si le délai imparti est écoulé */
-  function isElapsed(?DateTimeInterface $now=null): bool {
-    if ($this->repr === "INF") return false;
-    else return $this->_getDiff($now)->invert == 0;
-  }
-
   /**
    * retourner l'intervalle entre le moment courant et la destination.
    *
    * l'intervalle est négatif si le délai n'est pas écoulé, positif sinon
    */
   function getDiff(?DateTimeInterface $now=null): DateInterval {
-    return new DateInterval($this->_getDiff($now));
+    $dest = $this->dest;
+    if ($dest !== null) return $dest->diff($now ?? new \DateTime());
+    else return new DateInterval("-".self::INF_INTERVAL);
+  }
+
+  /** retourner true si le délai imparti est écoulé */
+  function isElapsed(?DateTimeInterface $now=null): bool {
+    if ($this->dest === null) return false;
+    else return $this->getDiff($now)->invert == 0;
   }
 }
diff --git a/php/src/php/time/Elapsed.php b/php/src/php/time/Elapsed.php
index 37f22c6..1d3f49a 100644
--- a/php/src/php/time/Elapsed.php
+++ b/php/src/php/time/Elapsed.php
@@ -1,6 +1,8 @@
 getTimestamp() - $start->getTimestamp();
     return (new self($seconds, $resolution))->formatAt();
   }
-  
-  static function format_since(DateTime $start, ?DateTime $now=null, ?int $resolution=null): string {
+
+  static function format_since(DateTimeInterface $start, ?DateTimeInterface $now=null, ?int $resolution=null): string {
     $now ??= new DateTime();
     $seconds = $now->getTimestamp() - $start->getTimestamp();
     return (new self($seconds, $resolution))->formatSince();
   }
-  
-  static function format_delay(DateTime $start, ?DateTime $now=null, ?int $resolution=null): string {
+
+  static function format_delay(DateTimeInterface $start, ?DateTimeInterface $now=null, ?int $resolution=null): string {
     $now ??= new DateTime();
     $seconds = $now->getTimestamp() - $start->getTimestamp();
     return (new self($seconds, $resolution))->formatDelay();
diff --git a/php/src/php/time/MutableDate.php b/php/src/php/time/MutableDate.php
new file mode 100644
index 0000000..1f01428
--- /dev/null
+++ b/php/src/php/time/MutableDate.php
@@ -0,0 +1,24 @@
+setTime(0, 0);
+  }
+
+  /** @return Date|self */
+  function clone(bool $mutable=false): DateTimeInterface {
+    if ($mutable) return clone $this;
+    else return new Date($this);
+  }
+
+  function format($format=self::DEFAULT_FORMAT): string {
+    return parent::format($format);
+  }
+}
diff --git a/php/src/php/time/MutableDateTime.php b/php/src/php/time/MutableDateTime.php
new file mode 100644
index 0000000..1083444
--- /dev/null
+++ b/php/src/php/time/MutableDateTime.php
@@ -0,0 +1,180 @@
+getTimezone();
+      parent::__construct();
+      $this->setTimestamp($datetime->getTimestamp());
+      $this->setTimezone($timezone);
+
+    } elseif (is_int($datetime)) {
+      parent::__construct("now", $timezone);
+      $this->setTimestamp($datetime);
+
+    } elseif (is_string($datetime)) {
+      $Y = $H = $Z = null;
+      if (preg_match(_utils::DMY_PATTERN, $datetime, $ms)) {
+        $Y = $ms[3] ?? null;
+        if ($Y !== null) $Y = _utils::fix_any_year(intval($Y));
+        else $Y = intval(date("Y"));
+        $m = intval($ms[2]);
+        $d = intval($ms[1]);
+      } elseif (preg_match(_utils::YMD_PATTERN, $datetime, $ms)) {
+        $Y = $ms[1];
+        if (strlen($Y) == 2) $Y = _utils::fix_any_year(intval($ms[1]));
+        else $Y = intval($Y);
+        $m = intval($ms[2]);
+        $d = intval($ms[3]);
+      } elseif (preg_match(_utils::DMYHIS_PATTERN, $datetime, $ms)) {
+        $Y = $ms[3];
+        if ($Y !== null) $Y = _utils::fix_any_year(intval($Y));
+        else $Y = intval(date("Y"));
+        $m = intval($ms[2]);
+        $d = intval($ms[1]);
+        $H = intval($ms[4]);
+        $M = intval($ms[5]);
+        $S = intval($ms[6] ?? 0);
+      } elseif (preg_match(_utils::YMDHISZ_PATTERN, $datetime, $ms)) {
+        $Y = $ms[1];
+        if (strlen($Y) == 2) $Y = _utils::fix_any_year(intval($ms[1]));
+        else $Y = intval($Y);
+        $m = intval($ms[2]);
+        $d = intval($ms[3]);
+        $H = intval($ms[4]);
+        $M = intval($ms[5]);
+        $S = intval($ms[6] ?? 0);
+        $Z = $ms[7] ?? null;
+      }
+      if ($Y !== null) {
+        if ($H === null) $datetime = sprintf("%04d-%02d-%02d", $Y, $m, $d);
+        else $datetime = sprintf("%04d-%02d-%02d %02d:%02d:%02d", $Y, $m, $d, $H, $M, $S);
+        if ($Z !== null) $timezone = new DateTimeZone(_utils::fix_z($Z));
+      }
+      parent::__construct($datetime, $timezone);
+
+    } elseif (is_array($datetime) && ($datetime = _utils::parse_array($datetime)) !== null) {
+      [$Y, $m, $d, $H, $M, $S, $Z] = $datetime;
+      if ($H === null && $M === null && $S === null) {
+        $datetime = sprintf("%04d-%02d-%02d", $Y, $m, $d);
+      } else {
+        $datetime = sprintf("%04d-%02d-%02d %02d:%02d:%02d", $Y, $m, $d, $H ?? 0, $M ?? 0, $S ?? 0);
+      }
+      if ($Z !== null) $timezone = new DateTimeZone(_utils::fix_z($Z));
+      parent::__construct($datetime, $timezone);
+
+    } else {
+      throw new InvalidArgumentException("datetime must be a string or an instance of DateTimeInterface");
+    }
+
+    if ($resetTimezone !== null) $this->setTimezone($resetTimezone);
+  }
+
+  /** @return DateTime|self */
+  function clone(bool $mutable=false): DateTimeInterface {
+    if ($mutable) return clone $this;
+    else return new DateTime($this);
+  }
+
+  /**
+   * modifier cet objet pour que l'heure soit à 00:00:00 ce qui le rend propice
+   * à l'utilisation comme borne inférieure d'une période
+   */
+  function setStartOfDay(): self {
+    $this->setTime(0, 0);
+    return $this;
+  }
+  function getStartOfDay(): self {
+    return $this->clone(true)->setStartOfDay();
+  }
+
+  /**
+   * modifier cet objet pour que l'heure soit à 23:59:59.999999 ce qui le rend
+   * propice à l'utilisation comme borne supérieure d'une période
+   */
+  function setEndOfDay(): self {
+    $this->setTime(23, 59, 59, 999999);
+    return $this;
+  }
+  function getEndOfDay(): self {
+    return $this->clone(true)->setEndOfDay();
+  }
+
+  function setPrevDay(int $nbDays=1, bool $skipWeekend=false): self {
+    if ($nbDays == 1 && $skipWeekend && $this->wday == 1) {
+      $nbDays = 3;
+    }
+    $this->sub(new \DateInterval("P${nbDays}D"));
+    return $this;
+  }
+  function getPrevDay(int $nbDays=1, bool $skipWeekend=false): self {
+    return $this->clone(true)->setPrevDay($nbDays, $skipWeekend);
+  }
+
+  function setNextDay(int $nbDays=1, bool $skipWeekend=false): self {
+    if ($nbDays == 1 && $skipWeekend) {
+      $wday = $this->wday;
+      if ($wday > 5) $nbDays = 8 - $this->wday;
+    }
+    $this->add(new \DateInterval("P${nbDays}D"));
+    return $this;
+  }
+  function getNextDay(int $nbDays=1, bool $skipWeekend=false): self {
+    return $this->clone(true)->setNextDay($nbDays, $skipWeekend);
+  }
+}
diff --git a/php/src/php/time/TODO.md b/php/src/php/time/TODO.md
index edf17b0..ffa6b2e 100644
--- a/php/src/php/time/TODO.md
+++ b/php/src/php/time/TODO.md
@@ -1,10 +1,3 @@
 # nulib\php\time
 
-* Date, DateTime sont immutables par défaut. par exemple, add() retourne un nouvel objet.
-  ajouter une version des méthodes qui modifie les données en place en les
-  préfixant de `_` e.g `_add()`
-
-  en terme d'implémentation, dériver \DateTime pour supporter les modification
-  en place, bien que ce ne soit pas le fonctionnement par défaut
-
 -*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary
\ No newline at end of file
diff --git a/php/src/php/time/_TDateTime.php b/php/src/php/time/_TDateTime.php
new file mode 100644
index 0000000..755ec31
--- /dev/null
+++ b/php/src/php/time/_TDateTime.php
@@ -0,0 +1,103 @@
+format();
+  }
+
+  function __get($name) {
+    if (array_key_exists($name, _utils::INT_FORMATS)) {
+      $format = _utils::INT_FORMATS[$name];
+      if (is_callable($format)) return intval($format($this));
+      else return intval($this->format($format));
+    } elseif (array_key_exists($name, _utils::STRING_FORMATS)) {
+      $format = _utils::STRING_FORMATS[$name];
+      if (is_callable($format)) return $format($this);
+      else return $this->format($format);
+    }
+    throw new InvalidArgumentException("Unknown property $name");
+  }
+
+  function getElapsedAt(?DateTimeInterface $now=null, ?int $resolution=null): string {
+    return Elapsed::format_at($this, $now, $resolution);
+  }
+
+  function getElapsedSince(?DateTimeInterface $now=null, ?int $resolution=null): string {
+    return Elapsed::format_since($this, $now, $resolution);
+  }
+
+  function getElapsedDelay(?DateTimeInterface $now=null, ?int $resolution=null): string {
+    return Elapsed::format_delay($this, $now, $resolution);
+  }
+}
diff --git a/php/src/php/time/_utils.php b/php/src/php/time/_utils.php
new file mode 100644
index 0000000..aeec99e
--- /dev/null
+++ b/php/src/php/time/_utils.php
@@ -0,0 +1,147 @@
+format("H,i,s"));
+    return $h * 3600 + $m * 60 + $s;
+  }
+
+  static function _YmdHMSZ_format(\DateTimeInterface $datetime): string {
+    $YmdHMS = $datetime->format("Ymd\\THis");
+    $Z = $datetime->format("P");
+    if ($Z === "+00:00") $Z = "Z";
+    return "$YmdHMS$Z";
+  }
+
+  const INT_FORMATS = [
+    "year" => "Y",
+    "month" => "m",
+    "day" => "d",
+    "hour" => "H",
+    "minute" => "i",
+    "second" => "s",
+    "wday" => "N",
+    "wnum" => "W",
+    "nbsecs" => [self::class, "_nbsecs_format"],
+  ];
+  const STRING_FORMATS = [
+    "timezone" => "P",
+    "datetime" => "d/m/Y H:i:s",
+    "date" => "d/m/Y",
+    "Ymd" => "Ymd",
+    "YmdHMS" => "Ymd\\THis",
+    "YmdHMSZ" => [self::class, "_YmdHMSZ_format"],
+  ];
+
+  /**
+   * corriger une année à deux chiffres qui est située dans le passé et
+   * retourner l'année à 4 chiffres.
+   *
+   * par exemple, si l'année courante est 2019, alors:
+   * - fix_past_year('18') === '2018'
+   * - fix_past_year('19') === '1919'
+   * - fix_past_year('20') === '1920'
+   */
+  static function fix_past_year(int $year): int {
+    if ($year < 100) {
+      $y = getdate();
+      $y = $y["year"];
+      $r = $y % 100;
+      $c = $y - $r;
+      if ($year >= $r) $year += $c - 100;
+      else $year += $c;
+    }
+    return $year;
+  }
+
+  /**
+   * corriger une année à deux chiffres et retourner l'année à 4 chiffres.
+   * l'année charnière entre année passée et année future est 70
+   *
+   * par exemple, si l'année courante est 2019, alors:
+   * - fix_past_year('18') === '2018'
+   * - fix_past_year('19') === '2019'
+   * - fix_past_year('20') === '2020'
+   * - fix_past_year('69') === '2069'
+   * - fix_past_year('70') === '1970'
+   * - fix_past_year('71') === '1971'
+   */
+  static function fix_any_year(int $year): int {
+    if ($year < 100) {
+      $y = intval(date("Y"));
+      $r = $y % 100;
+      $c = $y - $r;
+      if ($year >= 70) $year += $c - 100;
+      else $year += $c;
+    }
+    return $year;
+  }
+
+  static function fix_z(?string $Z): ?string {
+    $Z = strtoupper($Z);
+    str::del_prefix($Z, "+");
+    if (preg_match('/^\d{4}$/', $Z)) {
+      $Z = substr($Z, 0, 2) . ":" . substr($Z, 2);
+    }
+    if ($Z === "Z" || $Z === "UTC" || $Z === "00:00") return "UTC";
+    return "GMT+$Z";
+  }
+
+  static function get_value(array $datetime, ?string $key, ?string $k, ?int $index) {
+    $value = null;
+    if ($value === null && $key !== null) $value = $datetime[$key] ?? null;
+    if ($value === null && $k !== null) $value = $datetime[$k] ?? null;
+    if ($value === null && $index !== null) $value = $datetime[$index] ?? null;
+    return $value;
+  }
+
+  static function parse_int(array $datetime, ?string $key, ?string $k, ?int $index, ?int &$part, bool $required = true, ?int $default = null): bool {
+    $part = null;
+    $value = self::get_value($datetime, $key, $k, $index);
+    if ($value === null) {
+      if ($required && $default === null) return false;
+      $part = $default;
+      return true;
+    }
+    if (is_numeric($value)) {
+      $part = intval($value);
+      return true;
+    }
+    return false;
+  }
+
+  static function parse_str(array $datetime, ?string $key, ?string $k, ?int $index, ?string &$part, bool $required = true, ?string $default = null): bool {
+    $part = null;
+    $value = self::get_value($datetime, $key, $k, $index);
+    if ($value === null) {
+      if ($required && $default === null) return false;
+      $part = $default;
+      return true;
+    }
+    if (is_string($value)) {
+      $part = $value;
+      return true;
+    }
+    return false;
+  }
+
+  static function parse_array(array $datetime): ?array {
+    if (!self::parse_int($datetime, "year", "Y", 0, $year)) return null;
+    if (!self::parse_int($datetime, "month", "m", 1, $month)) return null;
+    if (!self::parse_int($datetime, "day", "d", 2, $day)) return null;
+    self::parse_int($datetime, "hour", "H", 3, $hour, false);
+    self::parse_int($datetime, "minute", "M", 4, $minute, false);
+    self::parse_int($datetime, "second", "S", 5, $second, false);
+    self::parse_str($datetime, "tz", null, 6, $tz, false);
+    return [$year, $month, $day, $hour, $minute, $second, $tz];
+  }
+}
diff --git a/php/src/php/types/varray.php b/php/src/php/types/varray.php
new file mode 100644
index 0000000..3a6b416
--- /dev/null
+++ b/php/src/php/types/varray.php
@@ -0,0 +1,24 @@
+ [null, null, "tableau contenant des paramètres et des options par défaut"],
-    "merge_arrays" => [null, null, "liste de tableaux à merger à celui-ci avant de calculer la liste effective des options"],
-    "merge" => [null, null, "tableau à merger à celui-ci avant de calculer la liste effective des options",
-      # si merge_arrays et merge sont spécifiés tous les deux, "merge" est mergé après "merge_arrays"
+    "merges" => ["?array", null, "liste de tableaux contenant des paramètres et des options par défaut"],
+    "merge" => ["?array", null, "tableau contenant des paramètres et des options par défaut",
+      # si merges et merge sont spécifiés tous les deux, "merge" est mergé après "merges"
     ],
+    "merge_after" => ["?array", null, "tableau contenant des paramètres et des options supplémentaires"],
     "prefix" => [null, null, "texte à afficher avant l'aide générée automatiquement"],
     "name" => [null, null, "nom du programme, utilisé pour l'affichage de l'aide"],
     "purpose" => [null, null, "courte description de l'objet de ce programme"],
@@ -51,34 +51,34 @@ class ref_args {
   ];
 
   const DEF_SCHEMA = [
-    "set_defaults" => [null, null, "tableau contenant des paramètres par défaut"],
-    "merge_arrays" => [null, null, "liste de tableaux à merger à celui-ci"],
-    "merge" => [null, null, "tableau à merger à celui-ci",
-      # si merge_arrays et merge sont spécifiés tous les deux, "merge" est mergé après "merge_arrays"
+    "merges" => ["array", null, "liste de tableaux contenant des paramètres et des options par défaut"],
+    "merge" => ["array", null, "tableau contenant des paramètres et des options par défaut",
+      # si merges et merge sont spécifiés tous les deux, "merge" est mergé après "merges"
     ],
-    "kind" => [null, null, "type de définition: 'option' ou 'command'"],
-    "arg" => [null, null, "type de l'argument attendu par l'option"],
-    "args" => [null, null, "type des arguments attendus par l'option",
+    "merge_after" => ["array", null, "tableau contenant des paramètres et des options supplémentaires"],
+    "extends" => ["string", null, "option que cette définition étend"],
+    "add" => ["array", null, "options à rajouter"],
+    "remove" => ["array", null, "options à enlever"],
+    "show" => ["bool", true, "faut-il afficher cette option par défaut?"],
+    "disabled" => ["bool", false, "cette option est-elle désactivée?"],
+    "arg" => ["?string|int|bool", null, "type de l'argument attendu par l'option"],
+    "args" => ["?array", null, "type des arguments attendus par l'option",
       # si args est spécifié, arg est ignoré
     ],
-    "argsdesc" => [null, null, "description textuelle des arguments, utilisé pour l'affichage de l'aide"],
-    "type" => [null, null, "types dans lesquels convertir les arguments avant de les fournir à l'utilisateur"],
-    "action" => [null, null, "fonction à appeler quand cette option est utilisée",
+    "argsdesc" => ["?string", null, "description textuelle des arguments, utilisé pour l'affichage de l'aide"],
+    "type" => ["schema", null, "type dans lequel convertir les arguments avant de les fournir à l'utilisateur"],
+    "ensure_array" => ["bool", false, "forcer la destination à être un tableau"],
+    "action" => ["callable", null, "fonction à appeler quand cette option est utilisée",
       # la signature de la fonction est ($value, $name, $arg, $dest, $def)
     ],
-    "name" => [null, null, "propriété ou clé à initialiser en réponse à l'utilisation de cette option",
+    "inverse" => ["bool", false, "décrémenter la destination au lieu de l'incrémenter pour une option sans argument"],
+    "name" => ["?string", null, "propriété ou clé à initialiser en réponse à l'utilisation de cette option",
       # le nom à spécifier est au format under_score, qui est transformée en camelCase si la destination est un objet
     ],
-    "property" => [null, null, "comme name mais force l'utilisation d'une propriété"],
-    "key" => [null, null, "comme name mais force l'utilisation d'une clé"],
-    "inverse" => ["bool", false, "décrémenter la destination au lieu de l'incrémenter pour une option sans argument"],
+    "property" => ["?string", null, "comme name mais force l'utilisation d'une propriété"],
+    "key" => ["?key", null, "comme name mais force l'utilisation d'une clé"],
     "value" => ["mixed", null, "valeur à forcer au lieu d'incrémenter la destination"],
-    "ensure_array" => [null, null, "forcer la destination à être un tableau"],
     "help" => [null, null, "description de cette option, utilisé pour l'affichage de l'aide"],
-    "cmd_args" => [null, null, "définition des sous-options pour une commande"],
-
-    # ces valeurs sont calculées
-    "cmd_defs" => [null, null, "(interne) liste des définitions correspondant au paramètre options"],
   ];
 
   const ARGS_ALLOWED_VALUES = ["value", "path", "dir", "file", "host"];
diff --git a/php/src/ref/ref_cache.php b/php/src/ref/ref_cache.php
new file mode 100644
index 0000000..d1dbf0a
--- /dev/null
+++ b/php/src/ref/ref_cache.php
@@ -0,0 +1,11 @@
+
+
+ true,
+    self::TEST => true,
+  ];
+}
diff --git a/php/src/ref/schema/ref_schema.php b/php/src/ref/ref_schema.php
similarity index 99%
rename from php/src/ref/schema/ref_schema.php
rename to php/src/ref/ref_schema.php
index 37bea48..b1cda6d 100644
--- a/php/src/ref/schema/ref_schema.php
+++ b/php/src/ref/ref_schema.php
@@ -1,5 +1,5 @@
  "bool",
+    "integer" => "int",
+    "flt" => "float", "double" => "float", "dbl" => "float",
+    "function" => "func", "callable" => "func",
+  ];
+}
diff --git a/php/src/ref/schema/ref_types.php b/php/src/ref/schema/ref_types.php
deleted file mode 100644
index d7ce1d4..0000000
--- a/php/src/ref/schema/ref_types.php
+++ /dev/null
@@ -1,11 +0,0 @@
- "bool",
-    "integer" => "int",
-    "flt" => "float", "double" => "float", "dbl" => "float",
-    "func" => "callable", "function" => "callable",
-  ];
-}
diff --git a/php/src/str.php b/php/src/str.php
index 6e09c51..0614ad1 100644
--- a/php/src/str.php
+++ b/php/src/str.php
@@ -110,7 +110,7 @@ class str {
     if ($s === null) return null;
     else return ucfirst($s);
   }
-  
+
   static final function upperw(?string $s, ?string $delimiters=null): ?string {
     if ($s === null) return null;
     if ($delimiters !== null) return ucwords($s, $delimiters);
@@ -392,6 +392,18 @@ class str {
     return implode($glue, $pieces);
   }
 
+  /**
+   * indenter chaque ligne de $text
+   */
+  static function indent(?string $text, string $indent="  "): ?string {
+    if ($text === null) return null;
+    $indented = [];
+    foreach (explode("\n", $text) as $line) {
+      $indented[] = "$indent$line";
+    }
+    return implode("\n", $indented);
+  }
+
   const CAMEL_PATTERN0 = '/([A-Z0-9]+)$/A';
   const CAMEL_PATTERN1 = '/([A-Z0-9]+)[A-Z]/A';
   const CAMEL_PATTERN2 = '/([A-Z]?[^A-Z]+)/A';
@@ -426,7 +438,7 @@ class str {
     } elseif (preg_match(self::CAMEL_PATTERN2, $camel, $ms, PREG_OFFSET_CAPTURE)) {
       # préfixe en minuscule
     } else {
-      throw ValueException::invalid_kind($camel, "camel string");
+      throw exceptions::invalid_value($camel, "camel string");
     }
     $parts[] = strtolower($ms[1][0]);
     $index = intval($ms[1][1]) + strlen($ms[1][0]);
diff --git a/php/src/text/Word.php b/php/src/text/Word.php
index a91b03b..e5889d1 100644
--- a/php/src/text/Word.php
+++ b/php/src/text/Word.php
@@ -28,17 +28,23 @@ use nulib\ValueException;
  */
 class Word {
   /** @var bool le mot est-il féminin? */
-  private $fem;
+  private ?bool $fem;
+  /** @var string article "aucun", "aucune" */
+  private ?string $aucun;
+  /** @var string article "un", "une" */
+  private ?string $un;
   /** @var string article "le", "la", "l'" */
-  private $le;
+  private ?string $le;
   /** @var string article "ce", "cet", "cette" */
-  private $ce;
+  private ?string $ce;
+  /** @var string article "de", "d'" */
+  private ?string $de;
   /** @var string article "du", "de la", "de l'" */
-  private $du;
+  private ?string $du;
   /** @var string article "au", "à la", "à l'" */
-  private $au;
+  private ?string $au;
   /** @var string le mot sans article */
-  private $w;
+  private string $w;
 
   function __construct(string $spec, bool $adjective=false) {
     if (preg_match('/^f([eé]m(inin)?)?\s*:\s*/iu', $spec, $ms)) {
@@ -57,28 +63,40 @@ class Word {
       $fem = null;
     }
     if (preg_match('/^l\'\s*/i', $spec, $ms) && $fem !== null) {
+      $aucun = $fem? "aucune ": "aucun ";
+      $un = $fem? "une ": "un ";
       $le = "l'";
       $ce = "cet ";
+      $de = "d'";
       $du = "de l'";
       $au = "à l'";
       $spec = substr($spec, strlen($ms[0]));
     } elseif (preg_match('/^la\s+/i', $spec, $ms)) {
       $fem = true;
+      $aucun = "aucune ";
+      $un = "une ";
       $le = "la ";
       $ce = "cette ";
+      $de = "de ";
       $du = "de la ";
       $au = "à la ";
       $spec = substr($spec, strlen($ms[0]));
     } elseif (preg_match('/^le\s+/i', $spec, $ms)) {
       $fem = false;
+      $aucun = "aucun ";
+      $un = "un ";
       $le = "le ";
       $ce = "ce ";
+      $de = "de ";
       $du = "du ";
       $au = "au ";
       $spec = substr($spec, strlen($ms[0]));
     } else {
+      $aucun = null;
+      $un = null;
       $le = null;
       $ce = null;
+      $de = null;
       $du = null;
       $au = null;
     }
@@ -86,18 +104,29 @@ class Word {
       # si c'est un nom, il faut l'article et le genre
       if ($fem === null) {
         throw new ValueException("Vous devez spécifier le genre du nom");
-      } elseif ($le === null || $du === null || $au === null) {
+      } elseif ($le === null) {
         throw new ValueException("Vous devez spécifier l'article du nom");
       }
     }
     $this->fem = $fem;
+    $this->aucun = $aucun;
+    $this->un = $un;
     $this->le = $le;
     $this->ce = $ce;
+    $this->de = $de;
     $this->du = $du;
     $this->au = $au;
     $this->w = $spec;
   }
 
+  function isMasculin(): bool {
+    return !$this->fem;
+  }
+
+  function isFeminin(): bool {
+    return $this->fem;
+  }
+
   /**
    * retourner le mot sans article
    *
@@ -168,15 +197,25 @@ class Word {
     return "$amount/$max ".$this->w($amount);
   }
 
+  function pronom(): string {
+    return $this->fem? "elle": "il";
+  }
+
+  function articleAucun(): ?string {
+    return $this->un;
+  }
+
+  function articleUn(): ?string {
+    return $this->un;
+  }
+
   /** retourner le mot avec l'article indéfini et la quantité */
   function un(int $amount=1): string {
     $abs_amount = abs($amount);
     if ($abs_amount == 0) {
-      $aucun = $this->fem? "aucune ": "aucun ";
-      return $aucun.$this->w($amount);
+      return $this->aucun.$this->w($amount);
     } elseif ($abs_amount == 1) {
-      $un = $this->fem? "une ": "un ";
-      return $un.$this->w($amount);
+      return $this->un.$this->w($amount);
     } else {
       return "les $amount ".$this->w($amount);
     }
@@ -193,6 +232,10 @@ class Word {
     }
   }
 
+  function articleLe(): ?string {
+    return $this->le;
+  }
+
   function le(int $amount=1): string {
     $abs_amount = abs($amount);
     if ($abs_amount == 0) {
@@ -214,6 +257,10 @@ class Word {
     }
   }
 
+  function articleCe(): string {
+    return $this->ce;
+  }
+
   function ce(int $amount=1): string {
     $abs_amount = abs($amount);
     if ($abs_amount == 0) {
@@ -235,6 +282,18 @@ class Word {
     }
   }
 
+  function articleDe(): string {
+    return $this->de;
+  }
+
+  function _de(int $amount=1): string {
+    return $this->de.$this->w($amount);
+  }
+
+  function articleDu(): string {
+    return $this->du;
+  }
+
   function du(int $amount=1): string {
     $abs_amount = abs($amount);
     if ($abs_amount == 0) {
@@ -256,6 +315,10 @@ class Word {
     }
   }
 
+  function articleAu(): string {
+    return $this->au;
+  }
+
   function au(int $amount=1): string {
     $abs_amount = abs($amount);
     if ($abs_amount == 0) {
diff --git a/php/src/txt.php b/php/src/txt.php
index 2c5ef53..68a2794 100644
--- a/php/src/txt.php
+++ b/php/src/txt.php
@@ -105,7 +105,7 @@ class txt {
     if ($s === null) return null;
     return mb_strtoupper(mb_substr($s, 0, 1)).mb_substr($s, 1);
   }
-  
+
   static final function upperw(?string $s, ?string $delimiters=null): ?string {
     if ($s === null) return null;
     if ($delimiters === null) $delimiters = " _-\t\r\n\f\v";
diff --git a/php/src/web/curl/CurlException.php b/php/src/web/curl/CurlException.php
index 53fda92..52e87a0 100644
--- a/php/src/web/curl/CurlException.php
+++ b/php/src/web/curl/CurlException.php
@@ -5,9 +5,8 @@ use nulib\UserException;
 use Throwable;
 
 class CurlException extends UserException {
-  function __construct($ch, ?string $message=null, $code=0, ?Throwable $previous=null) {
-    if ($message === null) $message = "(unknown error)";
-    $userMessage = $message;
+  function __construct($ch, $userMessage=null, $code=0, ?Throwable $previous=null) {
+    $userMessage ??= "erreur curl inconnue";
     $techMessage = null;
     if ($ch !== null) {
       $parts = [];
@@ -17,6 +16,7 @@ class CurlException extends UserException {
       if ($error != "") $parts[] = "error: $error";
       if ($parts) $techMessage = implode(", ", $parts);
     }
-    parent::__construct($userMessage, $techMessage, $code, $previous);
+    parent::__construct($userMessage, $code, $previous);
+    $this->setTechMessage($techMessage);
   }
 }
diff --git a/php/src/web/curl/curl.php b/php/src/web/curl/curl.php
index 34f0677..fcc7530 100644
--- a/php/src/web/curl/curl.php
+++ b/php/src/web/curl/curl.php
@@ -12,11 +12,11 @@ class curl {
     if (!isset($curlOptions[CURLOPT_RETURNTRANSFER])) $curlOptions[CURLOPT_RETURNTRANSFER] = true;
     $extractHeaders = isset($curlOptions[CURLOPT_HEADER]) && $curlOptions[CURLOPT_HEADER];
     $ch = curl_init();
-    if ($ch === false) throw new CurlException(null, "init");
+    if ($ch === false) throw new CurlException(null, "erreur curl lors de l'initialisation");
     curl_setopt_array($ch, $curlOptions);
     try {
       $result = curl_exec($ch);
-      if ($result === false) throw new CurlException($ch);
+      if ($result === false) throw new CurlException($ch, "erreur curl lors du téléchargement");
       if ($extractHeaders) {
         $info = curl_getinfo($ch);
         $headersSize = $info["header_size"];
diff --git a/php/src/web/params/F.php b/php/src/web/params/F.php
index 18ed828..987b1bc 100644
--- a/php/src/web/params/F.php
+++ b/php/src/web/params/F.php
@@ -17,7 +17,7 @@ class F {
   }
 
   /** obtenir le paramètre $name en cherchant dans $_POST puis $_GET */
-  static final function get($name, $default=null, bool $trim=false) {
+  static final function get($name, $default=null, bool $trim=false): ?string {
     if ($name === null || $name === false) $value = $default;
     elseif (array_key_exists($name, $_POST)) $value = $_POST[$name];
     elseif (array_key_exists($name, $_GET)) $value = $_GET[$name];
@@ -47,6 +47,10 @@ class F {
     ));
   }
 
+  /**
+   * calculer la liste de tous les paramètres qui ont été passés. ensuite,
+   * fusionner le tableau $merge s'il est spécifié
+   */
   static final function merge(?array $merge=null): array {
     $params = [];
     foreach (self::get_names() as $name) {
@@ -56,7 +60,7 @@ class F {
   }
 
   /**
-   * retourner une liste des paramètres qui ont été passés, en les sélectionnant
+   * calculer une liste des paramètres qui ont été passés, en les sélectionnant
    * selon le contenu de $includes et $excludes. ensuite, fusionner le tableau
    * $merge s'il est spécifié
    *
diff --git a/php/src/web/params/G.php b/php/src/web/params/G.php
index ee37af2..2948712 100644
--- a/php/src/web/params/G.php
+++ b/php/src/web/params/G.php
@@ -15,7 +15,7 @@ class G {
   }
 
   /** obtenir le paramètre $name */
-  static final function get($name, $default=null, bool $trim=false) {
+  static final function get($name, $default=null, bool $trim=false): ?string {
     $value = cl::get($_GET, $name, $default);
     if ($trim) $value = str::trim($value);
     return $value;
@@ -26,7 +26,11 @@ class G {
     $_GET[$name] = $value;
   }
 
-  static final function xselect(?array $includes, ?array $excludes=null, ?array $merge=null): array {
+  static final function merge(?array $merge=null): array {
+    return cl::merge($_GET, $merge);
+  }
+
+  static final function select(?array $includes, ?array $excludes=null, ?array $merge=null): array {
     return cl::merge(cl::xselect($_GET, $includes, $excludes), $merge);
   }
 }
diff --git a/php/src/web/params/P.php b/php/src/web/params/P.php
index 04faeec..eddb151 100644
--- a/php/src/web/params/P.php
+++ b/php/src/web/params/P.php
@@ -15,7 +15,7 @@ class P {
   }
 
   /** obtenir le paramètre $name */
-  static final function get($name, $default=null, bool $trim=false) {
+  static final function get($name, $default=null, bool $trim=false): ?string {
     $value = cl::get($_POST, $name, $default);
     if ($trim) $value = str::trim($value);
     return $value;
@@ -30,4 +30,12 @@ class P {
   static final function raw(): string {
     return file_get_contents("php://input");
   }
+
+  static final function merge(?array $merge=null): array {
+    return cl::merge($_POST, $merge);
+  }
+
+  static final function select(?array $includes, ?array $excludes=null, ?array $merge=null): array {
+    return cl::merge(cl::xselect($_POST, $includes, $excludes), $merge);
+  }
 }
diff --git a/php/src/web/params/R.php b/php/src/web/params/R.php
index 9c1aae6..b7d38fa 100644
--- a/php/src/web/params/R.php
+++ b/php/src/web/params/R.php
@@ -15,7 +15,7 @@ class R {
   }
 
   /** obtenir le paramètre $name */
-  static final function get($name, $default=null, bool $trim=false) {
+  static final function get($name, $default=null, bool $trim=false): ?string {
     $value = cl::get($_REQUEST, $name, $default);
     if ($trim) $value = str::trim($value);
     return $value;
diff --git a/php/src/web/session.php b/php/src/web/session.php
new file mode 100644
index 0000000..f475858
--- /dev/null
+++ b/php/src/web/session.php
@@ -0,0 +1,244 @@
+ $duration,
+      ]);
+      self::$started_once = true;
+
+      $creation_time = self::get(self::SESSION_CREATION_TIME, false);
+      if (!$creation_time) {
+        # création initiale
+        self::set(self::SESSION_CREATION_TIME, time());
+        return true;
+      } elseif ($canSetCookies) {
+        # étendre la durée du cookie
+        $params = session_get_cookie_params();
+        setcookie(session_name(), session_id(), time() + $duration, $params["path"], $params["domain"], $params["secure"], $params["httponly"]);
+      }
+    }
+    return false;
+  }
+
+  /**
+   * enregistrer la session, la fermer et libérer son verrou.
+   *
+   * cette fonction peut être appelée avant une opération longue si on n'a plus
+   * besoin de la session.
+   *
+   * retourn true si la session a été fermée, false sinon.
+   */
+  static final function close(): bool {
+    if (self::started()) {
+      session_write_close();
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * vider la session de toutes ses variables ($unsetOnly==true) ou la détruire
+   * ($unsetOnly==false). en cas de destruction de la session, supprimer aussi
+   * le cookie de session
+   *
+   * si $unsetOnly==true, refaire la variable SESSION_CREATION_TIME
+   */
+  static final function destroy(bool $unsetOnly=false, bool $clearCookie=true): void {
+    self::start();
+    if ($unsetOnly) {
+      session_unset();
+      self::set(self::SESSION_CREATION_TIME, time());
+    } else {
+      $canSetCookies = !headers_sent();
+      if ($clearCookie && $canSetCookies && ini_get("session.use_cookies")) {
+        $params = session_get_cookie_params();
+        setcookie(session_name(), '', time() - 42000, $params["path"], $params["domain"], $params["secure"], $params["httponly"]);
+      }
+      session_destroy();
+    }
+  }
+
+  /**
+   * Vider la session de toutes les clés spécifiées dans $keys qui ne sont pas
+   * mentionnées dans $keeps
+   *
+   * Si $keys vaut null, toutes les clés sont supprimées comme avec destroy(true)
+   * notamment, cela signifie que la variable SESSION_CREATION_TIME est refaite
+   */
+  static final function unset_keys(?array $keys, ?array $keeps=null): void {
+    $updateSessionCreationTime = false;
+    if ($keys === null) {
+      $keys = array_keys($_SESSION);
+      $updateSessionCreationTime = true;
+    }
+    if ($keeps !== null) $keys = array_diff($keys, $keeps);
+    foreach ($keys as $key) {
+      unset($_SESSION[$key]);
+    }
+    if ($updateSessionCreationTime) {
+      self::set(self::SESSION_CREATION_TIME, time());
+    }
+  }
+
+  /** vérifier si la session est démarrée et si la clé spécifiée existe. */
+  static final function has($key): bool {
+    if ($key === null || $key === false) return false;
+    return isset($_SESSION) && array_key_exists($key, $_SESSION);
+  }
+
+  /** obtenir la valeur associée à la clé spécifiée si la session est démarrée. */
+  static final function get(string $key, $default=null) {
+    if (!isset($_SESSION)) return $default;
+    return cl::get($_SESSION, $key, $default);
+  }
+
+  /**
+   * mettre à jour la valeur d'une variable de session.
+   *
+   * ne pas chercher à savoir si la session est démarrée ou non
+   */
+  static final function set(string $key, $value): void {
+    $_SESSION[$key] = $value;
+  }
+
+  /**
+   * comme {@link set()} mais rouvrir automatiquement la session si nécessaire,
+   * à condition qu'elle aie déjà été ouverte une fois
+   */
+  static final function setx(string $key, $value): void {
+    $close = !self::started() && self::started_once();
+    if ($close) self::start();
+    self::set($key, $value);
+    if ($close) self::close();
+  }
+
+  /**
+   * supprimer une variable de session.
+   *
+   * ne pas chercher à savoir si la session est démarrée ou non
+   */
+  static final function del(string $key): void {
+    unset($_SESSION[$key]);
+  }
+
+  /**
+   * comme {@link del()} mais rouvrir automatiquement la session si nécessaire,
+   * à condition qu'elle aie déjà été ouverte une fois
+   */
+  static final function delx(string $key): void {
+    $close = !self::started() && self::started_once();
+    if ($close) self::start();
+    self::del($key);
+    if ($close) self::close();
+  }
+
+  /** vérifier si chemin de clé spécifié existe dans la session. */
+  static final function phas($pkey): bool {
+    return isset($_SESSION) && cl::phas($_SESSION, $pkey);
+  }
+
+  /** obtenir la valeur associée au chemin de clé spécifié si la session est démarrée. */
+  static final function pget($pkey, $default=null) {
+    return isset($_SESSION) && cl::pget($_SESSION, $pkey, $default);
+  }
+
+  /**
+   * mettre à jour la valeur correspondant au chemin de clé spécifié.
+   *
+   * ne pas chercher à savoir si la session est démarrée ou non
+   */
+  static final function pset($pkey, $value): void {
+    cl::pset($_SESSION, $pkey, $value);
+  }
+
+  /**
+   * comme {@link pset()} mais rouvrir automatiquement la session si nécessaire,
+   * à condition qu'elle aie déjà été ouverte une fois
+   */
+  static final function psetx($pkey, $value): void {
+    $close = !self::started() && self::started_once();
+    if ($close) self::start();
+    self::pset($pkey, $value);
+    if ($close) self::close();
+  }
+
+  /**
+   * supprimer la variable au chemin de clé spécifié.
+   *
+   * ne pas chercher à savoir si la session est démarrée ou non
+   */
+  static final function pdel($pkey): void {
+    cl::pdel($_SESSION, $pkey);
+  }
+
+  /**
+   * comme {@link pdel()} mais rouvrir automatiquement la session si nécessaire,
+   * à condition qu'elle aie déjà été ouverte une fois
+   */
+  static final function pdelx(string $key): void {
+    $close = !self::started() && self::started_once();
+    if ($close) self::start();
+    self::pdel($key);
+    if ($close) self::close();
+  }
+}
diff --git a/php/tbin/.gitignore b/php/tbin/.gitignore
index f3b938a..e20f16c 100644
--- a/php/tbin/.gitignore
+++ b/php/tbin/.gitignore
@@ -1 +1,4 @@
+/devel/
 /*.db
+/*.cache
+/*.log
diff --git a/php/tbin/cachectl.php b/php/tbin/cachectl.php
new file mode 100755
index 0000000..4afa7a5
--- /dev/null
+++ b/php/tbin/cachectl.php
@@ -0,0 +1,7 @@
+#!/usr/bin/php
+ "SUBJECT BODY -t RECIPIENT",
+    "merge" => parent::ARGS,
+    ["-F", "--from", "args" => 1, "name" => "from", "argsdesc" => "MAILFROM"],
+    ["-t", "--to", "args" => 1, "action" => "--add", "name" => "to", "argsdesc" => "RECIPIENT"],
+    ["-c", "--cc", "args" => 1, "action" => "--add", "name" => "cc", "argsdesc" => "RECIPIENT"],
+    ["-b", "--bcc", "args" => 1, "action" => "--add", "name" => "bcc", "argsdesc" => "RECIPIENT"],
+    ["args" => 2, "name" => "args"],
+  ];
+
+  protected $to, $cc, $bcc, $from;
+
+  function main() {
+    $subject = cv::not_null($this->args[0] ?? null, "subject");
+    $body = cv::not_null($this->args[1] ?? null, "body");
+    mailer::send($this->to, $subject, $body, $this->cc, $this->bcc, $this->from);
+  }
+});
diff --git a/php/tbin/steam-train.php b/php/tbin/steam-train.php
new file mode 100755
index 0000000..cba3bcf
--- /dev/null
+++ b/php/tbin/steam-train.php
@@ -0,0 +1,14 @@
+#!/usr/bin/php
+ "../vendor/autoload.php",
+    "bindir" => "../vendor/bin",
+  ];
+}
+SteamTrainApp::run();
diff --git a/php/tbin/test-application.php b/php/tbin/test-application.php
new file mode 100755
index 0000000..a196a27
--- /dev/null
+++ b/php/tbin/test-application.php
@@ -0,0 +1,78 @@
+#!/usr/bin/php
+ "tester la gestion des arguments",
+    "usage" => "-A|-a|-b",
+
+    "merge" => parent::ARGS,
+    "sections" => [
+      [
+        "title" => "Section X",
+        "show" => false,
+        ["group",
+         ["-X:", "--setx", "args" => "int", "name" => "x",
+          "help" => "spécifier x",
+         ],
+         ["--setx10", "name" => "x", "value" => 10],
+         ["--setx20", "name" => "x", "value" => 20],
+        ],
+        ["-x", "--incx", "name" => "x"],
+        ["-y", "--decx", "name" => "x", "inverse" => true],
+      ],
+    ],
+    ["group",
+      ["-A:", "--seta", "args" => "int", "name" => "a",
+        "help" => "spécifier a",
+      ],
+      ["--seta10", "name" => "a", "value" => 10],
+      ["--seta20", "name" => "a", "value" => 20],
+    ],
+    ["-a", "--inca", "name" => "a",
+      "help" => "incrémenter a",
+    ],
+    ["-b", "--deca", "name" => "a", "inverse" => true,
+      "help" => "décrémenter a",
+    ],
+    ["-D::", "--override",
+      "help" => "++remplace celui de la section principale",
+    ],
+    ["-1:first", "--one", "help" => "un argument"],
+    ["-2:first,second", "--two", "help" => "deux arguments"],
+    ["-3", "args" => ""],
+    //["args" => [["value", "value"]], "name" => "args"],
+    //["args" => ["value", ["value"]], "name" => "args"],
+    //["args" => ["value", "value"], "name" => "args"],
+  ];
+
+  private ?int $a = null;
+  private ?int $x = null;
+  private ?string $override = null;
+  private ?string $one = null;
+  private ?array $two = null;
+
+  function main() {
+    $profile = app::get_profile($productionMode);
+    $profile = self::get_profile($profile);
+    $productionMode = $productionMode? "production": "development";
+    msg::info("profile=$profile ($productionMode)");
+    $debug = app::is_debug()? "DEBUG": "non";
+    msg::info("debug=$debug");
+
+    msg::info([
+      "variables:",
+      "\na=", var_export($this->a, true),
+      "\nx=", var_export($this->x, true),
+      "\noverride=", var_export($this->override, true),
+      "\none=", var_export($this->one, true),
+      "\ntwo=", var_export($this->two, true),
+      "\nargs=", var_export($this->args, true),
+    ]);
+  }
+});
diff --git a/php/tbin/test-cache.php b/php/tbin/test-cache.php
new file mode 100755
index 0000000..ebc91cd
--- /dev/null
+++ b/php/tbin/test-cache.php
@@ -0,0 +1,82 @@
+#!/usr/bin/php
+get());
+  if ($dumpInfos) {
+    yaml::dump($cache->getInfos());
+  }
+}
+
+//system("rm -f *.cache .*.cache");
+
+$what = [
+  "null",
+  "one",
+  "two",
+  "three",
+];
+$duration = 10;
+
+if (in_array("null", $what)) {
+  $null = new CacheFile("null", null, [
+    "duration" => $duration,
+  ]);
+  show("null", $null);
+}
+
+if (in_array("one", $what)) {
+  $one = new class("one", null, [
+    "duration" => $duration,
+  ]) extends CacheFile {
+    protected function compute() {
+      return 1;
+    }
+  };
+  show("one", $one);
+}
+
+if (in_array("two", $what)) {
+  $two = new CacheFile("two", new DataCacheData(null, function () {
+    return 2;
+  }), [
+    "duration" => $duration,
+  ]);
+  show("two", $two);
+}
+
+if (in_array("three", $what)) {
+  $data31 = new DataCacheData("data31name", function () {
+    return 31;
+  });
+
+  $data32 = new DataCacheData(null, function () {
+    return 32;
+  });
+
+  $three = new CacheFile("three", [
+    "data31" => $data31,
+    $data31, # name=data31name
+    "data32" => $data32,
+    $data32, # name=""
+  ]);
+  Txx("three.0=", $three->get("data31"));
+  Txx("three.1=", $three->get("data31name"));
+  Txx("three.2=", $three->get("data32"));
+  Txx("three.3=", $three->get(""));
+}
diff --git a/php/tbin/test-exceptions.php b/php/tbin/test-exceptions.php
new file mode 100755
index 0000000..ad17757
--- /dev/null
+++ b/php/tbin/test-exceptions.php
@@ -0,0 +1,65 @@
+#!/usr/bin/php
+ "tester l'affichage des exception",
+
+    "merge" => parent::ARGS,
+  ];
+
+  function fart(): void {
+    throw new RuntimeException("fart");
+  }
+
+  function prout(): void {
+    try {
+      $this->fart();
+    } catch (Exception $e) {
+      throw new RuntimeException("prout", $e->getCode(), $e);
+    }
+  }
+
+  function main() {
+    try {
+      throw new Exception("exception normale");
+    } catch (Exception $e) {
+      msg::info("summary: ". exceptions::get_summary($e));
+      msg::error($e);
+    }
+    try {
+      try {
+        $this->prout();
+      } catch (Exception $e) {
+        throw new Exception("exception normale", $e->getCode(), $e);
+      }
+    } catch (Exception $e) {
+      msg::info("summary: ". exceptions::get_summary($e));
+      msg::error($e);
+    }
+    try {
+      throw exceptions::invalid_value("valeur", $kind)
+        ->setTechMessage("message technique");
+    } catch (Exception $e) {
+      msg::info("summary: ". exceptions::get_summary($e));
+      msg::error($e);
+    }
+    try {
+      try {
+        $this->prout();
+      } catch (Exception $e) {
+        throw exceptions::invalid_value("valeur", $kind, null, $e)
+          ->setTechMessage("message technique");
+      }
+    } catch (Exception $e) {
+      msg::info("summary: ". exceptions::get_summary($e));
+      msg::error($e);
+    }
+  }
+});
diff --git a/php/tbin/test-mail.php b/php/tbin/test-mail.php
new file mode 100644
index 0000000..d37b25b
--- /dev/null
+++ b/php/tbin/test-mail.php
@@ -0,0 +1,19 @@
+ "test de mail",
+  "body" => << "moi même",
+];
+mailer::tsend($template, $data, "jephte.clain@gmail.com");
diff --git a/php/tbin/test_mysql.php b/php/tbin/test-mysql.php
similarity index 90%
rename from php/tbin/test_mysql.php
rename to php/tbin/test-mysql.php
index e2eb555..043d924 100644
--- a/php/tbin/test_mysql.php
+++ b/php/tbin/test-mysql.php
@@ -7,9 +7,9 @@ use nulib\db\CapacitorChannel;
 use nulib\db\mysql\Mysql;
 use nulib\db\mysql\MysqlStorage;
 use nulib\output\msg;
-use nulib\output\std\StdMessenger;
+use nulib\output\std\ConsoleMessenger;
 
-msg::set_messenger_class(StdMessenger::class);
+msg::set_messenger_class(ConsoleMessenger::class);
 
 $db = new Mysql([
   "type" => "mysql",
diff --git a/php/tbin/test-output-forever.php b/php/tbin/test-output-forever.php
new file mode 100755
index 0000000..6bee7b2
--- /dev/null
+++ b/php/tbin/test-output-forever.php
@@ -0,0 +1,18 @@
+#!/usr/bin/php
+ "output-forever.log",
+]));
+
+$index = 1;
+while (true) {
+  msg::info("info $index");
+  $index++;
+  sleep(1);
+}
diff --git a/php/tbin/test-output.php b/php/tbin/test-output.php
new file mode 100755
index 0000000..78aa8d0
--- /dev/null
+++ b/php/tbin/test-output.php
@@ -0,0 +1,458 @@
+#!/usr/bin/php
+title("title");
+  sleep(5);
+  $msg->info("info");
+  sleep(5);
+  $msg->info("info");
+  $msg->end();
+
+  echo date("Y-m-d\\TH:i:s.u")."\n";
+  $msg->action("action");
+  sleep(5);
+  $msg->info("info");
+  sleep(5);
+  $msg->info("info");
+  $msg->adone();
+
+  echo date("Y-m-d\\TH:i:s.u")."\n";
+  $msg->action("action");
+  sleep(5);
+  $msg->asuccess();
+
+  echo date("Y-m-d\\TH:i:s.u")."\n";
+  $msg->action("action");
+  $msg->asuccess("plouf1");
+
+  echo date("Y-m-d\\TH:i:s.u")."\n";
+  $msg->action("action");
+  sleep(5);
+  $msg->asuccess("plouf2");
+}
+
+if ($titles) {
+  $msg->title("title0");
+  $msg->desc("desc0");
+  $msg->title("title1");
+  $msg->desc("desc1");
+  $msg->print("print under title1");
+  $msg->end();
+  $msg->print("print under title0");
+  $msg->end();
+  $msg->print("print out of title");
+}
+
+if ($maxTitleLevel) {
+  $msg->info("test maxTitleLevel");
+  $msg->title("1first", function(IMessenger $msg) {
+    $msg->info("1one");
+    $msg->end();
+    $msg->info("1two");
+    $msg->end();
+    $msg->info("1three");
+  });
+  $msg->info("0one");
+  $msg->end();
+  $msg->info("0two");
+  $msg->end();
+  $msg->info("0three");
+
+  $msg->title("2first", function(IMessenger $msg) {
+    $msg->title("3second", function(IMessenger $msg) {
+      $msg->title("4third", function(IMessenger $msg) {
+        $msg->info("4one");
+        $msg->end();
+        $msg->info("4two");
+        $msg->end();
+        $msg->info("4three");
+      });
+      $msg->info("3four");
+      $msg->end();
+      $msg->info("3five");
+      $msg->end();
+      $msg->info("3six");
+    });
+    $msg->info("2seven");
+    $msg->end();
+    $msg->info("2eight");
+    $msg->end();
+    $msg->info("2nine");
+  });
+  $msg->info("1one");
+  $msg->end();
+  $msg->info("1two");
+  $msg->end();
+  $msg->info("1three");
+}
+
+if ($actions) {
+  $msg->desc("action avec step");
+  $msg->action("action avec step");
+  $msg->step("step");
+  $msg->asuccess("action success");
+
+  $msg->action("action avec step");
+  $msg->step("step");
+  $msg->afailure("action failure");
+
+  $msg->action("action avec step");
+  $msg->step("step");
+  $msg->adone("action neutral");
+
+  $msg->desc("actions sans step");
+  $msg->action("action sans step");
+  $msg->asuccess("action success");
+
+  $msg->action("action sans step");
+  $msg->afailure("action failure");
+
+  $msg->action("action sans step");
+  $msg->adone("action neutral");
+
+  $msg->desc("actions imbriquées");
+  $msg->action("action0");
+  $msg->action("action1");
+  $msg->action("action2");
+  $msg->asuccess("action2 success");
+  $msg->asuccess("action1 success");
+  $msg->asuccess("action0 success");
+
+  $msg->desc("action avec step, sans messages");
+  $msg->action("action avec step, sans messages, success");
+  $msg->step("step");
+  $msg->asuccess();
+
+  $msg->action("action avec step, sans messages, failure");
+  $msg->step("step");
+  $msg->afailure();
+
+  $msg->action("action avec step, sans messages, done");
+  $msg->step("step");
+  $msg->adone();
+
+  $msg->desc("action sans step, sans messages");
+  $msg->action("action sans step, sans messages, success");
+  $msg->asuccess();
+
+  $msg->action("action sans step, sans messages, failure");
+  $msg->afailure();
+
+  $msg->action("action sans step, sans messages, done");
+  $msg->adone();
+
+  $msg->desc("actions imbriquées, sans messages");
+  $msg->action("action0");
+  $msg->action("action1");
+  $msg->action("action2");
+  $msg->asuccess();
+  $msg->asuccess();
+  $msg->asuccess();
+}
+
+if ($maxActionLevel) {
+  $msg->info("test maxActionLevel");
+  $msg->action("first1", function (IMessenger $msg) {
+    $msg->info("one");
+    $msg->end();
+    $msg->info("two");
+    $msg->end();
+    $msg->info("three");
+  });
+  $msg->action("first2", function (IMessenger $msg) {
+    $msg->info("one");
+    $msg->adone();
+    $msg->info("two");
+    $msg->adone();
+    $msg->info("three");
+  });
+  $msg->action("first3", function (IMessenger $msg) {
+    $msg->action("second", function (IMessenger $msg) {
+      $msg->action("third", function (IMessenger $msg) {
+        $msg->info("one");
+        $msg->end();
+        $msg->info("two");
+        $msg->end();
+        $msg->info("three");
+      });
+      $msg->info("four");
+      $msg->end();
+      $msg->info("five");
+      $msg->end();
+      $msg->info("six");
+    });
+    $msg->info("seven");
+    $msg->end();
+    $msg->info("eight");
+    $msg->end();
+    $msg->info("nine");
+  });
+  $msg->info("ten");
+  $msg->end();
+  $msg->info("eleven");
+  $msg->end();
+  $msg->info("twelve");
+
+  $msg->action("first4", function (IMessenger $msg) {
+    $msg->action("second", function (IMessenger $msg) {
+      $msg->action("third", function (IMessenger $msg) {
+        $msg->info("one");
+        $msg->adone();
+        $msg->info("two");
+        $msg->adone();
+        $msg->info("three");
+      });
+      $msg->info("four");
+      $msg->adone();
+      $msg->info("five");
+      $msg->adone();
+      $msg->info("six");
+    });
+    $msg->info("seven");
+    $msg->adone();
+    $msg->info("eight");
+    $msg->adone();
+    $msg->info("nine");
+  });
+  $msg->info("ten");
+  $msg->adone();
+  $msg->info("eleven");
+  $msg->adone();
+  $msg->info("twelve");
+}
+
+if ($levels) {
+  $msg->info("info");
+  $msg->note("note");
+  $msg->warning("warning");
+  $msg->error("error");
+}
+
+if ($complete) {
+  $msg->section("section", function (IMessenger $msg) {
+    $msg->title("title", function (IMessenger $msg) {
+      $msg->desc("desc");
+      $msg->print("print");
+
+      $msg->desc("action avec step");
+      $msg->action("action avec step", function (IMessenger $msg) {
+        $msg->step("step");
+        $msg->asuccess("action success");
+      });
+
+      $msg->action("action avec step", function (IMessenger $msg) {
+        $msg->step("step");
+        $msg->afailure("action failure");
+      });
+
+      $msg->action("action avec step", function (IMessenger $msg) {
+        $msg->step("step");
+        $msg->adone("action done");
+      });
+
+      $msg->desc("actions sans step");
+      $msg->action("action sans step", function (IMessenger $msg) {
+        $msg->asuccess("action success");
+      });
+
+      $msg->action("action sans step", function (IMessenger $msg) {
+        $msg->afailure("action failure");
+      });
+
+      $msg->action("action sans step", function (IMessenger $msg) {
+        $msg->adone("action done");
+      });
+
+      $msg->desc("actions imbriquées");
+      $msg->action("action0", function (IMessenger $msg) {
+        $msg->action("action1", function (IMessenger $msg) {
+          $msg->action("action2", function (IMessenger $msg) {
+            $msg->asuccess("action2 success");
+          });
+          $msg->asuccess("action1 success");
+        });
+        $msg->asuccess("action0 success");
+      });
+
+      $msg->desc("action avec step, sans messages");
+      $msg->action("action avec step, sans messages, success", function (IMessenger $msg) {
+        $msg->step("step");
+        $msg->asuccess();
+      });
+
+      $msg->action("action avec step, sans messages, failure", function (IMessenger $msg) {
+        $msg->step("step");
+        $msg->afailure();
+      });
+
+      $msg->action("action avec step, sans messages, done", function (IMessenger $msg) {
+        $msg->step("step");
+        $msg->adone();
+      });
+
+      $msg->desc("action sans step, sans messages");
+      $msg->action("action sans step, sans messages, success", function (IMessenger $msg) {
+        $msg->asuccess();
+      });
+
+      $msg->action("action sans step, sans messages, failure", function (IMessenger $msg) {
+        $msg->afailure();
+      });
+
+      $msg->action("action sans step, sans messages, done", function (IMessenger $msg) {
+        $msg->adone();
+      });
+
+      $msg->desc("actions imbriquées, sans messages");
+      $msg->action("action0", function (IMessenger $msg) {
+        $msg->action("action1", function (IMessenger $msg) {
+          $msg->action("action2", function (IMessenger $msg) {
+            $msg->asuccess();
+          });
+          $msg->asuccess();
+        });
+        $msg->asuccess();
+      });
+
+      $msg->desc("action avec step, avec code de retour");
+      $msg->action("action avec step, avec code de retour true", function (IMessenger $msg) {
+        $msg->step("step");
+        return true;
+      });
+
+      $msg->action("action avec step, avec code de retour false", function (IMessenger $msg) {
+        $msg->step("step");
+        return false;
+      });
+
+      $msg->action("action avec step, avec code de retour autre", function (IMessenger $msg) {
+        $msg->step("step");
+        return "autre";
+      });
+
+      $msg->action("action avec step, avec code de retour null", function (IMessenger $msg) {
+        $msg->step("step");
+      });
+
+      $msg->desc("action sans step, avec code de retour");
+      $msg->action("action sans step, avec code de retour true", function (IMessenger $msg) {
+        return true;
+      });
+
+      $msg->action("action sans step, avec code de retour false", function (IMessenger $msg) {
+        return false;
+      });
+
+      $msg->action("action sans step, avec code de retour autre", function (IMessenger $msg) {
+        return "autre";
+      });
+
+      # ici, il n'y aura pas de message du tout
+      $msg->action("action sans step, avec code de retour null", function (IMessenger $msg) {
+      });
+
+      $msg->info("info");
+      $msg->note("note");
+      $msg->warning("warning");
+      $msg->error("error");
+    });
+  });
+}
+
+if ($multilines) {
+  $msg->section("multi-line\nsection", function (IMessenger $msg) {
+    $msg->title("multi-line\ntitle");
+    $msg->title("another\ntitle");
+
+    $msg->print("multi-line\nprint");
+    $msg->info("multi-line\ninfo");
+    $msg->action("multi-line\naction");
+    $msg->asuccess();
+    $msg->action("multi-line\naction");
+    $msg->step("multi-line\nstep");
+    $msg->afailure();
+    $msg->action("multi-line\naction");
+    $msg->step("multi-line\nstep");
+    $msg->asuccess("multi-line\nsuccess");
+    $msg->action("multi-line\naction");
+    $msg->step("multi-line\nstep");
+    $msg->adone("multi-line\ndone");
+
+    $msg->end();
+    $msg->end();
+  });
+}
+
+if ($exceptions) {
+  $msg->section("Exceptions", function (IMessenger $msg) {
+    $e = new Exception("message");
+    $u1 = new UserException("userMessage");
+    $u2 = (new UserException("userMessage"))->setTechMessage("techMessage");
+    $msg->title("avec message", function (IMessenger $msg) use ($e, $u1, $u2) {
+      $msg->info(["exception", $e]);
+      $msg->info(["userException1", $u1]);
+      $msg->info(["userException2", $u2]);
+    });
+    $msg->title("sans message", function (IMessenger $msg) use ($e, $u1, $u2) {
+      $msg->info($e);
+      $msg->info($u1);
+      $msg->info($u2);
+    });
+  });
+}
diff --git a/php/tbin/test-output1.php b/php/tbin/test-output1.php
new file mode 100755
index 0000000..8a2f78e
--- /dev/null
+++ b/php/tbin/test-output1.php
@@ -0,0 +1,56 @@
+#!/usr/bin/php
+ parent::ARGS,
+
+    ["-c", "--con", "name" => "use", "value" => self::CON],
+    ["-l", "--log", "name" => "use", "value" => self::LOG],
+    ["-m", "--msg", "name" => "use", "value" => self::MSG],
+  ];
+
+  protected int $use = self::MSG;
+
+  function main() {
+    switch ($this->use) {
+    case self::MSG:
+      $msg = new ProxyMessenger();
+      $msg->addMessenger(con::get());
+      $msg->addMessenger(new LogMessenger());
+      break;
+    case self::CON:
+      $msg = con::get();
+      break;
+    case self::LOG:
+      $msg = new LogMessenger();
+      break;
+    }
+    $msg->info("test d'information");
+    $msg->action("attente de 2 secondes", function (IMessenger $msg) {
+      sleep(1);
+      $msg->asuccess("1 seconde");
+      sleep(1);
+      $msg->asuccess("1 seconde");
+    });
+    $msg->action("attente de 2 secondes", function (IMessenger $msg) {
+      sleep(1);
+      $msg->info("1 seconde");
+      sleep(1);
+      $msg->info("1 seconde");
+    });
+    $msg->info("fin de test-appctl");
+  }
+});
diff --git a/php/tbin/test_pgsql.php b/php/tbin/test-pgsql.php
similarity index 100%
rename from php/tbin/test_pgsql.php
rename to php/tbin/test-pgsql.php
diff --git a/php/tbin/test_sqlite.php b/php/tbin/test-sqlite.php
similarity index 100%
rename from php/tbin/test_sqlite.php
rename to php/tbin/test-sqlite.php
diff --git a/php/tests/app/appTest.php b/php/tests/app/appTest.php
new file mode 100644
index 0000000..fe19a9a
--- /dev/null
+++ b/php/tests/app/appTest.php
@@ -0,0 +1,141 @@
+ $projdir,
+        "vendor" => [
+          "bindir" => "$projdir/vendor/bin",
+          "autoload" => "$projdir/vendor/autoload.php",
+        ],
+        "projcode" => "nulib-base",
+        "cwd" => $cwd,
+        "datadir" => "$projdir/devel",
+        "etcdir" => "$projdir/devel/etc",
+        "vardir" => "$projdir/devel/var",
+        "logdir" => "$projdir/devel/log",
+        "profile" => "devel",
+        "facts" => null,
+        "debug" => null,
+        "appgroup" => null,
+        "name" => "my-application1",
+        "title" => null,
+      ], $app1->getParams());
+
+      $app2 = myapp::with(MyApplication2::class, $app1);
+      self::assertSame([
+        "projdir" => $projdir,
+        "vendor" => [
+          "bindir" => "$projdir/vendor/bin",
+          "autoload" => "$projdir/vendor/autoload.php",
+        ],
+        "projcode" => "nulib-base",
+        "cwd" => $cwd,
+        "datadir" => "$projdir/devel",
+        "etcdir" => "$projdir/devel/etc",
+        "vardir" => "$projdir/devel/var",
+        "logdir" => "$projdir/devel/log",
+        "profile" => "devel",
+        "facts" => null,
+        "debug" => null,
+        "appgroup" => null,
+        "name" => "my-application2",
+        "title" => null,
+      ], $app2->getParams());
+    }
+
+    function testInit() {
+      $projdir = config::get_projdir();
+      $cwd = getcwd();
+
+      myapp::reset();
+      myapp::init(MyApplication1::class);
+      self::assertSame([
+        "projdir" => $projdir,
+        "vendor" => [
+          "bindir" => "$projdir/vendor/bin",
+          "autoload" => "$projdir/vendor/autoload.php",
+        ],
+        "projcode" => "nulib-base",
+        "cwd" => $cwd,
+        "datadir" => "$projdir/devel",
+        "etcdir" => "$projdir/devel/etc",
+        "vardir" => "$projdir/devel/var",
+        "logdir" => "$projdir/devel/log",
+        "profile" => "devel",
+        "facts" => null,
+        "debug" => null,
+        "appgroup" => null,
+        "name" => "my-application1",
+        "title" => null,
+      ], myapp::get()->getParams());
+
+      myapp::init(MyApplication2::class);
+      self::assertSame([
+        "projdir" => $projdir,
+        "vendor" => [
+          "bindir" => "$projdir/vendor/bin",
+          "autoload" => "$projdir/vendor/autoload.php",
+        ],
+        "projcode" => "nulib-base",
+        "cwd" => $cwd,
+        "datadir" => "$projdir/devel",
+        "etcdir" => "$projdir/devel/etc",
+        "vardir" => "$projdir/devel/var",
+        "logdir" => "$projdir/devel/log",
+        "profile" => "devel",
+        "facts" => null,
+        "debug" => null,
+        "appgroup" => null,
+        "name" => "my-application2",
+        "title" => null,
+      ], myapp::get()->getParams());
+    }
+  }
+}
+
+namespace nulib\app\impl {
+
+  use nulib\app\app;
+  use nulib\app\cli\Application;
+  use nulib\os\path;
+
+  class config {
+    const PROJDIR = __DIR__.'/../../..';
+
+    static function get_projdir(): string {
+      return path::abspath(self::PROJDIR);
+    }
+  }
+
+  class myapp extends app {
+    static function reset(): void {
+      self::$app = null;
+    }
+  }
+
+  class MyApplication1 extends Application {
+    const PROJDIR = config::PROJDIR;
+
+    function main() {
+    }
+  }
+  class MyApplication2 extends Application {
+    const PROJDIR = null;
+
+    function main() {
+    }
+  }
+}
diff --git a/php/tests/app/args/AodefTest.php b/php/tests/app/args/AodefTest.php
new file mode 100644
index 0000000..db6b6d1
--- /dev/null
+++ b/php/tests/app/args/AodefTest.php
@@ -0,0 +1,172 @@
+setup1();
+    $aodef->setup2();
+    #var_export($aodef->debugInfos()); #XXX
+    self::assertSame($options, $aodef->getOptions());
+    self::assertSame($haveShortOptions, $aodef->haveShortOptions, "haveShortOptions");
+    self::assertSame($haveLongOptions, $aodef->haveLongOptions, "haveLongOptions");
+    self::assertSame($isCommand, $aodef->isCommand, "isCommand");
+    self::assertSame($haveArgs, $aodef->haveArgs, "haveArgs");
+    self::assertSame($minArgs, $aodef->minArgs, "minArgs");
+    self::assertSame($maxArgs, $aodef->maxArgs, "maxArgs");
+    self::assertSame($argsdesc, $aodef->argsdesc, "argsdesc");
+  }
+
+  function testArgsNone() {
+    $aodef = new Aodef(["-o"]);
+    self::assertArg($aodef,
+      ["-o"],
+      true, false, false,
+      false, 0, 0, "");
+
+    $aodef = new Aodef(["--longo"]);
+    self::assertArg($aodef,
+      ["--longo"],
+      false, true, false,
+      false, 0, 0, "");
+
+    $aodef = new Aodef(["-o", "--longo"]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      false, 0, 0, "");
+  }
+
+  function testArgsMandatory() {
+    $aodef = new Aodef(["-o:", "--longo"]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 1, 1, "VALUE");
+
+    $aodef = new Aodef(["-a:", "-b:"]);
+    self::assertArg($aodef,
+      ["-a", "-b"],
+      true, false, false,
+      true, 1, 1, "VALUE");
+
+    $aodef = new Aodef(["-a:", "-b::"]);
+    self::assertArg($aodef,
+      ["-a", "-b"],
+      true, false, false,
+      true, 1, 1, "VALUE");
+
+    $aodef = new Aodef(["-a::", "-b:"]);
+    self::assertArg($aodef,
+      ["-a", "-b"],
+      true, false, false,
+      true, 1, 1, "VALUE");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => true]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 1, 1, "VALUE");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => 1]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 1, 1, "VALUE");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => "value"]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 1, 1, "VALUE");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => ["value"]]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 1, 1, "VALUE");
+  }
+
+  function testArgsOptional() {
+    $aodef = new Aodef(["-o::", "--longo"]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 0, 1, "[VALUE]");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => [["value"]]]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 0, 1, "[VALUE]");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => [[null]]]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 0, PHP_INT_MAX, "[VALUEs...]");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => ["value", null]]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 1, PHP_INT_MAX, "VALUE [VALUEs...]");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => "*"]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 0, PHP_INT_MAX, "[VALUEs...]");
+
+    $aodef = new Aodef(["-o", "--longo", "args" => "+"]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 1, PHP_INT_MAX, "VALUE [VALUEs...]");
+  }
+
+  function testMerge() {
+    $BASE = ["-o:", "--longo"];
+
+    $aodef = new Aodef([
+      "merge" => $BASE,
+      "add" => ["-a", "--longa"],
+      "remove" => ["-o", "--longo"],
+    ]);
+    self::assertArg($aodef,
+      ["-a", "--longa"],
+      true, true, false,
+      false, 0, 0, "");
+
+    $aodef = new Aodef([
+      "merge" => $BASE,
+      "add" => ["-a", "--longa"],
+      "remove" => ["-o", "--longo"],
+      "-x",
+    ]);
+    self::assertArg($aodef,
+      ["-a", "--longa", "-x"],
+      true, true, false,
+      false, 0, 0, "");
+  }
+
+  function testArgsdesc() {
+    $aodef = new Aodef(["-o:value", "--longo"]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 1, 1, "VALUE");
+
+    $aodef = new Aodef(["-o:file,suffix", "--longo"]);
+    self::assertArg($aodef,
+      ["-o", "--longo"],
+      true, true, false,
+      true, 2, 2, "FILE SUFFIX");
+  }
+}
diff --git a/php/tests/app/args/AolistTest.php b/php/tests/app/args/AolistTest.php
new file mode 100644
index 0000000..acf0402
--- /dev/null
+++ b/php/tests/app/args/AolistTest.php
@@ -0,0 +1,60 @@
+ "value",
+      ["--opt"],
+      ["group",
+        ["--gopt1"],
+        ["--gopt2"],
+      ],
+      "sections" => [
+        [
+          ["--s0opt"],
+          ["group",
+            ["--s0gopt1"],
+            ["--s0gopt2"],
+          ],
+        ],
+        "ns" => [
+          ["--nsopt"],
+          ["group",
+            ["--nsgopt1"],
+            ["--nsgopt2"],
+          ],
+        ],
+      ],
+    ]) extends Aolist {};
+
+    echo "$aolist\n";
+    self::assertTrue(true);
+  }
+}
diff --git a/php/tests/app/args/SimpleAolistTest.php b/php/tests/app/args/SimpleAolistTest.php
new file mode 100644
index 0000000..750e401
--- /dev/null
+++ b/php/tests/app/args/SimpleAolistTest.php
@@ -0,0 +1,75 @@
+ [
+        ["-o", "--longo"],
+      ],
+    ]);
+    echo "$aolist\n"; #XXX
+
+    $aolist = new SimpleAolist([
+      ["-o", "--longo"],
+      ["-o", "--longx"],
+    ]);
+    echo "$aolist\n"; #XXX
+
+    $aolist = new SimpleAolist([
+      ["-o", "--longo"],
+      ["-o"],
+      ["--longo"],
+    ]);
+    echo "$aolist\n"; #XXX
+
+    self::assertTrue(true);
+  }
+
+  function testExtends() {
+    $ARGS0 = [
+      ["-o:", "--longo",
+        "name" => "desto",
+        "help" => "help longo"
+      ],
+      ["-a:", "--longa",
+        "name" => "desta",
+        "help" => "help longa"
+      ],
+    ];
+    $ARGS = [
+      "merge" => $ARGS0,
+      ["extends" => "-a",
+        "remove" => ["--longa"],
+        "add" => ["--desta"],
+        "help" => "help desta"
+      ],
+    ];
+    //$aolist0 = new SimpleArgDefs($ARGS0);
+    //echo "$aolist0\n"; #XXX
+    $aolist = new SimpleAolist($ARGS);
+    echo "$aolist\n"; #XXX
+
+    self::assertTrue(true);
+  }
+
+  function testRemainingArgs() {
+    $aolist = new SimpleAolist([]);
+    echo "$aolist\n"; #XXX
+
+    $aolist = new SimpleAolist([
+      ["name" => "args"],
+    ]);
+    echo "$aolist\n"; #XXX
+
+    $aolist = new SimpleAolist([
+      ["args" => 2, "name" => "args"],
+    ]);
+    echo "$aolist\n"; #XXX
+
+    self::assertTrue(true);
+  }
+}
diff --git a/php/tests/app/args/SimpleArgsParserTest.php b/php/tests/app/args/SimpleArgsParserTest.php
new file mode 100644
index 0000000..de46776
--- /dev/null
+++ b/php/tests/app/args/SimpleArgsParserTest.php
@@ -0,0 +1,198 @@
+ [["value", "value"]]],
+    ["--mo12:", "args" => ["value", ["value"]]],
+    ["--mo22:", "args" => ["value", "value"]],
+  ];
+  const NORMALIZE_TESTS = [
+    [], ["--"],
+    ["--"], ["--"],
+    ["--", "--"], ["--", "--"],
+    ["-aa"], ["-a", "-a", "--"],
+    ["a", "b"], ["--", "a", "b"],
+    ["-a", "--ma", "x", "a", "--ma=y", "b"], ["-a", "--mandatory", "x", "--mandatory", "y", "--", "a", "b"],
+    ["-mx", "-m", "y"], ["-m", "x", "-m", "y", "--"],
+    ["-ox", "-o", "y"], ["-ox", "-o", "--", "y"],
+    ["-a", "--", "-a", "-c"], ["-a", "--", "-a", "-c"],
+
+    # -a et -b doivent être considérés comme arguments, -n comme option
+    ["--mo02"],                   ["--mo02", "--", "--"],
+    ["--mo02", "-a"],             ["--mo02", "-a", "--", "--"],
+    ["--mo02", "--"],             ["--mo02", "--", "--"],
+    ["--mo02", "--", "-n"],       ["--mo02", "--", "-n", "--"],
+    ["--mo02", "--", "--", "-b"], ["--mo02", "--", "--", "-b"],
+    #
+    ["--mo02", "-a"],                   ["--mo02", "-a", "--", "--"],
+    ["--mo02", "-a", "-a"],             ["--mo02", "-a", "-a", "--"],
+    ["--mo02", "-a", "--"],             ["--mo02", "-a", "--", "--"],
+    ["--mo02", "-a", "--", "-n"],       ["--mo02", "-a", "--", "-n", "--"],
+    ["--mo02", "-a", "--", "--", "-b"], ["--mo02", "-a", "--", "--", "-b"],
+
+    [
+      "--mo02", "--",
+      "--mo02", "x", "--",
+      "--mo02", "x", "y",
+      "--mo12", "x", "--",
+      "--mo12", "x", "y",
+      "--mo22", "x", "y",
+      "z",
+    ], [
+      "--mo02", "--",
+      "--mo02", "x", "--",
+      "--mo02", "x", "y",
+      "--mo12", "x", "--",
+      "--mo12", "x", "y",
+      "--mo22", "x", "y",
+      "--",
+      "z",
+    ],
+  ];
+
+  function testNormalize() {
+    $parser = new SimpleArgsParser(self::NORMALIZE_ARGS);
+    $count = count(self::NORMALIZE_TESTS);
+    for ($i = 0; $i < $count; $i += 2) {
+      $args = self::NORMALIZE_TESTS[$i];
+      $expected = self::NORMALIZE_TESTS[$i + 1];
+      $normalized = $parser->normalize($args);
+      self::assertSame($expected, $normalized
+        , "for ".var_export($args, true)
+        .", normalized is ".var_export($normalized, true)
+      );
+    }
+  }
+
+  function testArgsNone() {
+    $parser = new SimpleArgsParser([
+      ["-z"],
+      ["-a"],
+      ["-b"],
+      ["-c",],
+      ["-d", "value" => 42],
+    ]);
+
+    $dest = []; $parser->parse($dest, ["-a", "-bb", "-ccc", "-dddd"]);
+    self::assertSame(null, $dest["z"] ?? null);
+    self::assertSame(1, $dest["a"] ?? null);
+    self::assertSame(2, $dest["b"] ?? null);
+    self::assertSame(3, $dest["c"] ?? null);
+    self::assertSame(42, $dest["d"] ?? null);
+
+    self::assertTrue(true);
+  }
+
+  function testArgsMandatory() {
+    $parser = new SimpleArgsParser([
+      ["-z:"],
+      ["-a:"],
+      ["-b:"],
+      ["-c:", "value" => 42],
+    ]);
+
+    $dest = []; $parser->parse($dest, [
+      "-a",
+      "-bb",
+      "-c",
+      "-c15",
+      "-c30",
+      "-c45",
+    ]);
+    self::assertSame(null, $dest["z"] ?? null);
+    self::assertSame("-bb", $dest["a"] ?? null);
+    self::assertSame(null, $dest["b"] ?? null);
+    self::assertSame("45", $dest["c"] ?? null);
+
+    self::assertTrue(true);
+  }
+
+  function testArgsOptional() {
+    $parser = new SimpleArgsParser([
+      ["-z::"],
+      ["-a::"],
+      ["-b::"],
+      ["-c::", "value" => 42],
+      ["-d::", "value" => 42],
+    ]);
+
+    $dest = []; $parser->parse($dest, [
+      "-a",
+      "-bb",
+      "-c",
+      "-d15",
+      "-d30",
+    ]);
+    self::assertSame(null, $dest["z"] ?? null);
+    self::assertSame(null, $dest["a"] ?? null);
+    self::assertSame("b", $dest["b"] ?? null);
+    self::assertSame(42, $dest["c"] ?? null);
+    self::assertSame("30", $dest["d"] ?? null);
+
+    self::assertTrue(true);
+  }
+
+  function testRemains() {
+    $parser = new SimpleArgsParser([]);
+    $dest = []; $parser->parse($dest, ["x", "y"]);
+    self::assertSame(["x", "y"], $dest["args"] ?? null);
+  }
+
+  function test() {
+    $parser = new SimpleArgsParser([
+      ["-n", "--none"],
+      ["-m:", "--mandatory"],
+      ["-o::", "--optional"],
+      ["--mo02:", "args" => [["value", "value"]]],
+      ["--mo12:", "args" => ["value", ["value"]]],
+      ["--mo22:", "args" => ["value", "value"]],
+    ]);
+    $parser->parse($dest, [
+      "--mo02", "--",
+      "--mo02", "x", "--",
+      "--mo02", "x", "y",
+      "--mo12", "x", "--",
+      "--mo12", "x", "y",
+      "--mo22", "x", "y",
+      "z",
+    ]);
+
+    self::assertTrue(true);
+  }
+
+  function testAutono() {
+    $parser = new SimpleArgsParser([
+      ["-a", "--plouf"],
+      ["-b", "--no-plouf"],
+    ]);
+    $dest = [];
+    $parser->parse($dest, ["-aabb"]);
+    self::assertSame(["plouf" => 0, "args" => []], $dest);
+
+    $parser = new SimpleArgsParser([
+      ["-a", "--plouf", "value" => true],
+      ["-b", "--no-plouf", "value" => false],
+    ]);
+    $dest = ["plouf" => null];
+    $parser->parse($dest, []);
+    self::assertSame(["plouf" => null, "args" => []], $dest);
+    $dest = ["plouf" => null];
+    $parser->parse($dest, ["-a"]);
+    self::assertSame(["plouf" => true, "args" => []], $dest);
+    $dest = ["plouf" => null];
+    $parser->parse($dest, ["-b"]);
+    self::assertSame(["plouf" => false, "args" => []], $dest);
+  }
+}
diff --git a/php/tests/app/argsTest.php b/php/tests/app/argsTest.php
index c0a894c..3404d23 100644
--- a/php/tests/app/argsTest.php
+++ b/php/tests/app/argsTest.php
@@ -3,7 +3,6 @@
 namespace nulib\app;
 
 use nulib\tests\TestCase;
-use nulib\app\args;
 
 class argsTest extends TestCase {
   function testFrom_array() {
diff --git a/php/tests/app/config/ConfigManagerTest.php b/php/tests/app/config/ConfigManagerTest.php
new file mode 100644
index 0000000..415a6bd
--- /dev/null
+++ b/php/tests/app/config/ConfigManagerTest.php
@@ -0,0 +1,124 @@
+addConfigurator(config1::class);
+      $config->configure();
+      self::assertSame([
+        "config1::static configure1",
+      ], impl\result::$configured);
+
+      result::reset();
+      $config->addConfigurator(config1::class);
+      $config->configure();
+      $config->configure();
+      $config->configure();
+      self::assertSame([
+        "config1::static configure1",
+      ], impl\result::$configured);
+
+      result::reset();
+      $config->addConfigurator(new config1());
+      $config->configure();
+      self::assertSame([
+        "config1::static configure1",
+        "config1::configure2",
+      ], impl\result::$configured);
+
+      result::reset();
+      $config->addConfigurator(new config1());
+      $config->configure(["include" => "2"]);
+      self::assertSame([
+        "config1::configure2",
+      ], impl\result::$configured);
+      $config->configure(["include" => "1"]);
+      self::assertSame([
+        "config1::configure2",
+        "config1::static configure1",
+      ], impl\result::$configured);
+
+      result::reset();
+      $config->addConfigurator([
+        config1::class,
+        new config2(),
+      ]);
+      $config->configure();
+      self::assertSame([
+        "config1::static configure1",
+        "config2::static configure1",
+        "config2::configure2",
+      ], impl\result::$configured);
+    }
+
+    function testConfig() {
+      $config = new ConfigManager();
+
+      $config->addConfig([
+        "app" => [
+          "var" => "array",
+        ]
+      ]);
+      self::assertSame("array", $config->getValue("app.var"));
+
+      $config->addConfig(new ArrayConfig([
+        "app" => [
+          "var" => "instance",
+        ]
+      ]));
+      self::assertSame("instance", $config->getValue("app.var"));
+
+      $config->addConfig(config1::class);
+      self::assertSame("class1", $config->getValue("app.var"));
+
+      $config->addConfig(config2::class);
+      self::assertSame("class2", $config->getValue("app.var"));
+    }
+  }
+}
+
+namespace nulib\app\config\impl {
+  class result {
+    static array $configured = [];
+
+    static function reset() {
+      self::$configured = [];
+    }
+  }
+
+  class config1 {
+    const APP = [
+      "var" => "class1",
+    ];
+
+    static function configure1() {
+      result::$configured[] = "config1::static configure1";
+    }
+
+    function configure2() {
+      result::$configured[] = "config1::configure2";
+    }
+  }
+
+  class config2 {
+    const APP = [
+      "var" => "class2",
+    ];
+
+    static function configure1() {
+      result::$configured[] = "config2::static configure1";
+    }
+
+    function configure2() {
+      result::$configured[] = "config2::configure2";
+    }
+  }
+}
diff --git a/php/tests/cache/CursorChannelTest.php b/php/tests/cache/CursorChannelTest.php
new file mode 100644
index 0000000..70e7343
--- /dev/null
+++ b/php/tests/cache/CursorChannelTest.php
@@ -0,0 +1,38 @@
+ ["a" => "un", "b" => "deux"],
+    "eng" => ["a" => "one", "b" => "two"],
+    ["a" => 1, "b" => 2],
+  ];
+
+  function testUsage() {
+    $channel = CursorChannel::with("numbers", self::DATA, self::$storage);
+    $count = 0;
+    foreach ($channel as $key => $item) {
+      msg::info("one: $key => {$item["a"]}");
+      $count++;
+    }
+    self::assertSame(3, $count);
+  }
+
+  function testAddColumns() {
+    $channel = (new class("numbers") extends CursorChannel {
+      const NAME = "numbersac";
+      const TABLE_NAME = self::NAME;
+      const ADD_COLUMNS = [
+        "a" => "varchar(30)",
+      ];
+    })->initStorage(self::$storage)->rechargeAll(self::DATA);
+    $count = 0;
+    foreach ($channel as $key => $item) {
+      msg::info("one: $key => {$item["a"]}");
+      $count++;
+    }
+    self::assertSame(3, $count);
+  }
+}
diff --git a/php/tests/cache/SourceDb.php b/php/tests/cache/SourceDb.php
new file mode 100644
index 0000000..31dc119
--- /dev/null
+++ b/php/tests/cache/SourceDb.php
@@ -0,0 +1,22 @@
+exec("insert into source (s, i, b) values (null, null, null)");
+    $db->exec("insert into source (s, i, b) values ('false', 0, 0)");
+    $db->exec("insert into source (s, i, b) values ('first', 1, 1)");
+    $db->exec("insert into source (s, i, b) values ('second', 2, 1)");
+  }
+
+  public function __construct() {
+    parent::__construct(__DIR__."/source.db");
+  }
+}
diff --git a/php/tests/cache/_TestCase.php b/php/tests/cache/_TestCase.php
new file mode 100644
index 0000000..a8bfa11
--- /dev/null
+++ b/php/tests/cache/_TestCase.php
@@ -0,0 +1,23 @@
+close();
+  }
+}
diff --git a/php/tests/cache/cacheTest.php b/php/tests/cache/cacheTest.php
new file mode 100644
index 0000000..cdd2e8f
--- /dev/null
+++ b/php/tests/cache/cacheTest.php
@@ -0,0 +1,105 @@
+ ["a" => "un", "b" => "deux"],
+    "eng" => ["a" => "one", "b" => "two"],
+    ["a" => 1, "b" => 2],
+  ];
+
+  function _testRows(iterable $rows, int $expectedCount) {
+    $count = 0;
+    foreach ($rows as $key => $row) {
+      $parts = ["got $key => {"];
+      $i = 0;
+      foreach ($row as $k => $v) {
+        if ($i++ > 0) $parts[] = ", ";
+        $parts[] = "$k=$v";
+      }
+      $parts[] = "}";
+      msg::info(implode("", $parts));
+      $count++;
+    }
+    self::assertSame($expectedCount, $count);
+  }
+
+  function _testGet(string $dataId, int $expectedCount, callable $gencompute) {
+    msg::section($dataId);
+    cache::nc(true, true);
+
+    msg::step("premier");
+    $rows = cache::get($dataId, $gencompute());
+    $this->_testRows($rows, $expectedCount);
+    msg::step("deuxième");
+    $rows = cache::get($dataId, $gencompute());
+    $this->_testRows($rows, $expectedCount);
+
+    msg::step("vider le cache");
+    cache::nc(true, true);
+
+    msg::step("premier");
+    $rows = cache::get($dataId, $gencompute());
+    $this->_testRows($rows, $expectedCount);
+    msg::step("deuxième");
+    $rows = cache::get($dataId, $gencompute());
+    $this->_testRows($rows, $expectedCount);
+  }
+
+  function testGetStatic() {
+    $this->_testGet("getStatic", 3, function () {
+      return static function () {
+        msg::note("getdata");
+        return self::DATA;
+      };
+    });
+  }
+
+  function testGetGenerator() {
+    $this->_testGet("getGenerator", 3, function () {
+      return static function () {
+        msg::note("gendata");
+        foreach (self::DATA as $key => $item) {
+          msg::info("yield $key");
+          yield $key => $item;
+          sleep(2);
+        }
+        msg::note("fin gendata");
+      };
+    });
+  }
+
+  function _testAll(string $cursorId, int $expectedCount, callable $gencompute) {
+    msg::section($cursorId);
+    cache::nc(true, true);
+
+    msg::step("premier");
+    $rows = cache::all($cursorId, $gencompute());
+    $this->_testRows($rows, $expectedCount);
+    msg::step("deuxième");
+    $rows = cache::all($cursorId, $gencompute());
+    $this->_testRows($rows, $expectedCount);
+
+    msg::step("vider le cache");
+    cache::nc(true, true);
+
+    msg::step("premier");
+    $rows = cache::all($cursorId, $gencompute());
+    $this->_testRows($rows, $expectedCount);
+    msg::step("deuxième");
+    $rows = cache::all($cursorId, $gencompute());
+    $this->_testRows($rows, $expectedCount);
+  }
+
+  function testAllGenerator() {
+    $this->_testAll("allGenerator", 4, function() {
+      return static function() {
+        $db = new SourceDb();
+        msg::note("query source");
+        yield from $db->all("select * from source");
+      };
+    });
+  }
+}
diff --git a/php/tests/db/sqlite/ChannelMigrationTest.php b/php/tests/db/sqlite/ChannelMigrationTest.php
index fa48e7c..30a80f4 100644
--- a/php/tests/db/sqlite/ChannelMigrationTest.php
+++ b/php/tests/db/sqlite/ChannelMigrationTest.php
@@ -7,14 +7,14 @@ use nulib\db\sqlite\impl\MyChannelV2;
 use nulib\db\sqlite\impl\MyChannelV3;
 use nulib\db\sqlite\impl\MyIndexChannel;
 use nulib\output\msg;
-use nulib\output\std\StdMessenger;
+use nulib\output\std\ConsoleMessenger;
 use nulib\php\time\DateTime;
 use nulib\tests\TestCase;
 
 class ChannelMigrationTest extends TestCase {
   static function setUpBeforeClass(): void {
     parent::setUpBeforeClass();
-    msg::set_messenger_class(StdMessenger::class);
+    msg::set_messenger_class(ConsoleMessenger::class);
   }
 
   protected function addData(MyChannel $channel, array $data): void {
diff --git a/php/tests/db/sqlite/SqliteStorageTest.php b/php/tests/db/sqlite/SqliteStorageTest.php
index 540dfb7..356fcd3 100644
--- a/php/tests/db/sqlite/SqliteStorageTest.php
+++ b/php/tests/db/sqlite/SqliteStorageTest.php
@@ -1,10 +1,10 @@
  "infos pour NOM PRENOM",
+      "body" => << [
+        "PRENOM" => "prenom",
+        "NOM" => "nom",
+        "AGE" => "age",
+      ],
+    ];
+
+    $tpl = new MailTemplate($mail);
+    [
+      "subject" => $subject,
+      "body" => $body,
+    ] = $tpl->eval([
+      "nom" => "Clain",
+      "prenom" => "Jephté",
+      "age" => 47,
+    ]);
+    self::assertSame("infos pour Clain Jephté", $subject);
+    self::assertSame("bonjour Jephté Clain,
\nvous avez 47 ans
\n", $body);
+  }
+}
diff --git a/php/tests/php/funcTest.php b/php/tests/php/funcTest.php
index a382660..b942beb 100644
--- a/php/tests/php/funcTest.php
+++ b/php/tests/php/funcTest.php
@@ -1,11 +1,12 @@
 invoke([1, 2]);
       self::assertInstanceOf(C1::class, $i1); self::assertSame(1, $i1->base);
     }
-    
+
     private static function invoke_asserts(): array {
       $inv_ok = function($func) {
         return func::with($func)->invoke();
diff --git a/php/tests/php/time/DateTest.php b/php/tests/php/time/DateTest.php
index 458e42b..4656fc5 100644
--- a/php/tests/php/time/DateTest.php
+++ b/php/tests/php/time/DateTest.php
@@ -29,13 +29,15 @@ class DateTest extends TestCase {
 
   function testClone() {
     $date = self::dt("now");
+    $clone = $date->clone(true);
+    self::assertInstanceOf(MutableDate::class, $clone);
     $clone = $date->clone();
-    self::assertInstanceOf(DateTime::class, $clone);
+    self::assertInstanceOf(Date::class, $clone);
   }
 
   function testConstruct() {
-    $y = date("Y");
-    self::assertSame("05/04/$y", strval(new Date("5/4")));
+    $Y = date("Y");
+    self::assertSame("05/04/$Y", strval(new Date("5/4")));
     self::assertSame("05/04/2024", strval(new Date("5/4/24")));
     self::assertSame("05/04/2024", strval(new Date("5/4/2024")));
     self::assertSame("05/04/2024", strval(new Date("05/04/2024")));
diff --git a/php/tests/php/time/DateTimeTest.php b/php/tests/php/time/DateTimeTest.php
index 67cc9de..41ce15e 100644
--- a/php/tests/php/time/DateTimeTest.php
+++ b/php/tests/php/time/DateTimeTest.php
@@ -5,12 +5,8 @@ use DateTimeZone;
 use nulib\tests\TestCase;
 
 class DateTimeTest extends TestCase {
-  protected static function dt(string $datetime): DateTime {
-    return new DateTime($datetime, new DateTimeZone("Indian/Reunion"));
-  }
-
   function testDateTime() {
-    $date = self::dt("2024-04-05 09:15:23");
+    $date = new DateTime("2024-04-05 09:15:23");
 
     self::assertEquals("05/04/2024 09:15:23", $date->format());
     self::assertEquals("05/04/2024 09:15:23", strval($date));
@@ -31,24 +27,36 @@ class DateTimeTest extends TestCase {
   }
 
   function testDateTimeZ() {
-    $date = new DateTime("20240405T091523Z");
-    self::assertSame("20240405T131523", $date->YmdHMS);
-    self::assertSame("20240405T131523+04:00", $date->YmdHMSZ);
-    # comme on spécifie la timezone, la valeur Z est ignorée
-    $date = new DateTime("20240405T091523Z", new DateTimeZone("Indian/Reunion"));
-    self::assertSame("20240405T091523", $date->YmdHMS);
+    $date = new DateTime("20240405T091523");
     self::assertSame("20240405T091523+04:00", $date->YmdHMSZ);
+
+    $date = new DateTime("20240405T091523+02:00", null, null);
+    self::assertSame("20240405T111523+04:00", $date->YmdHMSZ);
+    $date = new DateTime("20240405T091523+02:00", null, true);
+    self::assertSame("20240405T111523+04:00", $date->YmdHMSZ);
+    $date = new DateTime("20240405T091523+02:00", null, false);
+    self::assertSame("20240405T091523+02:00", $date->YmdHMSZ);
+
+    $newtz = new DateTimeZone("+06:00");
+    $date = new DateTime("20240405T091523+02:00", $newtz, null);
+    self::assertSame("20240405T091523+02:00", $date->YmdHMSZ);
+    $date = new DateTime("20240405T091523+02:00", $newtz, false);
+    self::assertSame("20240405T091523+02:00", $date->YmdHMSZ);
+    $date = new DateTime("20240405T091523+02:00", $newtz, true);
+    self::assertSame("20240405T131523+06:00", $date->YmdHMSZ);
   }
 
   function testClone() {
-    $date = self::dt("now");
+    $date = new DateTime("now");
+    $clone = $date->clone(true);
+    self::assertInstanceOf(MutableDateTime::class, $clone);
     $clone = $date->clone();
     self::assertInstanceOf(DateTime::class, $clone);
   }
 
   function testConstruct() {
-    $y = date("Y");
-    self::assertSame("05/04/$y 00:00:00", strval(new DateTime("5/4")));
+    $Y = date("Y");
+    self::assertSame("05/04/$Y 00:00:00", strval(new DateTime("5/4")));
     self::assertSame("05/04/2024 00:00:00", strval(new DateTime("5/4/24")));
     self::assertSame("05/04/2024 00:00:00", strval(new DateTime("5/4/2024")));
     self::assertSame("05/04/2024 00:00:00", strval(new DateTime("05/04/2024")));
@@ -110,4 +118,12 @@ class DateTimeTest extends TestCase {
     self::assertFalse($b >= $b2);
     self::assertFalse($b >= $b3);
   }
+
+  function testSerialize() {
+    $date = new DateTime();
+    $serialized = serialize($date);
+    echo "serialized: $serialized\n";
+    $unserialized = unserialize($serialized);
+    self::assertEquals($date, $unserialized);
+  }
 }
diff --git a/php/tests/php/time/DelayTest.php b/php/tests/php/time/DelayTest.php
index 132bc4d..b7c1dd2 100644
--- a/php/tests/php/time/DelayTest.php
+++ b/php/tests/php/time/DelayTest.php
@@ -1,76 +1,71 @@
 getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 09:15:33"), $delay->getDest());
 
     $delay = new Delay("10", $from);
-    self::assertEquals(self::dt("2024-04-05 09:15:33"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 09:15:33"), $delay->getDest());
 
     $delay = new Delay("10s", $from);
-    self::assertEquals(self::dt("2024-04-05 09:15:33"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 09:15:33"), $delay->getDest());
 
     $delay = new Delay("s", $from);
-    self::assertEquals(self::dt("2024-04-05 09:15:24"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 09:15:24"), $delay->getDest());
 
     $delay = new Delay("5m", $from);
-    self::assertEquals(self::dt("2024-04-05 09:20:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 09:20:00"), $delay->getDest());
 
     $delay = new Delay("5m0", $from);
-    self::assertEquals(self::dt("2024-04-05 09:20:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 09:20:00"), $delay->getDest());
 
     $delay = new Delay("5m2", $from);
-    self::assertEquals(self::dt("2024-04-05 09:20:02"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 09:20:02"), $delay->getDest());
 
     $delay = new Delay("m", $from);
-    self::assertEquals(self::dt("2024-04-05 09:16:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 09:16:00"), $delay->getDest());
 
     $delay = new Delay("5h", $from);
-    self::assertEquals(self::dt("2024-04-05 14:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 14:00:00"), $delay->getDest());
 
     $delay = new Delay("5h0", $from);
-    self::assertEquals(self::dt("2024-04-05 14:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 14:00:00"), $delay->getDest());
 
     $delay = new Delay("5h2", $from);
-    self::assertEquals(self::dt("2024-04-05 14:02:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 14:02:00"), $delay->getDest());
 
     $delay = new Delay("h", $from);
-    self::assertEquals(self::dt("2024-04-05 10:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-05 10:00:00"), $delay->getDest());
 
     $delay = new Delay("5d", $from);
-    self::assertEquals(self::dt("2024-04-10 05:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-10 05:00:00"), $delay->getDest());
 
     $delay = new Delay("5d2", $from);
-    self::assertEquals(self::dt("2024-04-10 02:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-10 02:00:00"), $delay->getDest());
 
     $delay = new Delay("5d0", $from);
-    self::assertEquals(self::dt("2024-04-10 00:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-10 00:00:00"), $delay->getDest());
 
     $delay = new Delay("d", $from);
-    self::assertEquals(self::dt("2024-04-06 05:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-06 05:00:00"), $delay->getDest());
 
     $delay = new Delay("2w", $from);
-    self::assertEquals(self::dt("2024-04-21 05:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-21 05:00:00"), $delay->getDest());
 
     $delay = new Delay("2w2", $from);
-    self::assertEquals(self::dt("2024-04-21 02:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-21 02:00:00"), $delay->getDest());
 
     $delay = new Delay("2w0", $from);
-    self::assertEquals(self::dt("2024-04-21 00:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-21 00:00:00"), $delay->getDest());
 
     $delay = new Delay("w", $from);
-    self::assertEquals(self::dt("2024-04-07 05:00:00"), $delay->getDest());
+    self::assertEquals(new MutableDateTime("2024-04-07 05:00:00"), $delay->getDest());
   }
 
   function testElapsed() {
@@ -80,4 +75,27 @@ class DelayTest extends TestCase {
     sleep(5);
     self::assertTrue($delay->isElapsed());
   }
+
+  function testSerialize() {
+    $delay = new Delay(5);
+    $serialized = serialize($delay);
+    echo "serialized: $serialized\n";
+    $unserialized = unserialize($serialized);
+    self::assertEquals($delay, $unserialized);
+  }
+
+  function testInf() {
+    $delay = new Delay("INF");
+    self::assertSame("INF", strval($delay));
+    self::assertFalse($delay->isElapsed());
+
+    $diff = $delay->getDiff();
+    self::assertSame("-P1000YT", strval($diff));
+
+    $serialized = serialize($delay);
+    self::assertSame('O:20:"nulib\php\time\Delay":2:{i:0;N;i:1;s:3:"INF";}', $serialized);
+    echo "serialized: $serialized\n";
+    $unserialized = unserialize($serialized);
+    self::assertEquals($delay, $unserialized);
+  }
 }
diff --git a/php/tests/php/time/MutableDateTest.php b/php/tests/php/time/MutableDateTest.php
new file mode 100644
index 0000000..26f425d
--- /dev/null
+++ b/php/tests/php/time/MutableDateTest.php
@@ -0,0 +1,87 @@
+format());
+    self::assertSame("05/04/2024", strval($date));
+    self::assertSame(2024, $date->year);
+    self::assertSame(4, $date->month);
+    self::assertSame(5, $date->day);
+    self::assertSame(0, $date->hour);
+    self::assertSame(0, $date->minute);
+    self::assertSame(0, $date->second);
+    self::assertSame(5, $date->wday);
+    self::assertSame(14, $date->wnum);
+    self::assertSame("+04:00", $date->timezone);
+    self::assertSame("05/04/2024 00:00:00", $date->datetime);
+    self::assertSame("05/04/2024", $date->date);
+  }
+
+  function testClone() {
+    $date = self::dt("now");
+    $clone = $date->clone(true);
+    self::assertInstanceOf(MutableDate::class, $clone);
+    $clone = $date->clone();
+    self::assertInstanceOf(Date::class, $clone);
+  }
+
+  function testConstruct() {
+    $Y = date("Y");
+    self::assertSame("05/04/$Y", strval(new MutableDate("5/4")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/24")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024")));
+    self::assertSame("05/04/2024", strval(new MutableDate("05/04/2024")));
+    self::assertSame("05/04/2024", strval(new MutableDate("20240405")));
+    self::assertSame("05/04/2024", strval(new MutableDate("240405")));
+    self::assertSame("05/04/2024", strval(new MutableDate("20240405T091523")));
+    self::assertSame("05/04/2024", strval(new MutableDate("20240405T091523Z")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024 9:15:23")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024 9.15.23")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024 9:15")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024 9.15")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024 9h15")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024 09:15:23")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024 09:15")));
+    self::assertSame("05/04/2024", strval(new MutableDate("5/4/2024 09h15")));
+  }
+
+  function testCompare() {
+    $a = new MutableDate("10/02/2024");
+    $b = new MutableDate("15/02/2024");
+    $c = new MutableDate("20/02/2024");
+    $a2 = new MutableDate("10/02/2024");
+    $b2 = new MutableDate("15/02/2024");
+    $c2 = new MutableDate("20/02/2024");
+
+    self::assertTrue($a == $a2);
+    self::assertFalse($a === $a2);
+    self::assertTrue($b == $b2);
+    self::assertTrue($c == $c2);
+
+    self::assertFalse($a < $a);
+    self::assertTrue($a < $b);
+    self::assertTrue($a < $c);
+
+    self::assertTrue($a <= $a);
+    self::assertTrue($a <= $b);
+    self::assertTrue($a <= $c);
+
+    self::assertFalse($c > $c);
+    self::assertTrue($c > $b);
+    self::assertTrue($c > $a);
+
+    self::assertTrue($c >= $c);
+    self::assertTrue($c >= $b);
+    self::assertTrue($c >= $a);
+  }
+}
diff --git a/php/tests/php/time/MutableDateTimeTest.php b/php/tests/php/time/MutableDateTimeTest.php
new file mode 100644
index 0000000..a83f129
--- /dev/null
+++ b/php/tests/php/time/MutableDateTimeTest.php
@@ -0,0 +1,121 @@
+format());
+    self::assertEquals("05/04/2024 09:15:23", strval($date));
+    self::assertSame(2024, $date->year);
+    self::assertSame(4, $date->month);
+    self::assertSame(5, $date->day);
+    self::assertSame(9, $date->hour);
+    self::assertSame(15, $date->minute);
+    self::assertSame(23, $date->second);
+    self::assertSame(5, $date->wday);
+    self::assertSame(14, $date->wnum);
+    self::assertEquals("+04:00", $date->timezone);
+    self::assertSame("05/04/2024 09:15:23", $date->datetime);
+    self::assertSame("05/04/2024", $date->date);
+    self::assertSame("20240405", $date->Ymd);
+    self::assertSame("20240405T091523", $date->YmdHMS);
+    self::assertSame("20240405T091523+04:00", $date->YmdHMSZ);
+  }
+
+  function testDateTimeZ() {
+    $date = new MutableDateTime("20240405T091523");
+    self::assertSame("20240405T091523+04:00", $date->YmdHMSZ);
+
+    $date = new MutableDateTime("20240405T091523+02:00", null, null);
+    self::assertSame("20240405T111523+04:00", $date->YmdHMSZ);
+    $date = new MutableDateTime("20240405T091523+02:00", null, true);
+    self::assertSame("20240405T111523+04:00", $date->YmdHMSZ);
+    $date = new MutableDateTime("20240405T091523+02:00", null, false);
+    self::assertSame("20240405T091523+02:00", $date->YmdHMSZ);
+
+    $newtz = new DateTimeZone("+06:00");
+    $date = new MutableDateTime("20240405T091523+02:00", $newtz, null);
+    self::assertSame("20240405T091523+02:00", $date->YmdHMSZ);
+    $date = new MutableDateTime("20240405T091523+02:00", $newtz, false);
+    self::assertSame("20240405T091523+02:00", $date->YmdHMSZ);
+    $date = new MutableDateTime("20240405T091523+02:00", $newtz, true);
+    self::assertSame("20240405T131523+06:00", $date->YmdHMSZ);
+  }
+
+  function testClone() {
+    $date = new MutableDateTime("now");
+    $clone = $date->clone(true);
+    self::assertInstanceOf(MutableDateTime::class, $clone);
+    $clone = $date->clone();
+    self::assertInstanceOf(DateTime::class, $clone);
+  }
+
+  function testConstruct() {
+    $Y = date("Y");
+    self::assertSame("05/04/$Y 00:00:00", strval(new MutableDateTime("5/4")));
+    self::assertSame("05/04/2024 00:00:00", strval(new MutableDateTime("5/4/24")));
+    self::assertSame("05/04/2024 00:00:00", strval(new MutableDateTime("5/4/2024")));
+    self::assertSame("05/04/2024 00:00:00", strval(new MutableDateTime("05/04/2024")));
+    self::assertSame("05/04/2024 00:00:00", strval(new MutableDateTime("20240405")));
+    self::assertSame("05/04/2024 00:00:00", strval(new MutableDateTime("240405")));
+    self::assertSame("05/04/2024 09:15:23", strval(new MutableDateTime("20240405T091523")));
+    self::assertSame("05/04/2024 13:15:23", strval(new MutableDateTime("20240405T091523Z")));
+    self::assertSame("05/04/2024 09:15:23", strval(new MutableDateTime("5/4/2024 9:15:23")));
+    self::assertSame("05/04/2024 09:15:23", strval(new MutableDateTime("5/4/2024 9.15.23")));
+    self::assertSame("05/04/2024 09:15:00", strval(new MutableDateTime("5/4/2024 9:15")));
+    self::assertSame("05/04/2024 09:15:00", strval(new MutableDateTime("5/4/2024 9.15")));
+    self::assertSame("05/04/2024 09:15:00", strval(new MutableDateTime("5/4/2024 9h15")));
+    self::assertSame("05/04/2024 09:15:23", strval(new MutableDateTime("5/4/2024 09:15:23")));
+    self::assertSame("05/04/2024 09:15:00", strval(new MutableDateTime("5/4/2024 09:15")));
+    self::assertSame("05/04/2024 09:15:00", strval(new MutableDateTime("5/4/2024 09h15")));
+  }
+
+  function testCompare() {
+    $a = new MutableDateTime("10/02/2024");
+    $a2 = new MutableDateTime("10/02/2024 8:30");
+    $a3 = new MutableDateTime("10/02/2024 15:45");
+    $b = new MutableDateTime("15/02/2024");
+    $b2 = new MutableDateTime("15/02/2024 8:30");
+    $b3 = new MutableDateTime("15/02/2024 15:45");
+    $x = new MutableDateTime("10/02/2024");
+    $x2 = new MutableDateTime("10/02/2024 8:30");
+    $x3 = new MutableDateTime("10/02/2024 15:45");
+
+    self::assertTrue($a == $x);
+    self::assertFalse($a === $x);
+    self::assertTrue($a2 == $x2);
+    self::assertTrue($a3 == $x3);
+
+    self::assertFalse($a < $a);
+    self::assertTrue($a < $a2);
+    self::assertTrue($a < $a3);
+    self::assertTrue($a < $b);
+    self::assertTrue($a < $b2);
+    self::assertTrue($a < $b3);
+
+    self::assertTrue($a <= $a);
+    self::assertTrue($a <= $a2);
+    self::assertTrue($a <= $a3);
+    self::assertTrue($a <= $b);
+    self::assertTrue($a <= $b2);
+    self::assertTrue($a <= $b3);
+
+    self::assertTrue($b > $a);
+    self::assertTrue($b > $a2);
+    self::assertTrue($b > $a3);
+    self::assertFalse($b > $b);
+    self::assertFalse($b > $b2);
+    self::assertFalse($b > $b3);
+
+    self::assertTrue($b >= $a);
+    self::assertTrue($b >= $a2);
+    self::assertTrue($b >= $a3);
+    self::assertTrue($b >= $b);
+    self::assertFalse($b >= $b2);
+    self::assertFalse($b >= $b3);
+  }
+}
diff --git a/runphp/TODO.md b/runphp/TODO.md
new file mode 100644
index 0000000..b896d4a
--- /dev/null
+++ b/runphp/TODO.md
@@ -0,0 +1,6 @@
+# TODO
+
+* dans `phpwrapper-_wrapper.sh`, corriger le calcul de link à la ligne 61,
+  parce que le chemin relatif peut être différent que ../bin
+
+-*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary
\ No newline at end of file
diff --git a/runphp/dot-build.env.dist b/runphp/dot-build.env.dist
index 7baed50..1d19470 100644
--- a/runphp/dot-build.env.dist
+++ b/runphp/dot-build.env.dist
@@ -15,7 +15,7 @@ PRIVAREG=
 # Ne pas toucher à partir d'ici
 
 REGISTRY=pubdocker.univ-reunion.fr/dist
-DIST=d12
+DIST=d13
 IMAGENAME=nulib/
 #DEVUSER_USERENT=user:x:1000:1000:User,,,:/home/user:/bin/bash
 #DEVUSER_GROUPENT=user:x:1000:
diff --git a/runphp/dot-runphp.conf b/runphp/dot-runphp.conf
index 0c2f16a..1906caa 100644
--- a/runphp/dot-runphp.conf
+++ b/runphp/dot-runphp.conf
@@ -4,5 +4,5 @@
 RUNPHP=
 
 # Si RUNPHP n'est pas défini, les variables suivantes peuvent être définies
-#DIST=d12
+#DIST=d13
 #REGISTRY=pubdocker.univ-reunion.fr/dist
diff --git a/runphp/phpwrapper-.launcher.php b/runphp/phpwrapper-.launcher.php
index adabe3d..802d9cd 100644
--- a/runphp/phpwrapper-.launcher.php
+++ b/runphp/phpwrapper-.launcher.php
@@ -1,12 +1,12 @@
 # TODO Faire une copie de ce script dans un répertoire de l'application web
-# (dans le répertoire cli_config/ par défaut) et modifier les paramètres si nécessaire
+# (dans le répertoire cli/config/ par défaut) et modifier les paramètres si nécessaire
 #-------------------------------------------------------------------------------
  __DIR__.'/..',
-  "appcode" => \app\config\bootstrap::APPCODE,
+  "projdir" => __DIR__.'/@@CLI2PROJ@@',
+  "projcode" => \app\config\bootstrap::PROJCODE,
 ];
-require __DIR__.'/../vendor/nulib/base/php/src/app/cli/include-launcher.php';
+require __DIR__.'/@@CLI2PROJ@@/vendor/nulib/base/php/src/app/cli/include-launcher.php';
diff --git a/runphp/phpwrapper-_bg_launcher.php b/runphp/phpwrapper-_bg_launcher.php
index 783576a..86e28aa 100644
--- a/runphp/phpwrapper-_bg_launcher.php
+++ b/runphp/phpwrapper-_bg_launcher.php
@@ -2,17 +2,17 @@
 # (dans le répertoire sbin/ par défaut) et modifier les paramètres si nécessaire
 #-------------------------------------------------------------------------------
  __DIR__.'/..',
-  "appcode" => \app\config\bootstrap::APPCODE,
+  "projdir" => __DIR__.'/@@SBIN2PROJ@@',
+  "projcode" => \app\config\bootstrap::PROJCODE,
 ]);
 BgLauncherApp::run();
diff --git a/runphp/phpwrapper-_wrapper.sh b/runphp/phpwrapper-_wrapper.sh
index 05569e8..5320701 100644
--- a/runphp/phpwrapper-_wrapper.sh
+++ b/runphp/phpwrapper-_wrapper.sh
@@ -1,5 +1,5 @@
 # TODO Faire une copie de ce script dans un répertoire de l'application web
-# (dans le répertoire cli_config/ par défaut) et modifier les paramétres si nécessaire
+# (dans le répertoire cli/config/ par défaut) et modifier les paramétres si nécessaire
 #-------------------------------------------------------------------------------
 #!/bin/bash
 # -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
@@ -7,7 +7,7 @@
 # Tous les chemins suivants sont relatifs au répertoire qui contient ce script
 
 # Chemin relatif de la racine du projet
-PROJPATH=..
+PROJPATH=@@CLI2PROJ@@
 
 # Chemin relatif vers le lanceur PHP
 LAUNCHERPATH=.launcher.php
@@ -100,10 +100,9 @@ if [ "$RUNPHP_MODE" == host ]; then
     args+=(
         --workdir "$cwd"
         "$COMPOSE_SERVICE"
-        exec "$MYNAME"
+        exec "$0"
         "$@"
     )
-    cd "$PROJDIR"
     exec "${args[@]}"
 fi
 
diff --git a/runphp/runphp b/runphp/runphp
index 30f3056..f534d5a 100755
--- a/runphp/runphp
+++ b/runphp/runphp
@@ -41,7 +41,7 @@ BUILD_FLAVOUR=
 ## ici
 
 # version de debian à utiliser pour l'image
-# d12=php8.2, d11=php7.4, d10=php7.3
+# d13=php8.4 d12=php8.2, d11=php7.4, d10=php7.3
 DIST=
 
 # Nom de base de l'image (sans le registry), e.g prefix/
@@ -91,7 +91,7 @@ if [ -f "$MYDIR/runphp.userconf.local" ]; then
     source "$MYDIR/runphp.userconf.local"
 fi
 
-DEFAULT_DIST=d12
+DEFAULT_DIST=d13
 if [ -n "$RUNPHP_STANDALONE" ]; then
     PROJDIR="$RUNPHP_PROJDIR"
 
@@ -536,41 +536,36 @@ OPTIONS
     done
 
     # monter le répertoire qui contient $PROJDIR
-    mount_composer=
+    Cwd="$(pwd)"
+    mount_homes=1
     mount_standalone=1
     mount_mount=1
+    mount_cwd=1
     if [ -z "$PROJDIR" -o "${PROJDIR#$HOME/}" != "$PROJDIR" -o "$PROJDIR" == "$HOME" ]; then
         # bind mount $HOME
         args+=(-v "$HOME:$HOME${UseRslave:+:rslave}")
-        if [ -n "$RUNPHP_STANDALONE" -a "${RUNPHP_STANDALONE#$HOME/}" != "$RUNPHP_STANDALONE" ]; then
-            mount_standalone=
-        fi
-        if [ -n "$RUNPHP_MOUNT" -a "${RUNPHP_MOUNT#$HOME/}" != "$RUNPHP_MOUNT" ]; then
-            mount_mount=
-        fi
-    elif [ -n "$PROJDIR" ]; then
-        # bind mount uniquement le répertoire du projet
+        [ "${HOME#/home/}" != "$HOME" ] && mount_homes=
+        [ -n "$RUNPHP_STANDALONE" -a "${RUNPHP_STANDALONE#$HOME/}" != "$RUNPHP_STANDALONE" ] && mount_standalone=
+        [ -n "$RUNPHP_MOUNT" -a "${RUNPHP_MOUNT#$HOME/}" != "$RUNPHP_MOUNT" ] && mount_mount=
+        [ "${Cwd#$HOME/}" != "$Cwd" ] && mount_cwd=
+    elif [ -n "$PROJDIR" -a "${PROJDIR#/home/}" == "$PROJDIR" ]; then
+        # bind mount le répertoire du projet s'il n'est pas dans /home (qui est
+        # monté par défaut si $HOME n'est pas monté)
         args+=(-v "$PROJDIR:$PROJDIR${UseRslave:+:rslave}")
-        mount_composer=1
-        [ "$RUNPHP_STANDALONE" == "$PROJDIR" ] && mount_standalone=
-        [ "$RUNPHP_MOUNT" == "$PROJDIR" ] && mount_mount=
+        [ "$RUNPHP_STANDALONE" == "$PROJDIR" -o "${RUNPHP_STANDALONE#$PROJDIR/}" != "$PROJDIR"] && mount_standalone=
+        [ "$RUNPHP_MOUNT" == "$PROJDIR" -o "${RUNPHP_MOUNT#$PROJDIR/}" != "$PROJDIR" ] && mount_mount=
+        [ "$Cwd" == "$PROJDIR" -o "${Cwd#$PROJDIR/}" != "$PROJDIR" ] && mount_cwd=
     fi
-    if [ -n "$mount_composer" -a -d "$HOME/.composer" ]; then
-        # monter la configuration de composer
-        args+=(-v "$HOME/.composer:$HOME/.composer")
-    fi
-    if [ -n "$RUNPHP_STANDALONE" -a -n "$mount_standalone" ]; then
-        args+=(-v "$RUNPHP_STANDALONE:$RUNPHP_STANDALONE")
-    fi
-    if [ -n "$RUNPHP_MOUNT" -a -n "$mount_mount" ]; then
-        args+=(-v "$RUNPHP_MOUNT:$RUNPHP_MOUNT")
-    fi
-    args+=(-w "$(pwd)")
+    [ -n "$mount_homes" ] && args+=(-v "/home:/home${UseRslave:+:rslave}")
+    [ -n "$RUNPHP_STANDALONE" -a -n "$mount_standalone" ] && args+=(-v "$RUNPHP_STANDALONE:$RUNPHP_STANDALONE${UseRslave:+:rslave}")
+    [ -n "$RUNPHP_MOUNT" -a -n "$mount_mount" ] && args+=(-v "$RUNPHP_MOUNT:$RUNPHP_MOUNT${UseRslave:+:rslave}")
+    [ -n "$mount_cwd" ] && args+=(-v "$Cwd:$Cwd${UseRslave:+:rslave}")
+    args+=(-w "$Cwd")
 
     # lancer avec l'utilisateur courant
     if [ $(id -u) -ne 0 ]; then
         # si c'est un utilisateur lambda, il faut monter les informations
-        # nécessaires. composer est déjà monté via $HOME
+        # nécessaires. composer est déjà monté via $HOME ou /home
         args+=(
             -e DEVUSER_USERENT="$(getent passwd "$(id -un)")"
             -e DEVUSER_GROUPENT="$(getent group "$(id -gn)")"
diff --git a/runphp/update-runphp.sh b/runphp/update-runphp.sh
index b4555f5..07f30e4 100755
--- a/runphp/update-runphp.sh
+++ b/runphp/update-runphp.sh
@@ -52,7 +52,7 @@ declare -A PHPWRAPPER_MODES=(
 )
 
 projdir=
-install_phpwrappers=auto
+install_phpwrappers=1
 args=(
     "Mettre à jour le script runphp"
     "[path/to/runphp]"
@@ -63,7 +63,6 @@ Copier les fichiers pour un projet de l'université de la Réunion:
 - le script build est mis à jour
 - les wrappers PHP pour la gestion des tâches de fond sont mis à jour le cas
   échéant"
-    -p,--phpwrappers-install install_phpwrappers=1 "forcer l'installation des wrappers PHP"
     --np,--no-phpwrappers-install install_phpwrappers= "ne pas installer les wrappers PHP"
 )
 parse_args "$@"; set -- "${args[@]}"
@@ -156,23 +155,31 @@ if [ -n "$projdir" ]; then
 '
     fi
 
-    sbin_path=sbin
-    cli_path=cli_config
-    if [ "$install_phpwrappers" == auto ]; then
-        if [ ! -f "$PROJDIR/$COMPOSERDIR/composer.json" ]; then
-            # ce doit être un projet PHP
-            install_phpwrappers=
-        elif [ -d "$projdir/cli_config" ]; then
-            install_phpwrappers=1
-            sbin_path=sbin
-            cli_path=cli_config
-        elif [ -d "$projdir/_cli" ]; then
-            install_phpwrappers=1
-            sbin_path=sbin
-            cli_path=_cli
-        else
-            install_phpwrappers=
-        fi
+    if [ -z "$install_phpwrappers" ]; then
+        install_phpwrappers=
+    elif [ ! -f "$PROJDIR/$COMPOSERDIR/composer.json" ]; then
+        # ce doit être un projet PHP
+        install_phpwrappers=
+    elif [ -d "$projdir/cli/config" ]; then
+        install_phpwrappers=1
+        sbin_path=sbin
+        sbin2proj=..
+        cli_path=cli/config
+        cli2proj=../..
+    elif [ -d "$projdir/cli_config" ]; then
+        install_phpwrappers=1
+        sbin_path=sbin
+        sbin2proj=..
+        cli_path=cli_config
+        cli2proj=..
+    elif [ -d "$projdir/_cli" ]; then
+        install_phpwrappers=1
+        sbin_path=sbin
+        sbin2proj=..
+        cli_path=_cli
+        cli2proj=..
+    else
+        install_phpwrappers=
     fi
 
     if [ -n "$install_phpwrappers" ]; then
@@ -193,7 +200,9 @@ if [ -n "$projdir" ]; then
             mkdir -p "$destdir"
             tail -n+4 "$MYDIR/$phpwrapper" | sed "
 s|/@@SBIN@@/|/$sbin_path/|
+s|@@SBIN2PROJ@@|$sbin2proj|
 s|/@@CLI@@/|/$cli_path/|
+s|@@CLI2PROJ@@|$cli2proj|
 " >"$destdir/$destname"
             [ -n "$mode" ] && chmod "$mode" "$destdir/$destname"
         done
diff --git a/wip/TEMPLATE b/wip/TEMPLATE
new file mode 100755
index 0000000..c46b7c5
--- /dev/null
+++ b/wip/TEMPLATE
@@ -0,0 +1,10 @@
+#!/bin/bash
+# -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
+source "$(dirname -- "$0")/../load.sh" || exit 1
+
+args=(
+    "description"
+    "usage"
+)
+parse_args "$@"; set -- "${args[@]}"
+
diff --git a/wip/pman.md b/wip/TODO.md
similarity index 95%
rename from wip/pman.md
rename to wip/TODO.md
index 50460ec..d2bc928 100644
--- a/wip/pman.md
+++ b/wip/TODO.md
@@ -1,13 +1,6 @@
-# pman
+# TODO
 
-outil pour gérer les projets PHP
-* p, pci, pp, pu: gestion courante git.
-  ces outils peuvent agir sur un ensemble de projets, notamment tous les
-  projets dépendants du projet courant
-* pver: gestion des versions.
-  calculer la prochaine version en respectant semver
-
-## scripts de gestion de projet
+## pman
 
 définir précisément le rôle des scripts
 * pdist: créer la branche DIST, basculer dessus, merger MAIN dans DIST
@@ -24,4 +17,14 @@ il faudra supprimer
 * pman: fonctionnalités réparties dans les autres scripts spécialisés
 * pmer: fonctionnalités réperties dans les autres scripts spécialisés
 
+### divers
+
+outil pour gérer les projets PHP
+* p, pci, pp, pu: gestion courante git.
+  ces outils peuvent agir sur un ensemble de projets, notamment tous les
+  projets dépendants du projet courant
+* pver: gestion des versions.
+  calculer la prochaine version en respectant semver
+
+
 -*- coding: utf-8 mode: markdown -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8:noeol:binary
\ No newline at end of file
diff --git a/bin/pman b/wip/pman.orig
similarity index 100%
rename from bin/pman
rename to wip/pman.orig
diff --git a/bin/pmer b/wip/pmer.orig
similarity index 100%
rename from bin/pmer
rename to wip/pmer.orig
diff --git a/wip/prel.orig b/wip/prel.orig
new file mode 100755
index 0000000..2ec3a31
--- /dev/null
+++ b/wip/prel.orig
@@ -0,0 +1,292 @@
+#!/bin/bash
+# -*- coding: utf-8 mode: sh -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
+source "$(dirname -- "$0")/../load.sh" || exit 1
+require: git pman pman.conf
+
+git_cleancheckout_DIRTY="\
+Vous avez des modifications locales.
+Enregistrez ces modifications avant de créer une release"
+
+function show_action() {
+    local commits
+    setx commits=_list_commits
+    if [ -n "$commits" ]; then
+        if [ $ShowLevel -ge 2 ]; then
+            {
+                echo "\
+# Commits à fusionner $SrcBranch --> $DestBranch
+
+$commits
+"
+                _sd_COLOR=always _show_diff
+            } | less -eRF
+        else
+            einfo "Commits à fusionner $SrcBranch --> $DestBranch"
+            eecho "$commits"
+        fi
+    fi
+}
+
+function ensure_branches() {
+   [ -n "$SrcBranch" -a -n "$DestBranch" ] ||
+        die "$SrcBranch: Aucune configuration de fusion trouvée pour cette branche"
+
+   array_contains LocalBranches "$SrcBranch" || die "$SrcBranch: branche source introuvable"
+   array_contains LocalBranches "$DestBranch" || die "$DestBranch: branche destination introuvable"
+
+   Tag="$TAG_PREFIX$Version$TAG_SUFFIX"
+   local -a tags
+   setx -a tags=git tag -l "${TAG_PREFIX}*${TAG_SUFFIX}"
+   if [ -z "$ForceCreate" ]; then
+       array_contains tags "$Tag" && die "$Tag: le tag correspondant à la version existe déjà"
+   fi
+}
+
+function create_release_action() {
+    if [ -n "$ReleaseBranch" ]; then
+        Version="${ReleaseBranch#$RELEASE}"
+        Tag="$TAG_PREFIX$Version$TAG_SUFFIX"
+        merge_release_action "$@"; return $?
+    elif [ -n "$HotfixBranch" ]; then
+        Version="${HotfixBranch#$HOTFIX}"
+        Tag="$TAG_PREFIX$Version$TAG_SUFFIX"
+        merge_hotfix_action "$@"; return $?
+    fi
+
+    [ -n "$ManualRelease" ] && ewarn "\
+L'option --no-merge a été forcée puisque ce dépôt ne supporte pas les releases automatiques"
+    [ -z "$ShouldPush" ] && enote "\
+L'option --no-push a été forcée puisque ce dépôt n'a pas d'origine"
+
+    if [ -z "$Version" -a -n "$CurrentVersion" -a -f VERSION.txt ]; then
+        Version="$("
+    -d:,--chdir:BASEDIR chdir= "répertoire dans lequel se placer avant de lancer les opérations"
+    -O:,--origin Origin= "++\
+origine à partir de laquelle les branches distantes sont considérées"
+    -B:,--config-branch ConfigBranch= "++\
+branche à partir de laquelle charger la configuration"
+    -c:,--config-file:CONFIG ConfigFile= "++\
+fichier de configuration des branches. cette option est prioritaire sur --config-branch
+par défaut, utiliser le fichier .pman.conf dans le répertoire du dépôt s'il existe"
+    -n,--no-push Push= "\
+ne pas pousser les branches vers leur origine après la fusion"
+    --push Push=1 "++\
+pousser les branches vers leur origine après la fusion.
+c'est l'option par défaut"
+)
+parse_args "$@"; set -- "${args[@]}"
+
+# charger la configuration
+ensure_gitdir "$chdir"
+load_branches all
+load_config "$MYNAME"
+load_branches current
+
+branch="$1"
+if [ -z "$branch" -a ${#FeatureBranches[*]} -eq 1 ]; then
+    branch="${FeatureBranches[0]}"
+fi
+[ -n "$branch" ] || die "Vous devez spécifier la branche à créer"
+branch="$FEATURE${branch#$FEATURE}"
+
+resolve_should_push
+git_ensure_cleancheckout
+
+if array_contains AllBranches "$branch"; then
+    git checkout -q "$branch"
+else
+    # si la branche source n'existe pas, la créer
+    args=(--origin "$Origin")
+    if [ -n "$ConfigFile" ]; then args+=(--config-file "$ConfigFile")
+    elif [ -n "$ConfigBranch" ]; then args+=(--config-branch "$ConfigBranch")
+    fi
+    [ -z "$Push" ] && args+=(--no-push)
+    exec "$MYDIR/pman" "${args[@]}" "$branch"
+fi