├── Resources ├── views │ └── FormFlow │ │ ├── stepList.html.twig │ │ ├── stepList_customization.html.twig │ │ ├── stepList_blocks.html.twig │ │ ├── stepList_content.html.twig │ │ └── buttons.html.twig ├── translations │ ├── CraueFormFlowBundle.fa.yml │ ├── CraueFormFlowBundle.zh_CN.yml │ ├── CraueFormFlowBundle.zh_TW.yml │ ├── CraueFormFlowBundle.en.yml │ ├── CraueFormFlowBundle.it.yml │ ├── CraueFormFlowBundle.nl.yml │ ├── CraueFormFlowBundle.fr.yml │ ├── CraueFormFlowBundle.hu.yml │ ├── CraueFormFlowBundle.pl.yml │ ├── CraueFormFlowBundle.ru.yml │ ├── CraueFormFlowBundle.sk.yml │ ├── CraueFormFlowBundle.cs.yml │ ├── CraueFormFlowBundle.de.yml │ ├── CraueFormFlowBundle.es.yml │ ├── CraueFormFlowBundle.uk.yml │ ├── validators.zh_CN.yml │ ├── validators.zh_TW.yml │ ├── CraueFormFlowBundle.pt_BR.yml │ ├── validators.en.yml │ ├── validators.hu.yml │ ├── validators.nl.yml │ ├── validators.pt_BR.yml │ ├── validators.it.yml │ ├── validators.fa.yml │ ├── validators.fr.yml │ ├── validators.pl.yml │ ├── validators.sk.yml │ ├── validators.cs.yml │ ├── validators.de.yml │ ├── validators.uk.yml │ ├── validators.ru.yml │ └── validators.es.yml ├── public │ └── css │ │ └── buttons.css └── config │ ├── util.xml │ ├── twig.xml │ └── form_flow.xml ├── Exception ├── StepLabelCallableInvalidReturnValueException.php ├── AllStepsSkippedException.php └── InvalidTypeException.php ├── Event ├── PreBindEvent.php ├── GetStepsEvent.php ├── FormFlowEvent.php ├── PostBindFlowEvent.php ├── PostValidateEvent.php ├── FlowExpiredEvent.php ├── PostBindRequestEvent.php ├── PostBindSavedDataEvent.php └── PreviousStepInvalidEvent.php ├── Storage ├── StorageKeyGeneratorInterface.php ├── StorageInterface.php ├── ExtendedDataManagerInterface.php ├── DataManagerInterface.php ├── SessionStorage.php ├── SessionProviderTrait.php ├── UserSessionStorageKeyGenerator.php ├── SerializableFile.php ├── DataManager.php └── DoctrineStorage.php ├── EventListener ├── EventListenerWithTranslatorTrait.php ├── FlowExpiredEventListener.php └── PreviousStepInvalidEventListener.php ├── Form ├── FormFlowEvents.php ├── StepInterface.php ├── Extension │ ├── FormFlowHiddenFieldExtension.php │ └── FormFlowFormExtension.php ├── StepLabel.php ├── FormFlowInterface.php ├── Step.php └── FormFlow.php ├── CraueFormFlowBundle.php ├── Util ├── TempFileUtil.php ├── FormFlowUtil.php └── StringUtil.php ├── LICENSE ├── DependencyInjection └── CraueFormFlowExtension.php ├── composer.json ├── UPGRADE-2.1.md ├── Twig └── Extension │ └── FormFlowExtension.php ├── UPGRADE-2.0.md ├── UPGRADE-3.0.md ├── CHANGELOG.md └── README.md /Resources/views/FormFlow/stepList.html.twig: -------------------------------------------------------------------------------- 1 | {% include '@CraueFormFlow/FormFlow/stepList_customization.html.twig' %} 2 | -------------------------------------------------------------------------------- /Resources/views/FormFlow/stepList_customization.html.twig: -------------------------------------------------------------------------------- 1 | {% extends '@CraueFormFlow/FormFlow/stepList_content.html.twig' %} 2 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.fa.yml: -------------------------------------------------------------------------------- 1 | button.next: ادامه 2 | button.finish: پایان 3 | button.back: قبلی 4 | button.reset: از ابتدا 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.zh_CN.yml: -------------------------------------------------------------------------------- 1 | button.next: 到下一页 2 | button.finish: 完成 3 | button.back: 回到上一页 4 | button.reset: 重新开始 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.zh_TW.yml: -------------------------------------------------------------------------------- 1 | button.next: 到下一頁 2 | button.finish: 完成 3 | button.back: 回到上一頁 4 | button.reset: 重新開始 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.en.yml: -------------------------------------------------------------------------------- 1 | button.next: next 2 | button.finish: finish 3 | button.back: back 4 | button.reset: start over 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.it.yml: -------------------------------------------------------------------------------- 1 | button.next: avanti 2 | button.finish: finito 3 | button.back: indietro 4 | button.reset: reset 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.nl.yml: -------------------------------------------------------------------------------- 1 | button.next: verder 2 | button.finish: afronden 3 | button.back: terug 4 | button.reset: opnieuw 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.fr.yml: -------------------------------------------------------------------------------- 1 | button.next: suivant 2 | button.finish: terminer 3 | button.back: précédent 4 | button.reset: recommencer 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.hu.yml: -------------------------------------------------------------------------------- 1 | button.next: tovább 2 | button.finish: befejezés 3 | button.back: vissza 4 | button.reset: előlről kezdés 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.pl.yml: -------------------------------------------------------------------------------- 1 | button.next: dalej 2 | button.finish: zakończ 3 | button.back: wstecz 4 | button.reset: zacznij od nowa 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.ru.yml: -------------------------------------------------------------------------------- 1 | button.next: вперед 2 | button.finish: закончить 3 | button.back: назад 4 | button.reset: начать снова 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.sk.yml: -------------------------------------------------------------------------------- 1 | button.next: ďalej 2 | button.finish: dokončiť 3 | button.back: späť 4 | button.reset: vrátiť sa na začiatok 5 | -------------------------------------------------------------------------------- /Resources/public/css/buttons.css: -------------------------------------------------------------------------------- 1 | .craue_formflow_buttons { 2 | overflow: hidden; 3 | } 4 | 5 | .craue_formflow_buttons button { 6 | float: right; 7 | } 8 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.cs.yml: -------------------------------------------------------------------------------- 1 | button.next: další 2 | button.finish: dokončit 3 | button.back: předchozí 4 | button.reset: vrátit se na začátek 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.de.yml: -------------------------------------------------------------------------------- 1 | button.next: weiter 2 | button.finish: fertigstellen 3 | button.back: zurück 4 | button.reset: neu beginnen 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.es.yml: -------------------------------------------------------------------------------- 1 | button.next: siguiente 2 | button.finish: finalizar 3 | button.back: atrás 4 | button.reset: empezar de nuevo 5 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.uk.yml: -------------------------------------------------------------------------------- 1 | button.next: вперед 2 | button.finish: закінчити 3 | button.back: назад 4 | button.reset: розпочати заново 5 | -------------------------------------------------------------------------------- /Resources/translations/validators.zh_CN.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: 步骤%stepNumber%的表格无效,请回到上一个步骤并重新提交。 2 | craueFormFlow.flowExpired: 这个表格已经过期,请再次提交。 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.zh_TW.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: 步驟%stepNumber%的表格無效,請回到上一個步驟並重新提交。 2 | craueFormFlow.flowExpired: 這個表格已經過期,請再次提交。 3 | -------------------------------------------------------------------------------- /Resources/translations/CraueFormFlowBundle.pt_BR.yml: -------------------------------------------------------------------------------- 1 | button.next: próximo 2 | button.finish: finalizar 3 | button.back: voltar 4 | button.reset: começar novamente 5 | -------------------------------------------------------------------------------- /Resources/translations/validators.en.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: The form for step %stepNumber% is invalid. Please go back and try to submit it again. 2 | craueFormFlow.flowExpired: This form has expired. Please submit it again. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.hu.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: A(z) %stepNumber%. lépés űrlapja érvénytelen. Kérem, próbáljon meg visszalépni, és újra elküldeni. 2 | craueFormFlow.flowExpired: Az űrlap lejárt. Kérem, próbálja újra elküldeni. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.nl.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Het formulier voor stap %stepNumber% is ongeldig. Ga terug en probeer het opnieuw te versturen. 2 | craueFormFlow.flowExpired: Dit formulier is verlopen. Verstuur het opnieuw. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.pt_BR.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: O formulário da etapa %stepNumber% é inválido. Por favor volte e tente submeter novamente. 2 | craueFormFlow.flowExpired: Este formulário expirou. Por favor envie-o novamente. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.it.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Il form allo step %stepNumber% non è valido. Si prega di tornare indietro e inviare di nuovo. 2 | craueFormFlow.flowExpired: Questo form è scaduto. Si prega di inviare di nuovo. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.fa.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: مقادیر وارد شده در گام %stepNumber% نامعتبر هستند. لطفاً یک مرحله به عقب بازگردید و مجدداً اطلاعات را ثبت نمایید. 2 | craueFormFlow.flowExpired: این فرم منقضی شده است. لطفا مجددا تلاش کنید. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.fr.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Le formulaire pour l'étape %stepNumber% est invalide. Veuillez retourner et essayez de le renvoyer. 2 | craueFormFlow.flowExpired: Ce formulaire a expiré. Veuillez essayer de le renvoyer. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.pl.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Formularz dla kroku %stepNumber% został wypełniony nieprawidłowo. Proszę cofnąć się i wypełnić go ponownie. 2 | craueFormFlow.flowExpired: Formularz stracił ważność. Proszę wypełnić go ponownie. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.sk.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Formulár pre krok %stepNumber% nie je správne vyplnený. Prosím, vráťte sa späť a skúste formulár odoslať znova. 2 | craueFormFlow.flowExpired: Tento formulár expiroval. Prosím odošlite ho znova. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.cs.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Formulář pro krok %stepNumber% není správně vyplněn. Vraťe se prosím zpět a zkuste formulář znovu odeslat. 2 | craueFormFlow.flowExpired: Platnost tohoto formuláře vypršela. Odešlete jej prosím znovu. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.de.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Das Formular für Schritt %stepNumber% ist ungültig. Gehen Sie bitte zurück und versuchen es erneut zu senden. 2 | craueFormFlow.flowExpired: Dieses Formular ist abgelaufen. Bitte senden Sie es erneut. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.uk.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Форма для кроку %stepNumber% є недопустимою. Будь ласка, поверніться та спробуйте повторно відправити форму. 2 | craueFormFlow.flowExpired: Ця форма більше недійсна. Будь ласка, надішліть форму знову. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.ru.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: Форма для шага %stepNumber% имеет недопустимое значение. Пожалуйста, вернитесь назад и попробуйте повторить отправку. 2 | craueFormFlow.flowExpired: Даная форма больше недействительна. Пожалуйста, заполните форму снова. 3 | -------------------------------------------------------------------------------- /Resources/translations/validators.es.yml: -------------------------------------------------------------------------------- 1 | craueFormFlow.previousStepInvalid: El formulario del paso %stepNumber% está inválido. Por favor, utilice el botón de atrás y pruebe de enviar el formulario de nuevo. 2 | craueFormFlow.flowExpired: El formulario ha expirado. Intente enviar de nuevo, por favor. 3 | -------------------------------------------------------------------------------- /Exception/StepLabelCallableInvalidReturnValueException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2011-2025 Christian Raue 8 | * @license http://opensource.org/licenses/mit-license.php MIT License 9 | */ 10 | class StepLabelCallableInvalidReturnValueException extends \RuntimeException { 11 | } 12 | -------------------------------------------------------------------------------- /Resources/views/FormFlow/stepList_blocks.html.twig: -------------------------------------------------------------------------------- 1 | {% block craue_flow_stepList_class %} 2 | {%- if loop.index == flow.getCurrentStepNumber() -%} 3 | {{ ' class="craue_formflow_current_step"' }} 4 | {%- elseif flow.isStepSkipped(loop.index) -%} 5 | {{ ' class="craue_formflow_skipped_step"' }} 6 | {%- elseif flow.isStepDone(loop.index) -%} 7 | {{ ' class="craue_formflow_done_step"' }} 8 | {%- endif -%} 9 | {% endblock %} 10 | 11 | {% block craue_flow_stepLabel %} 12 | {{- stepLabel | trans -}} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /Exception/AllStepsSkippedException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2011-2025 Christian Raue 8 | * @license http://opensource.org/licenses/mit-license.php MIT License 9 | */ 10 | class AllStepsSkippedException extends \RuntimeException { 11 | 12 | public function __construct() { 13 | parent::__construct('All steps are marked as skipped. Please check the flow to make sure at least one step is not skipped.'); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Event/PreBindEvent.php: -------------------------------------------------------------------------------- 1 | 11 | * @author Christian Raue 12 | * @copyright 2011-2025 Christian Raue 13 | * @license http://opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | class PreBindEvent extends FormFlowEvent { 16 | } 17 | -------------------------------------------------------------------------------- /Storage/StorageKeyGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2011-2025 Christian Raue 8 | * @license http://opensource.org/licenses/mit-license.php MIT License 9 | */ 10 | interface StorageKeyGeneratorInterface { 11 | 12 | /** 13 | * Generates a complete storage key based on the key the storage received. 14 | * Usually, the given key would be appended to a user-unique identifier to achieve a session-like behavior. 15 | * @param string $key 16 | * @return string 17 | */ 18 | function generate($key); 19 | 20 | } 21 | -------------------------------------------------------------------------------- /EventListener/EventListenerWithTranslatorTrait.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright 2011-2025 Christian Raue 12 | * @license http://opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | trait EventListenerWithTranslatorTrait { 15 | 16 | /** 17 | * @var TranslatorInterface 18 | */ 19 | protected $translator; 20 | 21 | /** 22 | * @param TranslatorInterface $translator 23 | */ 24 | public function setTranslator(TranslatorInterface $translator) { 25 | $this->translator = $translator; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Event/GetStepsEvent.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright 2011-2025 Christian Raue 12 | * @license http://opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | class GetStepsEvent extends FormFlowEvent { 15 | 16 | /** 17 | * @var StepInterface[] 18 | */ 19 | protected $steps = []; 20 | 21 | /** 22 | * @param StepInterface[] $steps 23 | */ 24 | public function setSteps(array $steps) { 25 | $this->steps = $steps; 26 | } 27 | 28 | public function getSteps() { 29 | return $this->steps; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Form/FormFlowEvents.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2011-2025 Christian Raue 8 | * @license http://opensource.org/licenses/mit-license.php MIT License 9 | */ 10 | class FormFlowEvents { 11 | 12 | const PRE_BIND = 'flow.pre_bind'; 13 | 14 | const GET_STEPS = 'flow.get_steps'; 15 | 16 | const POST_BIND_SAVED_DATA = 'flow.post_bind_saved_data'; 17 | 18 | const POST_BIND_FLOW = 'flow.post_bind_flow'; 19 | 20 | const FLOW_EXPIRED = 'flow.flow_expired'; 21 | 22 | const POST_BIND_REQUEST = 'flow.post_bind_request'; 23 | 24 | const PREVIOUS_STEP_INVALID = 'flow.previous_step_invalid'; 25 | 26 | const POST_VALIDATE = 'flow.post_validate'; 27 | 28 | } 29 | -------------------------------------------------------------------------------- /CraueFormFlowBundle.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright 2011-2025 Christian Raue 11 | * @license http://opensource.org/licenses/mit-license.php MIT License 12 | */ 13 | class CraueFormFlowBundle extends Bundle { 14 | 15 | /** 16 | * @return void 17 | */ 18 | public function boot() { 19 | /* 20 | * Removes all temporary files created while handling file uploads. 21 | * Use a shutdown function to clean up even in case of a fatal error. 22 | */ 23 | register_shutdown_function(function() : void { 24 | TempFileUtil::removeTempFiles(); 25 | }); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Event/FormFlowEvent.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright 2011-2025 Christian Raue 11 | * @license http://opensource.org/licenses/mit-license.php MIT License 12 | */ 13 | abstract class FormFlowEvent extends Event { 14 | 15 | /** 16 | * @var FormFlowInterface 17 | */ 18 | protected $flow; 19 | 20 | /** 21 | * @param FormFlowInterface $flow 22 | */ 23 | public function __construct(FormFlowInterface $flow) { 24 | $this->flow = $flow; 25 | } 26 | 27 | /** 28 | * @return FormFlowInterface 29 | */ 30 | public function getFlow() { 31 | return $this->flow; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Resources/views/FormFlow/stepList_content.html.twig: -------------------------------------------------------------------------------- 1 | {% use '@CraueFormFlow/FormFlow/stepList_blocks.html.twig' %} 2 | 3 | {%- if flow.getStepLabels() is not empty -%} 4 |
    5 | {% for stepLabel in flow.getStepLabels() %} 6 | 7 | {%- if craue_isStepLinkable(flow, loop.index) -%} 8 | 11 | {{- block('craue_flow_stepLabel') -}} 12 | 13 | {%- else -%} 14 | {{ block('craue_flow_stepLabel') }} 15 | {%- endif -%} 16 | 17 | {% endfor %} 18 |
19 | {%- endif -%} 20 | -------------------------------------------------------------------------------- /Resources/config/util.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 12 | Craue\FormFlowBundle\Util\FormFlowUtil 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Event/PostBindFlowEvent.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright 2011-2025 Christian Raue 12 | * @license http://opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | class PostBindFlowEvent extends FormFlowEvent { 15 | 16 | /** 17 | * @var mixed 18 | */ 19 | protected $formData; 20 | 21 | /** 22 | * @param FormFlowInterface $flow 23 | * @param mixed $formData 24 | */ 25 | public function __construct(FormFlowInterface $flow, $formData) { 26 | parent::__construct($flow); 27 | $this->formData = $formData; 28 | } 29 | 30 | /** 31 | * @return mixed 32 | */ 33 | public function getFormData() { 34 | return $this->formData; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Util/TempFileUtil.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2011-2025 Christian Raue 10 | * @license http://opensource.org/licenses/mit-license.php MIT License 11 | */ 12 | abstract class TempFileUtil { 13 | 14 | private static $tempFiles = []; 15 | 16 | private function __construct() {} 17 | 18 | /** 19 | * @param string $tempFile Path to a file. 20 | */ 21 | public static function addTempFile($tempFile) { 22 | self::$tempFiles[] = $tempFile; 23 | } 24 | 25 | /** 26 | * Removes all previously added files from disk. 27 | */ 28 | public static function removeTempFiles() { 29 | foreach (self::$tempFiles as $tempFile) { 30 | if (is_file($tempFile)) { 31 | @unlink($tempFile); 32 | } 33 | } 34 | 35 | self::$tempFiles = []; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Storage/StorageInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2011-2025 Christian Raue 8 | * @license http://opensource.org/licenses/mit-license.php MIT License 9 | */ 10 | interface StorageInterface { 11 | 12 | /** 13 | * Store the given value under the given key. 14 | * @param string $key 15 | * @param mixed $value 16 | */ 17 | function set($key, $value); 18 | 19 | /** 20 | * Retrieve the data stored under the given key. 21 | * @param string $key 22 | * @param mixed $default 23 | * @return mixed 24 | */ 25 | function get($key, $default = null); 26 | 27 | /** 28 | * Checks if data is stored for the given key. 29 | * @param string $key 30 | * @return bool 31 | */ 32 | function has($key); 33 | 34 | /** 35 | * Delete the stored data of the given key. 36 | * @param string $key 37 | */ 38 | function remove($key); 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Event/PostValidateEvent.php: -------------------------------------------------------------------------------- 1 | 11 | * @author Christian Raue 12 | * @copyright 2011-2025 Christian Raue 13 | * @license http://opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | class PostValidateEvent extends FormFlowEvent { 16 | 17 | /** 18 | * @var mixed 19 | */ 20 | protected $formData; 21 | 22 | /** 23 | * @param FormFlowInterface $flow 24 | * @param mixed $formData 25 | */ 26 | public function __construct(FormFlowInterface $flow, $formData) { 27 | parent::__construct($flow); 28 | $this->formData = $formData; 29 | } 30 | 31 | /** 32 | * @return mixed 33 | */ 34 | public function getFormData() { 35 | return $this->formData; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Form/StepInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2011-2025 Christian Raue 10 | * @license http://opensource.org/licenses/mit-license.php MIT License 11 | */ 12 | interface StepInterface { 13 | 14 | /** 15 | * @return int 16 | */ 17 | function getNumber(); 18 | 19 | /** 20 | * @return string|null 21 | */ 22 | function getLabel(); 23 | 24 | /** 25 | * @return FormTypeInterface|string|null 26 | */ 27 | function getFormType(); 28 | 29 | /** 30 | * @return array 31 | */ 32 | function getFormOptions(); 33 | 34 | /** 35 | * @return bool 36 | */ 37 | function isSkipped(); 38 | 39 | /** 40 | * @param int $estimatedCurrentStepNumber 41 | * @param FormFlowInterface $flow 42 | */ 43 | function evaluateSkipping($estimatedCurrentStepNumber, FormFlowInterface $flow); 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Resources/config/twig.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 12 | Craue\FormFlowBundle\Twig\Extension\FormFlowExtension 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Event/FlowExpiredEvent.php: -------------------------------------------------------------------------------- 1 | 12 | * @copyright 2011-2025 Christian Raue 13 | * @license http://opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | class FlowExpiredEvent extends FormFlowEvent { 16 | 17 | /** 18 | * @var FormInterface 19 | */ 20 | protected $currentStepForm; 21 | 22 | /** 23 | * @param FormFlowInterface $flow 24 | * @param FormInterface $currentStepForm 25 | */ 26 | public function __construct(FormFlowInterface $flow, FormInterface $currentStepForm) { 27 | parent::__construct($flow); 28 | $this->currentStepForm = $currentStepForm; 29 | } 30 | 31 | /** 32 | * @return FormInterface 33 | */ 34 | public function getCurrentStepForm() { 35 | return $this->currentStepForm; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Storage/ExtendedDataManagerInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2011-2025 Christian Raue 10 | * @license http://opensource.org/licenses/mit-license.php MIT License 11 | */ 12 | interface ExtendedDataManagerInterface extends DataManagerInterface { 13 | 14 | /** 15 | * Note: This method may be used for custom flow management. 16 | * @return string[] Distinct names of flows (which may have data for more than one instance). 17 | */ 18 | function listFlows(); 19 | 20 | /** 21 | * Note: This method may be used for custom flow management. 22 | * @param string $name Name of the flow. 23 | * @return string[] Instances of flows with the given name. 24 | */ 25 | function listInstances($name); 26 | 27 | /** 28 | * Drops data of all flows. 29 | * Note: This method may be used for custom flow management. 30 | */ 31 | function dropAll(); 32 | 33 | } 34 | -------------------------------------------------------------------------------- /EventListener/FlowExpiredEventListener.php: -------------------------------------------------------------------------------- 1 | 12 | * @copyright 2011-2025 Christian Raue 13 | * @license http://opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | class FlowExpiredEventListener { 16 | 17 | use EventListenerWithTranslatorTrait; 18 | 19 | public function onFlowExpired(FlowExpiredEvent $event) { 20 | $event->getCurrentStepForm()->addError($this->getFlowExpiredFormError()); 21 | } 22 | 23 | /** 24 | * @return FormError 25 | */ 26 | protected function getFlowExpiredFormError() { 27 | $messageId = 'craueFormFlow.flowExpired'; 28 | $messageParameters = []; 29 | 30 | return new FormError($this->translator->trans($messageId, $messageParameters, 'validators'), $messageId, $messageParameters); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2011-2025 Christian Raue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Event/PostBindRequestEvent.php: -------------------------------------------------------------------------------- 1 | 11 | * @author Christian Raue 12 | * @copyright 2011-2025 Christian Raue 13 | * @license http://opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | class PostBindRequestEvent extends FormFlowEvent { 16 | 17 | /** 18 | * @var mixed 19 | */ 20 | protected $formData; 21 | 22 | /** 23 | * @var int 24 | */ 25 | protected $stepNumber; 26 | 27 | /** 28 | * @param FormFlowInterface $flow 29 | * @param mixed $formData 30 | * @param int $stepNumber 31 | */ 32 | public function __construct(FormFlowInterface $flow, $formData, $stepNumber) { 33 | parent::__construct($flow); 34 | $this->formData = $formData; 35 | $this->stepNumber = $stepNumber; 36 | } 37 | 38 | /** 39 | * @return mixed 40 | */ 41 | public function getFormData() { 42 | return $this->formData; 43 | } 44 | 45 | /** 46 | * @return int 47 | */ 48 | public function getStepNumber() { 49 | return $this->stepNumber; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Event/PostBindSavedDataEvent.php: -------------------------------------------------------------------------------- 1 | 11 | * @author Christian Raue 12 | * @copyright 2011-2025 Christian Raue 13 | * @license http://opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | class PostBindSavedDataEvent extends FormFlowEvent { 16 | 17 | /** 18 | * @var mixed 19 | */ 20 | protected $formData; 21 | 22 | /** 23 | * @var int 24 | */ 25 | protected $stepNumber; 26 | 27 | /** 28 | * @param FormFlowInterface $flow 29 | * @param mixed $formData 30 | * @param int $stepNumber 31 | */ 32 | public function __construct(FormFlowInterface $flow, $formData, $stepNumber) { 33 | parent::__construct($flow); 34 | $this->formData = $formData; 35 | $this->stepNumber = $stepNumber; 36 | } 37 | 38 | /** 39 | * @return mixed 40 | */ 41 | public function getFormData() { 42 | return $this->formData; 43 | } 44 | 45 | /** 46 | * @return int 47 | */ 48 | public function getStepNumber() { 49 | return $this->stepNumber; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /EventListener/PreviousStepInvalidEventListener.php: -------------------------------------------------------------------------------- 1 | 12 | * @copyright 2011-2025 Christian Raue 13 | * @license http://opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | class PreviousStepInvalidEventListener { 16 | 17 | use EventListenerWithTranslatorTrait; 18 | 19 | public function onPreviousStepInvalid(PreviousStepInvalidEvent $event) { 20 | $event->getCurrentStepForm()->addError($this->getPreviousStepInvalidFormError($event->getInvalidStepNumber())); 21 | } 22 | 23 | /** 24 | * @param int $stepNumber 25 | * @return FormError 26 | */ 27 | protected function getPreviousStepInvalidFormError($stepNumber) { 28 | $messageId = 'craueFormFlow.previousStepInvalid'; 29 | $messageParameters = ['%stepNumber%' => $stepNumber]; 30 | 31 | return new FormError($this->translator->trans($messageId, $messageParameters, 'validators'), $messageId, $messageParameters); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Storage/DataManagerInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2011-2025 Christian Raue 10 | * @license http://opensource.org/licenses/mit-license.php MIT License 11 | */ 12 | interface DataManagerInterface { 13 | 14 | /** 15 | * @var string Key for storing data of all flows. 16 | */ 17 | const STORAGE_ROOT = 'craue_form_flow'; 18 | 19 | /** 20 | * @return StorageInterface 21 | */ 22 | function getStorage(); 23 | 24 | /** 25 | * Saves data of the given flow. 26 | * @param FormFlowInterface $flow 27 | * @param array $data 28 | */ 29 | function save(FormFlowInterface $flow, array $data); 30 | 31 | /** 32 | * Checks if data exists for a given flow. 33 | * @param FormFlowInterface $flow 34 | * @return bool 35 | */ 36 | function exists(FormFlowInterface $flow); 37 | 38 | /** 39 | * Loads data of the given flow. 40 | * @param FormFlowInterface $flow 41 | * @return array 42 | */ 43 | function load(FormFlowInterface $flow); 44 | 45 | /** 46 | * Drops data of the given flow. 47 | * @param FormFlowInterface $flow 48 | */ 49 | function drop(FormFlowInterface $flow); 50 | 51 | } 52 | -------------------------------------------------------------------------------- /Event/PreviousStepInvalidEvent.php: -------------------------------------------------------------------------------- 1 | 12 | * @copyright 2011-2025 Christian Raue 13 | * @license http://opensource.org/licenses/mit-license.php MIT License 14 | */ 15 | class PreviousStepInvalidEvent extends FormFlowEvent { 16 | 17 | /** 18 | * @var int 19 | */ 20 | protected $invalidStepNumber; 21 | 22 | /** 23 | * @var FormInterface 24 | */ 25 | protected $currentStepForm; 26 | 27 | /** 28 | * @param FormFlowInterface $flow 29 | * @param FormInterface $currentStepForm 30 | * @param int $invalidStepNumber 31 | */ 32 | public function __construct(FormFlowInterface $flow, FormInterface $currentStepForm, $invalidStepNumber) { 33 | parent::__construct($flow); 34 | $this->currentStepForm = $currentStepForm; 35 | $this->invalidStepNumber = $invalidStepNumber; 36 | } 37 | 38 | /** 39 | * @return FormInterface 40 | */ 41 | public function getCurrentStepForm() { 42 | return $this->currentStepForm; 43 | } 44 | 45 | /** 46 | * @return int 47 | */ 48 | public function getInvalidStepNumber() { 49 | return $this->invalidStepNumber; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Storage/SessionStorage.php: -------------------------------------------------------------------------------- 1 | 13 | * @copyright 2011-2025 Christian Raue 14 | * @license http://opensource.org/licenses/mit-license.php MIT License 15 | */ 16 | class SessionStorage implements StorageInterface { 17 | 18 | use SessionProviderTrait; 19 | 20 | /** 21 | * @param RequestStack|SessionInterface $requestStackOrSession 22 | * @throws InvalidTypeException 23 | */ 24 | public function __construct($requestStackOrSession) { 25 | $this->setRequestStackOrSession($requestStackOrSession); 26 | } 27 | 28 | /** 29 | * {@inheritDoc} 30 | */ 31 | public function set($key, $value) { 32 | $this->getSession()->set($key, $value); 33 | } 34 | 35 | /** 36 | * {@inheritDoc} 37 | */ 38 | public function get($key, $default = null) { 39 | return $this->getSession()->get($key, $default); 40 | } 41 | 42 | /** 43 | * {@inheritDoc} 44 | */ 45 | public function has($key) { 46 | return $this->getSession()->has($key); 47 | } 48 | 49 | /** 50 | * {@inheritDoc} 51 | */ 52 | public function remove($key) { 53 | $this->getSession()->remove($key); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Exception/InvalidTypeException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2011-2025 Christian Raue 8 | * @license http://opensource.org/licenses/mit-license.php MIT License 9 | */ 10 | class InvalidTypeException extends \InvalidArgumentException { 11 | 12 | public function __construct($value, $expectedType) { 13 | $givenType = is_object($value) ? get_class($value) : gettype($value); 14 | 15 | if (is_array($expectedType)) { 16 | $message = sprintf('Expected argument of either type %s, but "%s" given.', $this->conjunctTypes($expectedType), $givenType); 17 | } else { 18 | $message = sprintf('Expected argument of type "%s", but "%s" given.', $expectedType, $givenType); 19 | } 20 | 21 | parent::__construct($message); 22 | } 23 | 24 | protected function conjunctTypes(array $expectedTypes) { 25 | $expectedTypes = array_values($expectedTypes); 26 | 27 | $len = count($expectedTypes); 28 | 29 | if ($len === 2) { 30 | return sprintf('"%s" or "%s"', $expectedTypes[0], $expectedTypes[1]); 31 | } 32 | 33 | $text = ''; 34 | 35 | for ($i = 0; $i < $len; ++$i) { 36 | if ($i !== 0) { 37 | $text .= ', '; 38 | } 39 | 40 | if ($i === $len - 1) { 41 | $text .= 'or '; 42 | } 43 | 44 | $text .= sprintf('"%s"', $expectedTypes[$i]); 45 | } 46 | 47 | return $text; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /Storage/SessionProviderTrait.php: -------------------------------------------------------------------------------- 1 | 13 | * @copyright 2011-2025 Christian Raue 14 | * @license http://opensource.org/licenses/mit-license.php MIT License 15 | */ 16 | trait SessionProviderTrait { 17 | 18 | /** 19 | * @var RequestStack|null 20 | */ 21 | private $requestStack; 22 | 23 | /** 24 | * @var SessionInterface|null 25 | */ 26 | private $session; 27 | 28 | /** 29 | * @param RequestStack|SessionInterface $requestStackOrSession 30 | * @throws InvalidTypeException 31 | */ 32 | private function setRequestStackOrSession($requestStackOrSession) : void { 33 | // TODO accept only RequestStack as soon as Symfony >= 6.0 is required 34 | 35 | if ($requestStackOrSession instanceof SessionInterface) { 36 | $this->session = $requestStackOrSession; 37 | 38 | return; 39 | } 40 | 41 | if ($requestStackOrSession instanceof RequestStack) { 42 | $this->requestStack = $requestStackOrSession; 43 | 44 | return; 45 | } 46 | 47 | throw new InvalidTypeException($requestStackOrSession, [RequestStack::class, SessionInterface::class]); 48 | } 49 | 50 | private function getSession() : SessionInterface { 51 | if ($this->requestStack !== null) { 52 | return $this->requestStack->getSession(); 53 | } 54 | 55 | return $this->session; 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /Form/Extension/FormFlowHiddenFieldExtension.php: -------------------------------------------------------------------------------- 1 | 13 | * @author Christian Raue 14 | * @copyright 2011-2025 Christian Raue 15 | * @license http://opensource.org/licenses/mit-license.php MIT License 16 | */ 17 | class FormFlowHiddenFieldExtension extends AbstractTypeExtension { 18 | 19 | /** 20 | * @return string 21 | */ 22 | public function getExtendedType() { 23 | return HiddenType::class; 24 | } 25 | 26 | public static function getExtendedTypes() : iterable { 27 | return [HiddenType::class]; 28 | } 29 | 30 | public function configureOptions(OptionsResolver $resolver) : void { 31 | $resolver->setDefined([ 32 | 'flow_instance_key', 33 | 'flow_step_key', 34 | ]); 35 | } 36 | 37 | public function finishView(FormView $view, FormInterface $form, array $options) : void { 38 | if (array_key_exists('flow_instance_key', $options) && $view->vars['name'] === $options['flow_instance_key']) { 39 | $view->vars['value'] = $options['data']; 40 | $view->vars['full_name'] = $options['flow_instance_key']; 41 | } 42 | 43 | if (array_key_exists('flow_step_key', $options) && $view->vars['name'] === $options['flow_step_key']) { 44 | $view->vars['value'] = $options['data']; 45 | $view->vars['full_name'] = $options['flow_step_key']; 46 | } 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /DependencyInjection/CraueFormFlowExtension.php: -------------------------------------------------------------------------------- 1 | 16 | * @copyright 2011-2025 Christian Raue 17 | * @license http://opensource.org/licenses/mit-license.php MIT License 18 | */ 19 | class CraueFormFlowExtension extends Extension implements CompilerPassInterface { 20 | 21 | const FORM_FLOW_TAG = 'craue.form.flow'; 22 | 23 | /** 24 | * @return void 25 | */ 26 | public function load(array $config, ContainerBuilder $container) { 27 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 28 | $loader->load('form_flow.xml'); 29 | $loader->load('twig.xml'); 30 | $loader->load('util.xml'); 31 | 32 | $container->registerForAutoconfiguration(FormFlowInterface::class)->addTag(self::FORM_FLOW_TAG); 33 | } 34 | 35 | /** 36 | * @return void 37 | */ 38 | public function process(ContainerBuilder $container) { 39 | $baseFlowDefinitionMethodCalls = $container->getDefinition('craue.form.flow')->getMethodCalls(); 40 | 41 | foreach (array_keys($container->findTaggedServiceIds(self::FORM_FLOW_TAG)) as $id) { 42 | $container->findDefinition($id)->setMethodCalls($baseFlowDefinitionMethodCalls); 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Util/FormFlowUtil.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2011-2025 Christian Raue 10 | * @license http://opensource.org/licenses/mit-license.php MIT License 11 | */ 12 | class FormFlowUtil { 13 | 14 | /** 15 | * Adds route parameters for dynamic step navigation. 16 | * @param array $parameters Current route parameters. 17 | * @param FormFlow $flow The flow involved. 18 | * @param int|null $stepNumber Number of the step the link will be generated for. If null, the $flow's current step number will be used. 19 | * @return array Route parameters plus instance and step parameter. 20 | */ 21 | public function addRouteParameters(array $parameters, FormFlow $flow, $stepNumber = null) { 22 | if ($stepNumber === null) { 23 | $stepNumber = $flow->getCurrentStepNumber(); 24 | } 25 | 26 | $parameters[$flow->getDynamicStepNavigationInstanceParameter()] = $flow->getInstanceId(); 27 | $parameters[$flow->getDynamicStepNavigationStepParameter()] = $stepNumber; 28 | 29 | return $parameters; 30 | } 31 | 32 | /** 33 | * Removes route parameters for dynamic step navigation. 34 | * @param array $parameters Current route parameters. 35 | * @param FormFlow $flow The flow involved. 36 | * @return array Route parameters without instance and step parameter. 37 | */ 38 | public function removeRouteParameters(array $parameters, FormFlow $flow) { 39 | unset($parameters[$flow->getDynamicStepNavigationInstanceParameter()]); 40 | unset($parameters[$flow->getDynamicStepNavigationStepParameter()]); 41 | 42 | return $parameters; 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Form/Extension/FormFlowFormExtension.php: -------------------------------------------------------------------------------- 1 | 13 | * @author Christian Raue 14 | * @copyright 2011-2025 Christian Raue 15 | * @license http://opensource.org/licenses/mit-license.php MIT License 16 | */ 17 | class FormFlowFormExtension extends AbstractTypeExtension { 18 | 19 | /** 20 | * @return string 21 | */ 22 | public function getExtendedType() { 23 | return FormType::class; 24 | } 25 | 26 | public static function getExtendedTypes() : iterable { 27 | return [FormType::class]; 28 | } 29 | 30 | public function configureOptions(OptionsResolver $resolver) : void { 31 | $resolver->setDefined([ 32 | 'flow_instance', 33 | 'flow_instance_key', 34 | 'flow_step', 35 | 'flow_step_key', 36 | ]); 37 | } 38 | 39 | public function buildForm(FormBuilderInterface $builder, array $options) : void { 40 | if (array_key_exists('flow_instance', $options) && array_key_exists('flow_instance_key', $options)) { 41 | $builder->add($options['flow_instance_key'], HiddenType::class, [ 42 | 'data' => $options['flow_instance'], 43 | 'mapped' => false, 44 | 'flow_instance_key' => $options['flow_instance_key'], 45 | ]); 46 | } 47 | 48 | if (array_key_exists('flow_step', $options) && array_key_exists('flow_step_key', $options)) { 49 | $builder->add($options['flow_step_key'], HiddenType::class, [ 50 | 'data' => $options['flow_step'], 51 | 'mapped' => false, 52 | 'flow_step_key' => $options['flow_step_key'], 53 | ]); 54 | } 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Util/StringUtil.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2011-2025 Christian Raue 10 | * @license http://opensource.org/licenses/mit-license.php MIT License 11 | */ 12 | abstract class StringUtil { 13 | 14 | private function __construct() {} 15 | 16 | /** 17 | * @param int $length 18 | * @return string 19 | */ 20 | public static function generateRandomString($length) { 21 | if (!is_int($length)) { 22 | throw new InvalidTypeException($length, 'int'); 23 | } 24 | 25 | if ($length < 0) { 26 | throw new \InvalidArgumentException(sprintf('Length must be >= 0, "%s" given.', $length)); 27 | } 28 | 29 | return substr(rtrim(strtr(base64_encode(random_bytes($length)), '+/', '-_'), '='), 0, $length); 30 | } 31 | 32 | /** 33 | * @param string $input 34 | * @param int $length 35 | * @return bool 36 | */ 37 | public static function isRandomString($input, $length) { 38 | if (!is_string($input)) { 39 | throw new InvalidTypeException($input, 'string'); 40 | } 41 | 42 | if (!is_int($length)) { 43 | throw new InvalidTypeException($length, 'int'); 44 | } 45 | 46 | if ($length < 0) { 47 | throw new \InvalidArgumentException(sprintf('Length must be >= 0, "%s" given.', $length)); 48 | } 49 | 50 | return preg_match(sprintf('/^[a-zA-Z0-9-_]{%u}$/', $length), $input) === 1; 51 | } 52 | 53 | /** 54 | * @param string $fqcn FQCN 55 | * @return string|null flow name or null if not a FQCN 56 | */ 57 | public static function fqcnToFlowName($fqcn) { 58 | if (!is_string($fqcn)) { 59 | throw new InvalidTypeException($fqcn, 'string'); 60 | } 61 | 62 | if (preg_match('/([^\\\\]+?)(flow)?$/i', $fqcn, $matches)) { 63 | return lcfirst(preg_replace('/([A-Z]+)([A-Z][a-z])/', '\\1\\2', $matches[1])); 64 | } 65 | 66 | return null; 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Form/StepLabel.php: -------------------------------------------------------------------------------- 1 | 10 | * @copyright 2011-2025 Christian Raue 11 | * @license http://opensource.org/licenses/mit-license.php MIT License 12 | */ 13 | class StepLabel { 14 | 15 | /** 16 | * @var bool If $value is callable. 17 | */ 18 | private $callable; 19 | 20 | /** 21 | * @var string|callable|null 22 | */ 23 | private $value = null; 24 | 25 | /** 26 | * @param string|null $value 27 | */ 28 | public static function createStringLabel($value) { 29 | return new static($value); 30 | } 31 | 32 | /** 33 | * @param callable $value 34 | */ 35 | public static function createCallableLabel($value) { 36 | return new static($value, true); 37 | } 38 | 39 | /** 40 | * @return string|null 41 | */ 42 | public function getText() { 43 | if ($this->callable) { 44 | $returnValue = call_user_func($this->value); 45 | 46 | if ($returnValue === null || is_string($returnValue)) { 47 | return $returnValue; 48 | } 49 | 50 | throw new StepLabelCallableInvalidReturnValueException(); 51 | } 52 | 53 | return $this->value; 54 | } 55 | 56 | /** 57 | * @param string|callable|null $value 58 | * @param bool $callable 59 | */ 60 | private final function __construct($value, $callable = false) { 61 | $this->setValue($value, $callable); 62 | } 63 | 64 | /** 65 | * @param string|callable|null $value 66 | * @param bool $callable 67 | */ 68 | private function setValue($value, $callable = false) { 69 | if ($callable) { 70 | if (!is_callable($value)) { 71 | throw new InvalidTypeException($value, ['callable']); 72 | } 73 | } else { 74 | if ($value !== null && !is_string($value)) { 75 | throw new InvalidTypeException($value, ['null', 'string']); 76 | } 77 | } 78 | 79 | $this->callable = $callable; 80 | $this->value = $value; 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Storage/UserSessionStorageKeyGenerator.php: -------------------------------------------------------------------------------- 1 | 16 | * @copyright 2011-2025 Christian Raue 17 | * @license http://opensource.org/licenses/mit-license.php MIT License 18 | */ 19 | class UserSessionStorageKeyGenerator implements StorageKeyGeneratorInterface { 20 | 21 | use SessionProviderTrait; 22 | 23 | /** 24 | * @var TokenStorageInterface 25 | */ 26 | private $tokenStorage; 27 | 28 | /** 29 | * @param TokenStorageInterface $tokenStorage 30 | * @param RequestStack|SessionInterface $requestStackOrSession 31 | * @throws InvalidTypeException 32 | */ 33 | public function __construct(TokenStorageInterface $tokenStorage, $requestStackOrSession) { 34 | $this->tokenStorage = $tokenStorage; 35 | $this->setRequestStackOrSession($requestStackOrSession); 36 | } 37 | 38 | /** 39 | * {@inheritDoc} 40 | */ 41 | public function generate($key) { 42 | if (!is_string($key)) { 43 | throw new InvalidTypeException($key, 'string'); 44 | } 45 | 46 | if ($key === '') { 47 | throw new \InvalidArgumentException('Argument must not be empty.'); 48 | } 49 | 50 | $token = $this->tokenStorage->getToken(); 51 | 52 | // TODO remove checks for AnonymousToken as soon as Symfony >= 6.0 is required 53 | if ($token instanceof TokenInterface && (!\class_exists(AnonymousToken::class) || !$token instanceof AnonymousToken)) { 54 | $userIdentifier = $token->getUserIdentifier(); 55 | if ($userIdentifier !== '') { 56 | return sprintf('user_%s_%s', $userIdentifier, $key); 57 | } 58 | } 59 | 60 | // fallback to session id 61 | $session = $this->getSession(); 62 | 63 | if (!$session->isStarted()) { 64 | $session->start(); 65 | } 66 | 67 | return sprintf('session_%s_%s', $session->getId(), $key); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "craue/formflow-bundle", 3 | "description": "Multi-step forms for your Symfony project.", 4 | "license": "MIT", 5 | "type": "symfony-bundle", 6 | "keywords": [ 7 | "form", 8 | "wizard", 9 | "step", 10 | "symfony" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Christian Raue", 15 | "email": "christian.raue@gmail.com" 16 | }, 17 | { 18 | "name": "Symfony Community", 19 | "homepage": "https://github.com/craue/CraueFormFlowBundle/contributors" 20 | } 21 | ], 22 | "homepage": "https://github.com/craue/CraueFormFlowBundle", 23 | "require": { 24 | "php": "^7.3 || ^8", 25 | "symfony/config": "^5.4 || ^6.4 || ^7.2", 26 | "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.2", 27 | "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.2", 28 | "symfony/form": "^5.4 || ^6.4 || ^7.2", 29 | "symfony/http-foundation": "^5.4 || ^6.4 || ^7.2", 30 | "symfony/http-kernel": "^5.4 || ^6.4 || ^7.2", 31 | "symfony/options-resolver": "^5.4 || ^6.4 || ^7.2", 32 | "symfony/security-core": "^5.4 || ^6.4 || ^7.2", 33 | "symfony/translation": "^5.4 || ^6.4 || ^7.2", 34 | "symfony/validator": "^5.4 || ^6.4 || ^7.2", 35 | "symfony/yaml": "^5.4 || ^6.4 || ^7.2" 36 | }, 37 | "require-dev": { 38 | "craue/translations-tests": "^1.1", 39 | "doctrine/collections": "^1.8 || ^2.1", 40 | "doctrine/common": "^2.9 || ^3.0", 41 | "doctrine/dbal": "^2.10 || ^3.0", 42 | "doctrine/doctrine-bundle": "^1.10 || ^2.0", 43 | "phpstan/extension-installer": "^1.1", 44 | "phpstan/phpstan": "^1.10", 45 | "phpstan/phpstan-deprecation-rules": "^1.0", 46 | "phpstan/phpstan-strict-rules": "^1.1", 47 | "phpstan/phpstan-symfony": "^1.1", 48 | "phpunit/phpunit": "^9.5", 49 | "symfony/browser-kit": "^5.4 || ^6.4 || ^7.2", 50 | "symfony/css-selector": "^5.4 || ^6.4 || ^7.2", 51 | "symfony/framework-bundle": "^5.4 || ^6.4 || ^7.2", 52 | "symfony/mime": "^5.4 || ^6.4 || ^7.2", 53 | "symfony/phpunit-bridge": "^7.3", 54 | "symfony/security-bundle": "^5.4 || ^6.4 || ^7.2", 55 | "symfony/twig-bundle": "^5.4 || ^6.4 || ^7.2" 56 | }, 57 | "conflict": { 58 | "doctrine/dbal": "<2.10" 59 | }, 60 | "minimum-stability": "stable", 61 | "autoload": { 62 | "psr-4": { 63 | "Craue\\FormFlowBundle\\": "" 64 | }, 65 | "exclude-from-classmap": [ 66 | "/Tests/" 67 | ] 68 | }, 69 | "config": { 70 | "allow-plugins": { 71 | "phpstan/extension-installer": true, 72 | "symfony/flex": true 73 | }, 74 | "sort-packages": true 75 | }, 76 | "extra": { 77 | "branch-alias": { 78 | "dev-master": "3.8.x-dev" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Resources/views/FormFlow/buttons.html.twig: -------------------------------------------------------------------------------- 1 | {% set renderBackButton = flow.getFirstStepNumber() < flow.getLastStepNumber() and flow.getCurrentStepNumber() in (flow.getFirstStepNumber() + 1) .. flow.getLastStepNumber() %} 2 | {% set renderResetButton = craue_formflow_button_render_reset is defined ? craue_formflow_button_render_reset : true %} 3 | {% set buttonCount = 1 + (renderBackButton ? 1 : 0) + (renderResetButton ? 1 : 0) %} 4 | 5 |
6 | {# 7 | Default button (the one trigged by pressing the enter/return key) must be defined first. 8 | Thus, all buttons are defined in reverse order and will be reversed again via CSS. 9 | See http://stackoverflow.com/questions/1963245/multiple-submit-buttons-specifying-default-button 10 | #} 11 | {%- set isLastStep = flow.getCurrentStepNumber() == flow.getLastStepNumber() -%} 12 | {%- set craue_formflow_button_class_last = craue_formflow_button_class_last | default('craue_formflow_button_last') -%} 13 | {%- set craue_formflow_button_class_last = isLastStep 14 | ? craue_formflow_button_class_finish | default(craue_formflow_button_class_last) 15 | : craue_formflow_button_class_next | default(craue_formflow_button_class_last) 16 | -%} 17 | {%- set craue_buttons = [ 18 | { 19 | label: craue_formflow_button_label_last | default(isLastStep ? craue_formflow_button_label_finish | default('button.finish') : craue_formflow_button_label_next | default('button.next')), 20 | render: true, 21 | attr: { 22 | class: craue_formflow_button_class_last, 23 | }, 24 | }, 25 | { 26 | label: craue_formflow_button_label_back | default('button.back'), 27 | render: renderBackButton, 28 | attr: { 29 | class: craue_formflow_button_class_back | default(''), 30 | name: flow.getFormTransitionKey(), 31 | value: 'back', 32 | formnovalidate: 'formnovalidate', 33 | }, 34 | }, 35 | { 36 | label: craue_formflow_button_label_reset | default('button.reset'), 37 | render: renderResetButton, 38 | attr: { 39 | class: craue_formflow_button_class_reset | default('craue_formflow_button_first'), 40 | name: flow.getFormTransitionKey(), 41 | value: 'reset', 42 | formnovalidate: 'formnovalidate', 43 | }, 44 | }, 45 | ] -%} 46 | 47 | {% for button in craue_buttons %} 48 | {% if button.render %} 49 | 52 | {% endif %} 53 | {% endfor %} 54 |
55 | -------------------------------------------------------------------------------- /Storage/SerializableFile.php: -------------------------------------------------------------------------------- 1 | UploadedFile currently. 11 | * 12 | * @author Christian Raue 13 | * @copyright 2011-2025 Christian Raue 14 | * @license http://opensource.org/licenses/mit-license.php MIT License 15 | */ 16 | class SerializableFile { 17 | 18 | /** 19 | * @var string Base64-encoded content of the original file. 20 | */ 21 | protected $content; 22 | 23 | /** 24 | * @var string FQCN of the object encapsulating the original file. Not used yet, but meant for possible future support of further types. 25 | */ 26 | protected $type; 27 | 28 | protected $clientOriginalName; 29 | protected $clientMimeType; 30 | 31 | /** 32 | * @param mixed $file An object meant to be serialized. 33 | * @throws InvalidTypeException If the type of $file is unsupported. 34 | */ 35 | public function __construct($file) { 36 | if (!self::isSupported($file)) { 37 | throw new InvalidTypeException($file, UploadedFile::class); 38 | } 39 | 40 | $this->content = base64_encode(file_get_contents($file->getPathname())); 41 | $this->type = UploadedFile::class; 42 | 43 | $this->clientOriginalName = $file->getClientOriginalName(); 44 | $this->clientMimeType = $file->getClientMimeType(); 45 | } 46 | 47 | /** 48 | * @param string|null $tempDir Directory for storing temporary files. If null, the system's default will be used. 49 | * @return mixed The unserialized object. 50 | */ 51 | public function getAsFile($tempDir = null) { 52 | if ($tempDir === null) { 53 | $tempDir = sys_get_temp_dir(); 54 | } 55 | 56 | // create a temporary file with its original content 57 | $tempFile = tempnam($tempDir, 'craue_form_flow_serialized_file'); 58 | file_put_contents($tempFile, base64_decode($this->content, true)); 59 | 60 | TempFileUtil::addTempFile($tempFile); 61 | 62 | return new UploadedFile($tempFile, $this->clientOriginalName, $this->clientMimeType, null, true); 63 | } 64 | 65 | /** 66 | * @param mixed $file 67 | * @return bool 68 | */ 69 | public static function isSupported($file) { 70 | return $file instanceof UploadedFile; 71 | } 72 | 73 | public function __serialize() : array { 74 | return [ 75 | 'content' => $this->content, 76 | 'type' => $this->type, 77 | 'clientOriginalName' => $this->clientOriginalName, 78 | 'clientMimeType' => $this->clientMimeType, 79 | ]; 80 | } 81 | 82 | public function __unserialize(array $data) : void { 83 | // TODO remove for 4.0 84 | // handle representation of object which got serialized before `__serialize` method was added 85 | if (count(array_diff(array_keys($data), ["\x00*\x00content", "\x00*\x00type", "\x00*\x00clientOriginalName", "\x00*\x00clientMimeType"])) === 0) { 86 | $this->content = $data["\x00*\x00content"]; 87 | $this->type = $data["\x00*\x00type"]; 88 | $this->clientOriginalName = $data["\x00*\x00clientOriginalName"]; 89 | $this->clientMimeType = $data["\x00*\x00clientMimeType"]; 90 | return; 91 | } 92 | 93 | $this->content = $data['content']; 94 | $this->type = $data['type']; 95 | $this->clientOriginalName = $data['clientOriginalName']; 96 | $this->clientMimeType = $data['clientMimeType']; 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /UPGRADE-2.1.md: -------------------------------------------------------------------------------- 1 | # Upgrade from 2.0.x to 2.1 2 | 3 | This version comes with two changes which allow you to remove some superfluous code from your flows, form types, and templates: 4 | 5 | 1. The step number is automatically added as option `flow_step` to forms. So you can remove all the code to manually 6 | set this option (which was called `flowStep` in the docs before) and just use the new option instead. 7 | 2. The hidden step field is automatically added to the form. So you don't need to include the bundle's template for it. 8 | 9 | ## Flow 10 | 11 | - In method `loadStepsConfig`, the `type` option doesn't need to be set on empty steps anymore just to avoid an exception. 12 | 13 | before: 14 | ```php 15 | protected function loadStepsConfig() { 16 | return array( 17 | // ... 18 | array( 19 | 'label' => 'confirmation', 20 | 'type' => $this->formType, // needed to avoid InvalidOptionsException regarding option 'flowStep' 21 | ), 22 | ); 23 | } 24 | ``` 25 | 26 | after: 27 | ```php 28 | protected function loadStepsConfig() { 29 | return array( 30 | // ... 31 | array( 32 | 'label' => 'confirmation', 33 | ), 34 | ); 35 | } 36 | ``` 37 | 38 | - In method `getFormOptions`, the `flowStep` option doesn't need to be set manually anymore. 39 | 40 | before: 41 | ```php 42 | public function getFormOptions($step, array $options = array()) { 43 | $options = parent::getFormOptions($step, $options); 44 | 45 | $options['flowStep'] = $step; 46 | 47 | // ... 48 | 49 | return $options; 50 | } 51 | ``` 52 | 53 | after: 54 | ```php 55 | public function getFormOptions($step, array $options = array()) { 56 | $options = parent::getFormOptions($step, $options); 57 | 58 | // ... 59 | 60 | return $options; 61 | } 62 | ``` 63 | 64 | If method `getFormOptions` was only overridden to set this option, it can be removed altogether. 65 | 66 | ## Form type 67 | 68 | - In method `setDefaultOptions`, you don't have to set the `flowStep` option anymore. 69 | 70 | before: 71 | ```php 72 | public function setDefaultOptions(OptionsResolverInterface $resolver) { 73 | $resolver->setDefaults(array( 74 | 'flowStep' => null, 75 | // ... 76 | )); 77 | } 78 | ``` 79 | 80 | after: 81 | ```php 82 | public function setDefaultOptions(OptionsResolverInterface $resolver) { 83 | $resolver->setDefaults(array( 84 | // ... 85 | )); 86 | } 87 | ``` 88 | 89 | If method `setDefaultOptions` was only overridden to set this option, it can be removed altogether. 90 | 91 | - In method `buildForm`, you can use the automatically set option `flow_step` now. 92 | 93 | before: 94 | ```php 95 | public function buildForm(FormBuilderInterface $builder, array $options) { 96 | switch ($options['flowStep']) { 97 | // ... 98 | } 99 | } 100 | ``` 101 | 102 | after: 103 | ```php 104 | public function buildForm(FormBuilderInterface $builder, array $options) { 105 | switch ($options['flow_step']) { 106 | // ... 107 | } 108 | } 109 | ``` 110 | 111 | ## Template 112 | 113 | - Including the template `CraueFormFlowBundle:FormFlow:stepField.html.twig` is no longer needed. 114 | 115 | before: 116 | ```twig 117 |
118 | {% include 'CraueFormFlowBundle:FormFlow:stepField.html.twig' %} 119 | {{ form_errors(form) }} 120 | {{ form_rest(form) }} 121 | {% include 'CraueFormFlowBundle:FormFlow:buttons.html.twig' %} 122 |
123 | ``` 124 | 125 | after: 126 | ```twig 127 |
128 | {{ form_errors(form) }} 129 | {{ form_rest(form) }} 130 | {% include 'CraueFormFlowBundle:FormFlow:buttons.html.twig' %} 131 |
132 | ``` 133 | -------------------------------------------------------------------------------- /Resources/config/form_flow.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 13 | Craue\FormFlowBundle\Form\FormFlow 14 | Craue\FormFlowBundle\Storage\SessionStorage 15 | Craue\FormFlowBundle\EventListener\PreviousStepInvalidEventListener 16 | Craue\FormFlowBundle\Form\FormFlowEvents::PREVIOUS_STEP_INVALID 17 | Craue\FormFlowBundle\EventListener\FlowExpiredEventListener 18 | Craue\FormFlowBundle\Form\FormFlowEvents::FLOW_EXPIRED 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /Storage/DataManager.php: -------------------------------------------------------------------------------- 1 | 12 | * DataManagerInterface::STORAGE_ROOT => [ 13 | * name of the flow => [ 14 | * instance id of the flow => [ 15 | * 'data' => [] // the actual step data 16 | * ] 17 | * ] 18 | * ] 19 | * 20 | * 21 | * @author Christian Raue 22 | * @copyright 2011-2025 Christian Raue 23 | * @license http://opensource.org/licenses/mit-license.php MIT License 24 | */ 25 | class DataManager implements ExtendedDataManagerInterface { 26 | 27 | /** 28 | * @var string Key for the actual step data. 29 | */ 30 | const DATA_KEY = 'data'; 31 | 32 | /** 33 | * @var StorageInterface 34 | */ 35 | private $storage; 36 | 37 | /** 38 | * @param StorageInterface $storage 39 | */ 40 | public function __construct(StorageInterface $storage) { 41 | $this->storage = $storage; 42 | } 43 | 44 | /** 45 | * {@inheritDoc} 46 | */ 47 | public function getStorage() { 48 | return $this->storage; 49 | } 50 | 51 | /** 52 | * {@inheritDoc} 53 | */ 54 | public function save(FormFlowInterface $flow, array $data) { 55 | // handle file uploads 56 | if ($flow->isHandleFileUploads()) { 57 | array_walk_recursive($data, function(&$value, $key) { 58 | if (SerializableFile::isSupported($value)) { 59 | $value = new SerializableFile($value); 60 | } 61 | }); 62 | } 63 | 64 | // drop old data 65 | $this->drop($flow); 66 | 67 | // save new data 68 | $savedFlows = $this->storage->get(DataManagerInterface::STORAGE_ROOT, []); 69 | 70 | $savedFlows = array_merge_recursive($savedFlows, [ 71 | $flow->getName() => [ 72 | $flow->getInstanceId() => [ 73 | self::DATA_KEY => $data, 74 | ], 75 | ], 76 | ]); 77 | 78 | $this->storage->set(DataManagerInterface::STORAGE_ROOT, $savedFlows); 79 | } 80 | 81 | /** 82 | * {@inheritDoc} 83 | */ 84 | public function load(FormFlowInterface $flow) { 85 | $data = []; 86 | 87 | // try to find data for the given flow 88 | $savedFlows = $this->storage->get(DataManagerInterface::STORAGE_ROOT, []); 89 | if (isset($savedFlows[$flow->getName()][$flow->getInstanceId()][self::DATA_KEY])) { 90 | $data = $savedFlows[$flow->getName()][$flow->getInstanceId()][self::DATA_KEY]; 91 | } 92 | 93 | // handle file uploads 94 | if ($flow->isHandleFileUploads()) { 95 | $tempDir = $flow->getHandleFileUploadsTempDir(); 96 | array_walk_recursive($data, function(&$value, $key) use ($tempDir) { 97 | if ($value instanceof SerializableFile) { 98 | $value = $value->getAsFile($tempDir); 99 | } 100 | }); 101 | } 102 | 103 | return $data; 104 | } 105 | 106 | /** 107 | * {@inheritDoc} 108 | */ 109 | public function exists(FormFlowInterface $flow) { 110 | $savedFlows = $this->storage->get(DataManagerInterface::STORAGE_ROOT, []); 111 | return isset($savedFlows[$flow->getName()][$flow->getInstanceId()][self::DATA_KEY]); 112 | } 113 | 114 | /** 115 | * {@inheritDoc} 116 | */ 117 | public function drop(FormFlowInterface $flow) { 118 | $savedFlows = $this->storage->get(DataManagerInterface::STORAGE_ROOT, []); 119 | 120 | // remove data for only this flow instance 121 | unset($savedFlows[$flow->getName()][$flow->getInstanceId()]); 122 | 123 | $this->storage->set(DataManagerInterface::STORAGE_ROOT, $savedFlows); 124 | } 125 | 126 | /** 127 | * {@inheritDoc} 128 | */ 129 | public function listFlows() { 130 | return array_keys($this->storage->get(DataManagerInterface::STORAGE_ROOT, [])); 131 | } 132 | 133 | /** 134 | * {@inheritDoc} 135 | */ 136 | public function listInstances($name) { 137 | $savedFlows = $this->storage->get(DataManagerInterface::STORAGE_ROOT, []); 138 | 139 | if (array_key_exists($name, $savedFlows)) { 140 | return array_keys($savedFlows[$name]); 141 | } 142 | 143 | return []; 144 | } 145 | 146 | /** 147 | * {@inheritDoc} 148 | */ 149 | public function dropAll() { 150 | $this->storage->remove(DataManagerInterface::STORAGE_ROOT); 151 | } 152 | 153 | } 154 | -------------------------------------------------------------------------------- /Twig/Extension/FormFlowExtension.php: -------------------------------------------------------------------------------- 1 | 15 | * @copyright 2011-2025 Christian Raue 16 | * @license http://opensource.org/licenses/mit-license.php MIT License 17 | */ 18 | class FormFlowExtension extends AbstractExtension { 19 | 20 | /** 21 | * @var FormFlowUtil 22 | */ 23 | protected $formFlowUtil; 24 | 25 | public function setFormFlowUtil(FormFlowUtil $formFlowUtil) { 26 | $this->formFlowUtil = $formFlowUtil; 27 | } 28 | 29 | /** 30 | * {@inheritDoc} 31 | */ 32 | public function getName() { 33 | return 'craue_formflow'; 34 | } 35 | 36 | /** 37 | * {@inheritDoc} 38 | */ 39 | public function getFilters() : array { 40 | return [ 41 | new TwigFilter('craue_addDynamicStepNavigationParameters', [$this, 'addDynamicStepNavigationParameters']), 42 | new TwigFilter('craue_removeDynamicStepNavigationParameters', [$this, 'removeDynamicStepNavigationParameters']), 43 | // methods for BC with third-party templates (e.g. MopaBootstrapBundle) 44 | new TwigFilter('craue_addDynamicStepNavigationParameter', [$this, 'addDynamicStepNavigationParameter']), 45 | new TwigFilter('craue_removeDynamicStepNavigationParameter', [$this, 'removeDynamicStepNavigationParameter']), 46 | ]; 47 | } 48 | 49 | /** 50 | * {@inheritDoc} 51 | */ 52 | public function getFunctions() : array { 53 | return [ 54 | new TwigFunction('craue_isStepLinkable', [$this, 'isStepLinkable']), 55 | ]; 56 | } 57 | 58 | /** 59 | * Adds route parameters for dynamic step navigation. 60 | * @param array $parameters Current route parameters. 61 | * @param FormFlow $flow The flow involved. 62 | * @param int $stepNumber Number of the step the link will be generated for. 63 | * @return array Route parameters plus instance and step parameter. 64 | */ 65 | public function addDynamicStepNavigationParameters(array $parameters, FormFlow $flow, $stepNumber) { 66 | return $this->formFlowUtil->addRouteParameters($parameters, $flow, $stepNumber); 67 | } 68 | 69 | /** 70 | * Removes route parameters for dynamic step navigation. 71 | * @param array $parameters Current route parameters. 72 | * @param FormFlow $flow The flow involved. 73 | * @return array Route parameters without instance and step parameter. 74 | */ 75 | public function removeDynamicStepNavigationParameters(array $parameters, FormFlow $flow) { 76 | return $this->formFlowUtil->removeRouteParameters($parameters, $flow); 77 | } 78 | 79 | /** 80 | * @param FormFlow $flow The flow involved. 81 | * @param int $stepNumber Number of the step the link will be generated for. 82 | * @return bool If the step can be linked to. 83 | */ 84 | public function isStepLinkable(FormFlow $flow, $stepNumber) { 85 | if (!$flow->isAllowDynamicStepNavigation() 86 | || $flow->getCurrentStepNumber() === $stepNumber 87 | || $flow->isStepSkipped($stepNumber)) { 88 | return false; 89 | } 90 | 91 | $lastStepConsecutivelyDone = 0; 92 | for ($i = $flow->getFirstStepNumber(), $lastStepNumber = $flow->getLastStepNumber(); $i < $lastStepNumber; ++$i) { 93 | if ($flow->isStepDone($i)) { 94 | $lastStepConsecutivelyDone = $i; 95 | } else { 96 | break; 97 | } 98 | } 99 | 100 | $lastStepLinkable = $lastStepConsecutivelyDone + 1; 101 | 102 | if ($stepNumber <= $lastStepLinkable) { 103 | return true; 104 | } 105 | 106 | return false; 107 | } 108 | 109 | // methods for BC with third-party templates (e.g. MopaBootstrapBundle) 110 | 111 | public function addDynamicStepNavigationParameter(array $parameters, FormFlow $flow, $stepNumber) { 112 | @trigger_error('Twig filter craue_addDynamicStepNavigationParameter is deprecated since CraueFormFlowBundle 3.0. Use filter craue_addDynamicStepNavigationParameters instead.', E_USER_DEPRECATED); 113 | return $this->addDynamicStepNavigationParameters($parameters, $flow, $stepNumber); 114 | } 115 | 116 | public function removeDynamicStepNavigationParameter(array $parameters, FormFlow $flow) { 117 | @trigger_error('Twig filter craue_removeDynamicStepNavigationParameter is deprecated since CraueFormFlowBundle 3.0. Use filter craue_removeDynamicStepNavigationParameters instead.', E_USER_DEPRECATED); 118 | return $this->removeDynamicStepNavigationParameters($parameters, $flow); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /Storage/DoctrineStorage.php: -------------------------------------------------------------------------------- 1 | 16 | * @copyright 2011-2025 Christian Raue 17 | * @license http://opensource.org/licenses/mit-license.php MIT License 18 | */ 19 | class DoctrineStorage implements StorageInterface { 20 | 21 | const TABLE = 'craue_form_flow_storage'; 22 | const KEY_COLUMN = 'key'; 23 | const VALUE_COLUMN = 'value'; 24 | 25 | /** 26 | * @var Connection 27 | */ 28 | private $conn; 29 | 30 | /** 31 | * @var StorageKeyGeneratorInterface 32 | */ 33 | private $storageKeyGenerator; 34 | 35 | /** 36 | * @var AbstractSchemaManager 37 | */ 38 | private $schemaManager; 39 | 40 | /** 41 | * @var string 42 | */ 43 | private $keyColumn; 44 | 45 | /** 46 | * @var string 47 | */ 48 | private $valueColumn; 49 | 50 | public function __construct(Connection $conn, StorageKeyGeneratorInterface $storageKeyGenerator) { 51 | $this->conn = $conn; 52 | $this->storageKeyGenerator = $storageKeyGenerator; 53 | // TODO just call `createSchemaManager()` as soon as DBAL >= 3.1 is required 54 | $this->schemaManager = \method_exists($this->conn, 'createSchemaManager') ? $this->conn->createSchemaManager() : $this->conn->getSchemaManager(); 55 | $this->keyColumn = $this->conn->quoteIdentifier(self::KEY_COLUMN); 56 | $this->valueColumn = $this->conn->quoteIdentifier(self::VALUE_COLUMN); 57 | } 58 | 59 | /** 60 | * {@inheritDoc} 61 | */ 62 | public function set($key, $value) { 63 | if (!$this->tableExists()) { 64 | $this->createTable(); 65 | } 66 | 67 | if ($this->has($key)) { 68 | $this->conn->update(self::TABLE, [ 69 | $this->valueColumn => serialize($value), 70 | ], [ 71 | $this->keyColumn => $this->generateKey($key), 72 | ]); 73 | 74 | return; 75 | } 76 | 77 | $this->conn->insert(self::TABLE, [ 78 | $this->keyColumn => $this->generateKey($key), 79 | $this->valueColumn => serialize($value), 80 | ]); 81 | } 82 | 83 | /** 84 | * {@inheritDoc} 85 | */ 86 | public function get($key, $default = null) { 87 | if (!$this->tableExists()) { 88 | return $default; 89 | } 90 | 91 | $rawValue = $this->getRawValueForKey($key); 92 | 93 | if ($rawValue === false) { 94 | return $default; 95 | } 96 | 97 | return unserialize($rawValue); 98 | } 99 | 100 | /** 101 | * {@inheritDoc} 102 | */ 103 | public function has($key) { 104 | if (!$this->tableExists()) { 105 | return false; 106 | } 107 | 108 | return $this->getRawValueForKey($key) !== false; 109 | } 110 | 111 | /** 112 | * {@inheritDoc} 113 | */ 114 | public function remove($key) { 115 | if (!$this->tableExists()) { 116 | return; 117 | } 118 | 119 | $this->conn->delete(self::TABLE, [ 120 | $this->keyColumn => $this->generateKey($key), 121 | ]); 122 | } 123 | 124 | /** 125 | * Gets stored raw data for the given key. 126 | * @param string $key 127 | * @return string|false Raw data or false, if no data is available. 128 | */ 129 | private function getRawValueForKey($key) { 130 | $qb = $this->conn->createQueryBuilder() 131 | ->select($this->valueColumn) 132 | ->from(self::TABLE) 133 | ->where($this->keyColumn . ' = :key') 134 | ->setParameter('key', $this->generateKey($key)) 135 | ; 136 | 137 | // TODO just call `executeQuery()` as soon as DBAL >= 2.13.1 is required 138 | $result = \method_exists($qb, 'executeQuery') ? $qb->executeQuery() : $qb->execute(); 139 | 140 | // TODO remove as soon as Doctrine DBAL >= 3.0 is required 141 | if (!\method_exists($result, 'fetchOne')) { 142 | return $result->fetchColumn(); 143 | } 144 | 145 | return $result->fetchOne(); 146 | } 147 | 148 | private function tableExists() { 149 | return $this->schemaManager->tablesExist([self::TABLE]); 150 | } 151 | 152 | private function createTable() { 153 | $table = new Table(self::TABLE, [ 154 | new Column($this->keyColumn, Type::getType(Types::STRING), ['length' => 255]), 155 | new Column($this->valueColumn, Type::getType(Types::TEXT)), 156 | ]); 157 | 158 | $table->setPrimaryKey([$this->keyColumn]); 159 | $this->schemaManager->createTable($table); 160 | } 161 | 162 | private function generateKey($key) { 163 | return $this->storageKeyGenerator->generate($key); 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /Form/FormFlowInterface.php: -------------------------------------------------------------------------------- 1 | 14 | * @copyright 2011-2025 Christian Raue 15 | * @license http://opensource.org/licenses/mit-license.php MIT License 16 | */ 17 | interface FormFlowInterface { 18 | 19 | /** 20 | * @return string 21 | */ 22 | function getName(); 23 | 24 | /** 25 | * @param FormFactoryInterface $formFactory 26 | */ 27 | function setFormFactory(FormFactoryInterface $formFactory); 28 | 29 | /** 30 | * @param RequestStack $requestStack 31 | */ 32 | function setRequestStack(RequestStack $requestStack); 33 | 34 | /** 35 | * @param DataManagerInterface $dataManager 36 | */ 37 | function setDataManager(DataManagerInterface $dataManager); 38 | 39 | /** 40 | * @return DataManagerInterface 41 | */ 42 | function getDataManager(); 43 | 44 | /** 45 | * @param EventDispatcherInterface $eventDispatcher 46 | */ 47 | function setEventDispatcher(EventDispatcherInterface $eventDispatcher); 48 | 49 | /** 50 | * @return bool 51 | */ 52 | function isRevalidatePreviousSteps(); 53 | 54 | /** 55 | * @return bool 56 | */ 57 | function isAllowDynamicStepNavigation(); 58 | 59 | /** 60 | * @return bool If file uploads should be handled by serializing them into the storage. 61 | */ 62 | function isHandleFileUploads(); 63 | 64 | /** 65 | * @return string|null Directory for storing temporary files while handling uploads. If null, the system's default will be used. 66 | */ 67 | function getHandleFileUploadsTempDir(); 68 | 69 | /** 70 | * @return bool 71 | */ 72 | function isAllowRedirectAfterSubmit(); 73 | 74 | /** 75 | * @return string 76 | */ 77 | function getId(); 78 | 79 | /** 80 | * @return string 81 | */ 82 | function getInstanceId(); 83 | 84 | /** 85 | * Restores previously saved form data of all steps and determines the current step. 86 | * @param mixed $formData 87 | */ 88 | function bind($formData); 89 | 90 | /** 91 | * @return mixed 92 | */ 93 | function getFormData(); 94 | 95 | /** 96 | * Creates the form for the current step. 97 | * @return FormInterface 98 | */ 99 | function createForm(); 100 | 101 | /** 102 | * @param int $stepNumber 103 | * @return bool 104 | */ 105 | function isStepDone($stepNumber); 106 | 107 | /** 108 | * @param int $stepNumber 109 | * @return bool 110 | */ 111 | function isStepSkipped($stepNumber); 112 | 113 | /** 114 | * @param FormInterface $form 115 | * @return bool Whether the form is valid. 116 | */ 117 | function isValid(FormInterface $form); 118 | 119 | /** 120 | * Saves the form data of the current step. 121 | * @param FormInterface $form 122 | */ 123 | function saveCurrentStepData(FormInterface $form); 124 | 125 | /** 126 | * Proceeds to the next step. 127 | * @return bool Whether the next step can be prepared. If not, the flow is finished. 128 | */ 129 | function nextStep(); 130 | 131 | /** 132 | * Resets the flow and clears its underlying storage. 133 | */ 134 | function reset(); 135 | 136 | /** 137 | * @return int First visible step, which may be greater than 1 if steps are skipped. 138 | */ 139 | function getFirstStepNumber(); 140 | 141 | /** 142 | * @return int Last visible step, which may be less than getStepCount() if steps are skipped. 143 | */ 144 | function getLastStepNumber(); 145 | 146 | /** 147 | * @return int 148 | * @throws \RuntimeException If the current step is not yet known. 149 | */ 150 | function getCurrentStepNumber(); 151 | 152 | /** 153 | * @return string|null The label for the current step. 154 | */ 155 | function getCurrentStepLabel(); 156 | 157 | /** 158 | * Get labels for all steps used to render the step list. 159 | * @return string[]|null[] Value with index 0 is the label for step 1. 160 | */ 161 | function getStepLabels(); 162 | 163 | /** 164 | * @param int $stepNumber 165 | * @return StepInterface 166 | * @throws InvalidTypeException If $stepNumber is not an integer. 167 | * @throws \OutOfBoundsException If step $stepNumber doesn't exist. 168 | */ 169 | function getStep($stepNumber); 170 | 171 | /** 172 | * @return StepInterface[] Value with index 0 is step 1. 173 | */ 174 | function getSteps(); 175 | 176 | /** 177 | * @return int 178 | */ 179 | function getStepCount(); 180 | 181 | /** 182 | * @return StepInterface[] Steps done. 183 | */ 184 | function getStepsDone(); 185 | 186 | /** 187 | * @return StepInterface[] Steps remaining. 188 | */ 189 | function getStepsRemaining(); 190 | 191 | /** 192 | * @return int Count of steps done. 193 | */ 194 | function getStepsDoneCount(); 195 | 196 | /** 197 | * @return int Count of steps remaining. 198 | */ 199 | function getStepsRemainingCount(); 200 | } 201 | -------------------------------------------------------------------------------- /Form/Step.php: -------------------------------------------------------------------------------- 1 | 11 | * @copyright 2011-2025 Christian Raue 12 | * @license http://opensource.org/licenses/mit-license.php MIT License 13 | */ 14 | class Step implements StepInterface { 15 | 16 | /** 17 | * @var int 18 | */ 19 | protected $number; 20 | 21 | /** 22 | * @var string|StepLabel|null 23 | */ 24 | protected $label = null; 25 | 26 | /** 27 | * @var FormTypeInterface|string|null 28 | */ 29 | protected $formType = null; 30 | 31 | /** 32 | * @var array 33 | */ 34 | protected $formOptions = []; 35 | 36 | /** 37 | * @var callable|null 38 | */ 39 | private $skipFunction = null; 40 | 41 | /** 42 | * @var bool|null Is only null if not yet evaluated. 43 | */ 44 | private $skipped = false; 45 | 46 | public static function createFromConfig($number, array $config) { 47 | $step = new static(); 48 | 49 | $step->setNumber($number); 50 | 51 | foreach ($config as $key => $value) { 52 | switch ($key) { 53 | case 'label': 54 | $step->setLabel($value); 55 | break; 56 | case 'type': 57 | @trigger_error('Step config option "type" is deprecated since CraueFormFlowBundle 3.0. Use "form_type" instead.', E_USER_DEPRECATED); 58 | case 'form_type': 59 | $step->setFormType($value); 60 | break; 61 | case 'form_options': 62 | $step->setFormOptions($value); 63 | break; 64 | case 'skip': 65 | $step->setSkip($value); 66 | break; 67 | default: 68 | throw new \InvalidArgumentException(sprintf('Invalid step config option "%s" given.', $key)); 69 | } 70 | } 71 | 72 | return $step; 73 | } 74 | 75 | /** 76 | * @param int $number 77 | */ 78 | public function setNumber($number) { 79 | if (is_int($number)) { 80 | $this->number = $number; 81 | 82 | return; 83 | } 84 | 85 | throw new InvalidTypeException($number, 'int'); 86 | } 87 | 88 | /** 89 | * {@inheritDoc} 90 | */ 91 | public function getNumber() { 92 | return $this->number; 93 | } 94 | 95 | /** 96 | * @param string|StepLabel|null $label 97 | */ 98 | public function setLabel($label) { 99 | if (is_string($label)) { 100 | $this->label = StepLabel::createStringLabel($label); 101 | 102 | return; 103 | } 104 | 105 | if ($label === null || $label instanceof StepLabel) { 106 | $this->label = $label; 107 | 108 | return; 109 | } 110 | 111 | throw new InvalidTypeException($label, ['null', 'string', StepLabel::class]); 112 | } 113 | 114 | /** 115 | * {@inheritDoc} 116 | */ 117 | public function getLabel() { 118 | try { 119 | return $this->label !== null ? $this->label->getText() : null; 120 | } catch (StepLabelCallableInvalidReturnValueException $e) { 121 | throw new \RuntimeException(sprintf('The label callable for step %d did not return a string or null value.', 122 | $this->number)); 123 | } 124 | } 125 | 126 | /** 127 | * @param FormTypeInterface|string|null $formType 128 | * @throws InvalidTypeException 129 | */ 130 | public function setFormType($formType) { 131 | if ($formType === null || is_string($formType) || $formType instanceof FormTypeInterface) { 132 | $this->formType = $formType; 133 | 134 | return; 135 | } 136 | 137 | throw new InvalidTypeException($formType, ['null', 'string', FormTypeInterface::class]); 138 | } 139 | 140 | /** 141 | * {@inheritDoc} 142 | */ 143 | public function getFormType() { 144 | return $this->formType; 145 | } 146 | 147 | /** 148 | * @param array $formOptions 149 | */ 150 | public function setFormOptions($formOptions) { 151 | if (is_array($formOptions)) { 152 | $this->formOptions = $formOptions; 153 | 154 | return; 155 | } 156 | 157 | throw new InvalidTypeException($formOptions, 'array'); 158 | } 159 | 160 | /** 161 | * {@inheritDoc} 162 | */ 163 | public function getFormOptions() { 164 | return $this->formOptions; 165 | } 166 | 167 | /** 168 | * @param bool|callable $skip 169 | * @throws InvalidTypeException 170 | */ 171 | public function setSkip($skip) { 172 | if (is_bool($skip)) { 173 | $this->skipFunction = null; 174 | $this->skipped = $skip; 175 | 176 | return; 177 | } 178 | 179 | if (is_callable($skip)) { 180 | $this->skipFunction = $skip; 181 | $this->skipped = null; 182 | 183 | return; 184 | } 185 | 186 | throw new InvalidTypeException($skip, ['bool', 'callable']); 187 | } 188 | 189 | /** 190 | * {@inheritDoc} 191 | */ 192 | public function evaluateSkipping($estimatedCurrentStepNumber, FormFlowInterface $flow) { 193 | if ($this->skipFunction !== null) { 194 | $returnValue = ($this->skipFunction)(...[$estimatedCurrentStepNumber, $flow]); 195 | 196 | if (!is_bool($returnValue)) { 197 | throw new \RuntimeException(sprintf('The skip callable for step %d did not return a boolean value.', 198 | $this->number)); 199 | } 200 | 201 | $this->skipped = $returnValue; 202 | } 203 | } 204 | 205 | /** 206 | * {@inheritDoc} 207 | */ 208 | public function isSkipped() { 209 | return $this->skipped === true; 210 | } 211 | 212 | } 213 | -------------------------------------------------------------------------------- /UPGRADE-2.0.md: -------------------------------------------------------------------------------- 1 | # Upgrade from 1.x to 2.0 2 | 3 | ## Action 4 | 5 | - Remove the form data as argument to method `createForm`. 6 | 7 | before: 8 | ```php 9 | $form = $flow->createForm($formData); 10 | ``` 11 | 12 | after: 13 | ```php 14 | $form = $flow->createForm(); 15 | ``` 16 | 17 | - Add the form as argument to method `saveCurrentStepData`. 18 | 19 | before: 20 | ```php 21 | $flow->saveCurrentStepData(); 22 | ``` 23 | 24 | after: 25 | ```php 26 | $flow->saveCurrentStepData($form); 27 | ``` 28 | 29 | ## Events 30 | 31 | - The current step number won't be determined by the time `PostBindSavedDataEvent` is dispatched. So `PostBindFlowEvent` has been added and should be used instead of `PostBindSavedDataEvent` for code which needs to access the current step number. 32 | 33 | ## Flow 34 | 35 | - Add method `getName`. Let it return the same value `getName` does for your form type to continue working with the same validation groups. 36 | 37 | ```php 38 | public function getName() { 39 | return 'createVehicle'; 40 | } 41 | ``` 42 | 43 | - Remove property `maxSteps` and method `loadStepDescriptions`. Replace them with methods `setFormType` and `loadStepsConfig`. Add option `flowStep` to method `getFormOptions.` 44 | 45 | before: 46 | ```php 47 | protected $maxSteps = 3; 48 | 49 | protected function loadStepDescriptions() { 50 | return array( 51 | 'wheels', 52 | 'engine', 53 | 'confirmation', 54 | ); 55 | } 56 | ``` 57 | 58 | after: 59 | ```php 60 | use Symfony\Component\Form\FormTypeInterface; 61 | 62 | /** 63 | * @var FormTypeInterface 64 | */ 65 | protected $formType; 66 | 67 | public function setFormType(FormTypeInterface $formType) { 68 | $this->formType = $formType; 69 | } 70 | 71 | protected function loadStepsConfig() { 72 | return array( 73 | array( 74 | 'label' => 'wheels', 75 | 'type' => $this->formType, 76 | ), 77 | array( 78 | 'label' => 'engine', 79 | 'type' => $this->formType, 80 | ), 81 | array( 82 | 'label' => 'confirmation', 83 | 'type' => $this->formType, 84 | ), 85 | ); 86 | } 87 | 88 | public function getFormOptions($step, array $options = array()) { 89 | $options = parent::getFormOptions($step, $options); 90 | 91 | $options['flowStep'] = $step; 92 | 93 | return $options; 94 | } 95 | ``` 96 | 97 | - Method `getFormOptions` doesn't receive the form data as an argument anymore. You have to get it if needed. Also, step-based options don't necessarily also need to be available for all subsequent steps anymore. 98 | 99 | before: 100 | ```php 101 | public function getFormOptions($formData, $step, array $options = array()) { 102 | $options = parent::getFormOptions($formData, $step, $options); 103 | 104 | if ($step > 1) { 105 | $options['numberOfWheels'] = $formData->getNumberOfWheels(); 106 | } 107 | 108 | return $options; 109 | } 110 | ``` 111 | 112 | after: 113 | ```php 114 | public function getFormOptions($step, array $options = array()) { 115 | $options = parent::getFormOptions($step, $options); 116 | 117 | $formData = $this->getFormData(); 118 | 119 | if ($step === 2) { // if you need this option only for step 2 120 | $options['numberOfWheels'] = $formData->getNumberOfWheels(); 121 | } 122 | 123 | return $options; 124 | } 125 | ``` 126 | 127 | - Some methods have been renamed to make more clear what they do. 128 | 129 | - `getMaxSteps` to `getStepCount` 130 | - `getCurrentStep` to `getCurrentStepNumber` 131 | - `getCurrentStepDescription` to `getCurrentStepLabel` 132 | - `getStepDescriptions` to `getStepLabels` 133 | - `getFirstStep` to `getFirstStepNumber` 134 | - `getLastStep` to `getLastStepNumber` 135 | - `hasSkipStep` to `isStepSkipped` 136 | - `getRequestedStep` to `getRequestedStepNumber` 137 | - `determineCurrentStep` to `determineCurrentStepNumber` 138 | 139 | - One method has been made protected. 140 | 141 | - `applySkipping` 142 | 143 | - Some methods' signatures have changed in several ways. 144 | 145 | - `public function createForm($formData, array $options = array())` to `public function createForm(array $options = array())` 146 | - `public function getFormOptions($formData, $step, array $options = array())` to `public function getFormOptions($step, array $options = array())` 147 | - `public function determineCurrentStep()` to `protected function determineCurrentStepNumber()` 148 | - `public function getRequestedStep()` to `protected function getRequestedStepNumber()` 149 | - `protected function createFormForStep($formData, $step, array $options = array())` to `protected function createFormForStep($stepNumber, array $options = array())` 150 | - `public function saveCurrentStepData()` to `public function saveCurrentStepData(FormInterface $form)` 151 | - `public function applyDataFromSavedSteps($formData, array $options = array())` to `protected function applyDataFromSavedSteps()` 152 | 153 | - Some methods have been removed. 154 | 155 | - `setFormType`/`getFormType` 156 | - `setMaxSteps` 157 | - `setCurrentStep` 158 | - `addSkipStep`/`removeSkipStep` 159 | - `loadStepDescriptions` 160 | 161 | - Some properties have been renamed and/or made private. Use their public accessors instead. 162 | 163 | - `id`: `setId`/`getId` 164 | - `formStepKey`: `setFormStepKey`/`getFormStepKey` 165 | - `formTransitionKey`: `setFormTransitionKey`/`getFormTransitionKey` 166 | - `stepDataKey`: `setStepDataKey`/`getStepDataKey` 167 | - `validationGroupPrefix`: `setValidationGroupPrefix`/`getValidationGroupPrefix` 168 | - `maxSteps`: `getStepCount` 169 | - `stepDescriptions`: `getStepLabels` 170 | - `currentStep`: `getCurrentStepNumber` 171 | - `request`: `getRequest` 172 | 173 | - Some properties have been removed. 174 | 175 | - `formType` 176 | - `skipSteps` 177 | 178 | - After calling `nextStep`, now the method `getCurrentStepNumber` won't return a value greater than what `getStepCount` returns. This used to be different in 1.x, where `getCurrentStep` returned `getMaxSteps() + 1` in case the flow is finished. 179 | 180 | ## Template 181 | 182 | - Block `craue_flow_stepDescription` has been renamed to `craue_flow_stepLabel` and the variable it accesses has been renamed from `stepDescription` to `stepLabel`. 183 | 184 | before: 185 | ```twig 186 | {{ block('craue_flow_stepDescription') }} 187 | ``` 188 | 189 | after: 190 | ```twig 191 | {{ block('craue_flow_stepLabel') }} 192 | ``` 193 | 194 | before: 195 | ```twig 196 | {% block craue_flow_stepDescription %} 197 | {{ stepDescription | trans }} 198 | {% endblock %} 199 | ``` 200 | 201 | after: 202 | ```twig 203 | {% block craue_flow_stepLabel %} 204 | {{ stepLabel | trans }} 205 | {% endblock %} 206 | ``` 207 | -------------------------------------------------------------------------------- /UPGRADE-3.0.md: -------------------------------------------------------------------------------- 1 | # Upgrade from 2.1.x to 3.0 2 | 3 | ## Removal of request scope from service definitions 4 | 5 | - To ensure compatibility with the latest versions of Symfony, the request scope has been removed from all service 6 | definitions. You in turn also have to remove the scope from your flows and connected form types. 7 | 8 | before: 9 | ```xml 10 | 14 | 15 | ``` 16 | 17 | after: 18 | ```xml 19 | 22 | 23 | ``` 24 | 25 | ## Renaming step config option `type` to `form_type` 26 | 27 | - The step config option to specify the form type for each step within the `loadStepsConfig` method has been renamed 28 | from `type` to `form_type`. This was done for the sake of consistency with the newly added option `form_options`. 29 | The old option `type` is still available, but deprecated. 30 | 31 | before: 32 | ```php 33 | protected function loadStepsConfig() { 34 | return array( 35 | array( 36 | 'type' => $this->formType, 37 | ), 38 | // ... 39 | ); 40 | } 41 | ``` 42 | 43 | after: 44 | ```php 45 | protected function loadStepsConfig() { 46 | return array( 47 | array( 48 | 'form_type' => $this->formType, 49 | ), 50 | // ... 51 | ); 52 | } 53 | ``` 54 | 55 | ## Concurrent instances of the same flow 56 | 57 | - This version adds support for concurrent instances of the same flow, which required a change in the handling of flows. 58 | 59 | - When performing a GET request _without any additional parameters_ to run a flow with dynamic step navigation enabled, 60 | it has just been reused as there could only be one instance using the default session storage. So previously, the 61 | data of all steps would still be available. Now, a new flow instance will be started. Thus, if you want to provide a 62 | custom link to the same flow instance, (beside the optional step number) you now need to add the instance id as 63 | parameter `instance` (per default). 64 | 65 | before: 66 | ```twig 67 | continue creating a topic 68 | ``` 69 | 70 | after: 71 | ```twig 72 | continue creating a topic 73 | ``` 74 | 75 | - For the same reason, it's no longer necessary to use a dedicated action to reset a flow in order to start it with 76 | clean data. 77 | 78 | before: 79 | ```php 80 | /** 81 | * @Route("/create-topic/start/", name="createTopic_start") 82 | */ 83 | public function createTopicStartAction() { 84 | $flow = $this->get('form.flow.createTopic'); 85 | $flow->reset(); 86 | 87 | return $this->redirect($this->generateUrl('createTopic')); 88 | } 89 | ``` 90 | ```twig 91 | create a topic 92 | ``` 93 | 94 | after: 95 | ```twig 96 | create a topic 97 | ``` 98 | 99 | - To remove saved step data from the session when finishing the flow you should call `$flow->reset()` at the end of the 100 | action. 101 | 102 | before: 103 | ```php 104 | public function createTopicAction() { 105 | // ... 106 | 107 | // flow finished 108 | // persist data to the DB or whatever... 109 | 110 | // redirect when done... 111 | } 112 | ``` 113 | 114 | after: 115 | ```php 116 | public function createTopicAction() { 117 | // ... 118 | 119 | // flow finished 120 | // persist data to the DB or whatever... 121 | 122 | $flow->reset(); 123 | 124 | // redirect when done... 125 | } 126 | ``` 127 | 128 | ## Removal of options from method `createForm` 129 | 130 | - Options cannot be passed to step forms using `createForm` anymore. You can now use `setGenericFormOptions` for that. 131 | 132 | before: 133 | ```php 134 | $flow->bind($formData); 135 | $form = $flow->createForm(array('action' => 'targetUrl')); 136 | ``` 137 | 138 | after: 139 | ```php 140 | $flow->setGenericFormOptions(array('action' => 'targetUrl')); 141 | $flow->bind($formData); 142 | $form = $flow->createForm(); 143 | ``` 144 | 145 | ## Events 146 | 147 | - Some methods have been renamed. 148 | 149 | - `PostBindRequestEvent`: `getStep` to `getStepNumber` 150 | - `PostBindSavedDataEvent`: `getStep` to `getStepNumber` 151 | 152 | - Some properties have been renamed. 153 | 154 | - `PostBindRequestEvent`: `step` to `stepNumber` 155 | - `PostBindSavedDataEvent`: `step` to `stepNumber` 156 | 157 | ## Flow 158 | 159 | - A default implementation for method `getName` has been added. If you just let it return the class name with the first 160 | letter lower-cased and without the "Flow" suffix, you can remove it from your flow since the default implementation 161 | will return the same value. 162 | 163 | before: 164 | ```php 165 | class CreateVehicleFlow extends FormFlow { 166 | 167 | public function getName() { 168 | return 'createVehicle'; 169 | } 170 | 171 | // ... 172 | } 173 | ``` 174 | 175 | after: 176 | ```php 177 | class CreateVehicleFlow extends FormFlow { 178 | // ... 179 | } 180 | ``` 181 | 182 | - The signature of method `setRequest` has changed to accept a `RequestStack` instance. 183 | 184 | - `public function setRequest(Request $request = null)` to `public function setRequestStack(RequestStack $requestStack)` 185 | 186 | - Some methods have been removed. 187 | 188 | - `setStepDataKey`/`getStepDataKey` 189 | - `setStorage`/`getStorage` (call `getDataManager()->getStorage()` instead or adapt your code to use 190 | `setDataManager`/`getDataManager`) 191 | 192 | - Some methods have been renamed. 193 | 194 | - `setDynamicStepNavigationParameter` to `setDynamicStepNavigationStepParameter` 195 | - `getDynamicStepNavigationParameter` to `getDynamicStepNavigationStepParameter` 196 | 197 | - A property has been removed. 198 | 199 | - `storage` (call `$this->dataManager->getStorage()` instead) 200 | 201 | - A property has been renamed. 202 | 203 | - `dynamicStepNavigationParameter` to `dynamicStepNavigationStepParameter` 204 | 205 | ## Step 206 | 207 | - Some methods have been renamed. 208 | 209 | - `setType` to `setFormType` 210 | - `getType` to `getFormType` 211 | 212 | - A property has been renamed. 213 | 214 | - `type` to `formType` 215 | 216 | ## Storage 217 | 218 | - The signature of method `remove` in `StorageInterface` has changed to not return the removed value anymore. 219 | 220 | ## Template 221 | 222 | - The Twig filters `craue_addDynamicStepNavigationParameter` and `craue_removeDynamicStepNavigationParameter` have been 223 | renamed to `craue_addDynamicStepNavigationParameters` and `craue_removeDynamicStepNavigationParameters`, i.e. 224 | pluralized, since they now handle more than one parameter. Filters with the old names still exist, but are deprecated. 225 | 226 | - The template `CraueFormFlowBundle:FormFlow:stepField.html.twig` (deprecated in 2.1.0) has been removed. 227 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.7.0] – 2024-01-11 4 | 5 | - [#422]: added support for Symfony 7 6 | - dropped support for Symfony 5.3, 6.0, 6.1, 6.2 7 | 8 | [#422]: https://github.com/craue/CraueFormFlowBundle/issues/422 9 | [3.7.0]: https://github.com/craue/CraueFormFlowBundle/compare/3.6.0...3.7.0 10 | 11 | ## [3.6.0] – 2022-01-24 12 | 13 | - [#393]: added support for Symfony 6 14 | - dropped support for Symfony 3.4, 5.1, 5.2 15 | 16 | [#393]: https://github.com/craue/CraueFormFlowBundle/issues/393 17 | [3.6.0]: https://github.com/craue/CraueFormFlowBundle/compare/3.5.1...3.6.0 18 | 19 | ## [3.5.1] – 2021-07-28 20 | 21 | - [#389]: fix file upload in collections 22 | 23 | [#389]: https://github.com/craue/CraueFormFlowBundle/issues/389 24 | [3.5.1]: https://github.com/craue/CraueFormFlowBundle/compare/3.5.0...3.5.1 25 | 26 | ## [3.5.0] – 2021-05-31 27 | 28 | - [#387]: avoid several deprecation notices with Symfony >= 5.3 29 | - dropped support for Symfony 5.1 30 | 31 | [#387]: https://github.com/craue/CraueFormFlowBundle/issues/387 32 | [3.5.0]: https://github.com/craue/CraueFormFlowBundle/compare/3.4.1...3.5.0 33 | 34 | ## [3.4.1] – 2021-03-31 35 | 36 | - [#386]: revert to clean class definitions for event listeners 37 | - avoid deprecation notices regarding `Symfony\Component\HttpFoundation\InputBag::get()` with Symfony >= 5.1 38 | 39 | [#386]: https://github.com/craue/CraueFormFlowBundle/issues/386 40 | [3.4.1]: https://github.com/craue/CraueFormFlowBundle/compare/3.4.0...3.4.1 41 | 42 | ## [3.4.0] – 2020-12-17 43 | 44 | - [#359]: use DataManager to check if current flow exists 45 | - [#368]: fix opcache preloading 46 | - added DoctrineStorage support for Doctrine DBAL 3.0 47 | - added support for PHP 8.0 48 | - dropped support for PHP 7.0, 7.1, 7.2 49 | - dropped support for Symfony 4.2, 4.3, 5.0 50 | 51 | [#359]: https://github.com/craue/CraueFormFlowBundle/issues/359 52 | [#368]: https://github.com/craue/CraueFormFlowBundle/issues/368 53 | [3.4.0]: https://github.com/craue/CraueFormFlowBundle/compare/3.3.2...3.4.0 54 | 55 | ## [3.3.2] – 2020-05-07 56 | 57 | - [#355]: avoid infinite loop when all steps are skipped 58 | 59 | [#355]: https://github.com/craue/CraueFormFlowBundle/issues/355 60 | [3.3.2]: https://github.com/craue/CraueFormFlowBundle/compare/3.3.1...3.3.2 61 | 62 | ## [3.3.1] – 2020-03-08 63 | 64 | - avoid warnings while cleaning up temporary files 65 | 66 | [3.3.1]: https://github.com/craue/CraueFormFlowBundle/compare/3.3.0...3.3.1 67 | 68 | ## [3.3.0] – 2019-12-03 69 | 70 | - added support for Symfony 5.* 71 | - dropped support for Symfony 4.1 72 | 73 | [3.3.0]: https://github.com/craue/CraueFormFlowBundle/compare/3.2.1...3.3.0 74 | 75 | ## [3.2.1] – 2019-10-07 76 | 77 | - [#342]: fixed a Symfony Form deprecation 78 | 79 | [#342]: https://github.com/craue/CraueFormFlowBundle/issues/342 80 | [3.2.1]: https://github.com/craue/CraueFormFlowBundle/compare/3.2.0...3.2.1 81 | 82 | ## [3.2.0] – 2019-06-05 83 | 84 | - [#333]: added autoconfiguration support 85 | 86 | [#333]: https://github.com/craue/CraueFormFlowBundle/issues/333 87 | [3.2.0]: https://github.com/craue/CraueFormFlowBundle/compare/3.1.1...3.2.0 88 | 89 | ## [3.1.1] – 2019-05-31 90 | 91 | - code updates to avoid deprecation notices 92 | 93 | [3.1.1]: https://github.com/craue/CraueFormFlowBundle/compare/3.1.0...3.1.1 94 | 95 | ## [3.0.4] – 2019-01-23 96 | 97 | - [#331]: added Italian translation 98 | 99 | [#331]: https://github.com/craue/CraueFormFlowBundle/issues/331 100 | [3.0.4]: https://github.com/craue/CraueFormFlowBundle/compare/3.0.3...3.0.4 101 | 102 | ## [3.1.0] – 2019-01-06 103 | 104 | - [#331]: added Italian translation 105 | - removed attribute `clientSize` from `SerializableFile` 106 | - dropped support for Symfony 2.7, 2.8, 3.0, 3.1, 3.2, 3.3, 4.0 107 | - dropped support for PHP 5.3, 5.4, 5.5, 5.6 108 | - dropped support for HHVM 109 | 110 | [#331]: https://github.com/craue/CraueFormFlowBundle/issues/331 111 | [3.1.0]: https://github.com/craue/CraueFormFlowBundle/compare/3.0.3...3.1.0 112 | 113 | ## [3.0.3] – 2017-12-07 114 | 115 | - [#306]: fixed method `applySkipping` to avoid OutOfBoundsException 116 | 117 | [#306]: https://github.com/craue/CraueFormFlowBundle/issues/306 118 | [3.0.3]: https://github.com/craue/CraueFormFlowBundle/compare/3.0.2...3.0.3 119 | 120 | ## [2.1.10] – 2017-12-07 121 | 122 | - [#306]: fixed method `applySkipping` to avoid OutOfBoundsException 123 | 124 | [#306]: https://github.com/craue/CraueFormFlowBundle/issues/306 125 | [2.1.10]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.9...2.1.10 126 | 127 | ## [3.0.2] – 2017-12-01 128 | 129 | - added support for Symfony 4.* 130 | 131 | [3.0.2]: https://github.com/craue/CraueFormFlowBundle/compare/3.0.1...3.0.2 132 | 133 | ## [3.0.1] – 2017-11-08 134 | 135 | - [#301]: allow setting a `GroupSequence` for the `validation_groups` option 136 | 137 | [#301]: https://github.com/craue/CraueFormFlowBundle/issues/301 138 | [3.0.1]: https://github.com/craue/CraueFormFlowBundle/compare/3.0.0...3.0.1 139 | 140 | ## [3.0.0] – 2017-09-04 141 | 142 | - BC breaks (follow `UPGRADE-3.0.md` to upgrade): 143 | - [#101]: support for concurrent instances of the same flow 144 | - [#104]: removed options from method `createForm` 145 | - [#145]: bumped Symfony dependency to 2.3 146 | - [#148]: restructured data storage 147 | - [#180]: renamed step config option `type` to `form_type` 148 | - [#184]: made the bundle Symfony 3 compatible, bumped Symfony dependency to 2.6 149 | - [#222]: bumped Symfony dependency to 2.7 150 | - removed the step field template 151 | - renamed property `step` to `stepNumber` and method `getStep` to `getStepNumber` within event classes 152 | - [#98]+[#143]: add a validation error to the current form if a form of a previous step became invalid 153 | - [#107]: added Czech translation 154 | - [#112]: improved Dutch translation 155 | - [#117]: method `getFormOptions` returns an array for the `validation_groups` option 156 | - [#122]: added support for PUT method 157 | - [#125]: added generic form options to simplify passing options to all steps 158 | - [#126]: allow custom classes on buttons 159 | - [#133]+[#134]: added Farsi translation 160 | - [#142]: added support for the "redirect after submit" pattern 161 | - [#146]: handling of file uploads 162 | - [#175]+[#178]: form options for each step 163 | - [#196]: allow to use a closure for the `validation_groups` option 164 | - [#215]: added a default `getName` implementation 165 | - [#217]: added DoctrineStorage to store data in a Doctrine-managed database 166 | - [#219]+[#238]: allow a callable for each step label 167 | - [#220]: added expired form detection 168 | - [#226]: allow custom button labels 169 | - [#254]: added Slovak translation 170 | - [#262]: added getters for steps remaining and done 171 | - [#280]: added an option for removing the reset button 172 | - [#293]: added Hungarian translation 173 | 174 | [#98]: https://github.com/craue/CraueFormFlowBundle/issues/98 175 | [#101]: https://github.com/craue/CraueFormFlowBundle/issues/101 176 | [#104]: https://github.com/craue/CraueFormFlowBundle/issues/104 177 | [#107]: https://github.com/craue/CraueFormFlowBundle/issues/107 178 | [#112]: https://github.com/craue/CraueFormFlowBundle/issues/112 179 | [#117]: https://github.com/craue/CraueFormFlowBundle/issues/117 180 | [#122]: https://github.com/craue/CraueFormFlowBundle/issues/122 181 | [#125]: https://github.com/craue/CraueFormFlowBundle/issues/125 182 | [#126]: https://github.com/craue/CraueFormFlowBundle/issues/126 183 | [#133]: https://github.com/craue/CraueFormFlowBundle/issues/133 184 | [#134]: https://github.com/craue/CraueFormFlowBundle/issues/134 185 | [#142]: https://github.com/craue/CraueFormFlowBundle/issues/142 186 | [#143]: https://github.com/craue/CraueFormFlowBundle/issues/143 187 | [#145]: https://github.com/craue/CraueFormFlowBundle/issues/145 188 | [#146]: https://github.com/craue/CraueFormFlowBundle/issues/146 189 | [#148]: https://github.com/craue/CraueFormFlowBundle/issues/148 190 | [#175]: https://github.com/craue/CraueFormFlowBundle/issues/175 191 | [#178]: https://github.com/craue/CraueFormFlowBundle/issues/178 192 | [#180]: https://github.com/craue/CraueFormFlowBundle/issues/180 193 | [#184]: https://github.com/craue/CraueFormFlowBundle/issues/184 194 | [#196]: https://github.com/craue/CraueFormFlowBundle/issues/196 195 | [#215]: https://github.com/craue/CraueFormFlowBundle/issues/215 196 | [#217]: https://github.com/craue/CraueFormFlowBundle/issues/217 197 | [#219]: https://github.com/craue/CraueFormFlowBundle/issues/219 198 | [#220]: https://github.com/craue/CraueFormFlowBundle/issues/220 199 | [#222]: https://github.com/craue/CraueFormFlowBundle/issues/222 200 | [#226]: https://github.com/craue/CraueFormFlowBundle/issues/226 201 | [#238]: https://github.com/craue/CraueFormFlowBundle/issues/238 202 | [#254]: https://github.com/craue/CraueFormFlowBundle/issues/254 203 | [#262]: https://github.com/craue/CraueFormFlowBundle/issues/262 204 | [#280]: https://github.com/craue/CraueFormFlowBundle/issues/280 205 | [#293]: https://github.com/craue/CraueFormFlowBundle/issues/293 206 | [3.0.0]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.9...3.0.0 207 | 208 | ## [2.1.9] – 2015-12-29 209 | 210 | - [#205]: added conditional code updates to avoid most deprecation notices with Symfony 2.8 211 | - added forward compatibility for Twig 2.0 212 | - added support for PHP 7.0 and HHVM 213 | 214 | [#205]: https://github.com/craue/CraueFormFlowBundle/issues/205 215 | [2.1.9]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.8...2.1.9 216 | 217 | ## [2.1.8] – 2015-06-11 218 | 219 | - [#169]: simplified some code meant for avoiding deprecation notices 220 | - suppress errors when triggering deprecation notices 221 | 222 | [#169]: https://github.com/craue/CraueFormFlowBundle/issues/169 223 | [2.1.8]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.7...2.1.8 224 | 225 | ## [2.1.7] – 2015-03-06 226 | 227 | - avoid bubbling up of a possible OutOfBoundsException while determining the current step number 228 | - fixed minimum version of Symfony 229 | 230 | [2.1.7]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.6...2.1.7 231 | 232 | ## [2.1.6] – 2015-02-02 233 | 234 | - added conditional code updates to avoid deprecation notices 235 | 236 | [2.1.6]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.5...2.1.6 237 | 238 | ## [2.1.5] – 2014-06-13 239 | 240 | - [#132]: fixed BC method `hasSkipStep` 241 | 242 | [#132]: https://github.com/craue/CraueFormFlowBundle/issues/132 243 | [2.1.5]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.4...2.1.5 244 | 245 | ## [2.1.4] – 2013-12-05 246 | 247 | - adjusted Composer constraint to allow being used with SensioFrameworkExtraBundle 3.0 248 | 249 | [2.1.4]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.3...2.1.4 250 | 251 | ## [2.1.3] – 2013-11-18 252 | 253 | - [#94]: disallow invalid step config options 254 | - ensure that `Step#isSkipped` always returns a boolean value 255 | - avoid triggering deprecation errors when used with Symfony 2.1.x 256 | - [#100]: fixed the step list to avoid linking not yet accessible steps 257 | 258 | [#94]: https://github.com/craue/CraueFormFlowBundle/issues/94 259 | [#100]: https://github.com/craue/CraueFormFlowBundle/issues/100 260 | [2.1.3]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.2...2.1.3 261 | 262 | ## [2.1.2] – 2013-09-26 263 | 264 | - [#90]: fixed the step list to render the last step already been visited (but not submitted) as a link 265 | 266 | [#90]: https://github.com/craue/CraueFormFlowBundle/issues/90 267 | [2.1.2]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.1...2.1.2 268 | 269 | ## [2.1.1] – 2013-09-24 270 | 271 | - ensure that `skip` callables always return a boolean value 272 | - [#87]: the step parameter used in links to specific steps is not limited to be a query parameter anymore, e.g. can be a route parameter 273 | 274 | [#87]: https://github.com/craue/CraueFormFlowBundle/issues/87 275 | [2.1.1]: https://github.com/craue/CraueFormFlowBundle/compare/2.1.0...2.1.1 276 | 277 | ## [2.1.0] – 2013-08-27 278 | 279 | - [#75]: the hidden step field is automatically added to the form (follow `UPGRADE-2.1.md` to upgrade) 280 | 281 | [#75]: https://github.com/craue/CraueFormFlowBundle/issues/75 282 | [2.1.0]: https://github.com/craue/CraueFormFlowBundle/compare/2.0.1...2.1.0 283 | 284 | ## [2.0.1] – 2013-07-12 285 | 286 | - fixed steps being incorrectly marked as skipped when resetting the flow 287 | [2.0.1]: https://github.com/craue/CraueFormFlowBundle/compare/2.0.0...2.0.1 288 | 289 | ## [2.0.0] – 2013-05-27 290 | 291 | - BC breaks (follow `UPGRADE-2.0.md` to upgrade): 292 | - [#46]: reworked the way steps are defined 293 | - adjustments in handling the request for Symfony 2.3 compatibility 294 | - [#52]: added `GetStepsEvent` 295 | - added `PostBindFlowEvent` 296 | 297 | [#46]: https://github.com/craue/CraueFormFlowBundle/issues/46 298 | [#52]: https://github.com/craue/CraueFormFlowBundle/issues/52 299 | [2.0.0]: https://github.com/craue/CraueFormFlowBundle/compare/1.1.3...2.0.0 300 | 301 | ## [1.1.3] – 2013-05-23 302 | 303 | - [#48]: added method `getStorage` 304 | - made the dependency on an event dispatcher optional 305 | 306 | [#48]: https://github.com/craue/CraueFormFlowBundle/issues/48 307 | [1.1.3]: https://github.com/craue/CraueFormFlowBundle/compare/1.1.2...1.1.3 308 | 309 | ## [1.1.2] – 2013-04-17 310 | 311 | - always dispatch `PreBindEvent` when `bind` is called (to match expected behavior) 312 | - [#45]: added Brazilian Portuguese translation 313 | 314 | [#45]: https://github.com/craue/CraueFormFlowBundle/issues/45 315 | [1.1.2]: https://github.com/craue/CraueFormFlowBundle/compare/1.1.1...1.1.2 316 | 317 | ## [1.1.1] – 2013-04-14 318 | 319 | - avoid skipping all steps by tampering with the hidden step field 320 | - added some basic tests 321 | 322 | [1.1.1]: https://github.com/craue/CraueFormFlowBundle/compare/1.1.0...1.1.1 323 | 324 | ## [1.1.0] – 2013-02-28 325 | 326 | - adjustments to changes in the Form component for Symfony 2.1.* 327 | - adjustments to changes in the HttpFoundation component for Symfony 2.1.* 328 | - [#21]: added `StorageInterface` 329 | - [#23]: added route parameters to links generated for dynamic step navigation 330 | - preserve given `validation_groups` option 331 | - added the flow instance as a property in events 332 | - throw an exception if the number of steps doesn't match the number of step descriptions 333 | 334 | [#21]: https://github.com/craue/CraueFormFlowBundle/issues/21 335 | [#23]: https://github.com/craue/CraueFormFlowBundle/issues/23 336 | [1.1.0]: https://github.com/craue/CraueFormFlowBundle/compare/1.0.0...1.1.0 337 | 338 | ## 1.0.0 – 2012-03-07 339 | 340 | - first stable release for Symfony 2.0.* 341 | 342 | ## 2011-08-02 343 | 344 | - initial commit 345 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Information 2 | 3 | [![Tests](https://github.com/craue/CraueFormFlowBundle/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/craue/CraueFormFlowBundle/actions/workflows/tests.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/craue/CraueFormFlowBundle/badge.svg?branch=master)](https://coveralls.io/github/craue/CraueFormFlowBundle?branch=master) 5 | 6 | CraueFormFlowBundle provides a facility for building and handling multi-step forms in your Symfony project. 7 | 8 | Features: 9 | - navigation (next, back, start over) 10 | - step labels 11 | - skipping of steps 12 | - different validation group for each step 13 | - handling of file uploads 14 | - dynamic step navigation (optional) 15 | - redirect after submit (a.k.a. "Post/Redirect/Get", optional) 16 | 17 | A live demo showcasing these features is available at http://craue.de/symfony-playground/en/CraueFormFlow/. 18 | 19 | # Installation 20 | 21 | ## Get the bundle 22 | 23 | Let Composer download and install the bundle by running 24 | 25 | ```sh 26 | composer require craue/formflow-bundle 27 | ``` 28 | 29 | in a shell. 30 | 31 | ## Enable the bundle 32 | 33 | If you don't use Symfony Flex, register the bundle manually: 34 | 35 | ```php 36 | // in config/bundles.php 37 | return [ 38 | // ... 39 | Craue\FormFlowBundle\CraueFormFlowBundle::class => ['all' => true], 40 | ]; 41 | ``` 42 | 43 | Or, for Symfony 3.4: 44 | 45 | ```php 46 | // in app/AppKernel.php 47 | public function registerBundles() { 48 | $bundles = [ 49 | // ... 50 | new Craue\FormFlowBundle\CraueFormFlowBundle(), 51 | ]; 52 | // ... 53 | } 54 | ``` 55 | 56 | # Usage 57 | 58 | This section shows how to create a 3-step form flow for creating a vehicle. 59 | You have to choose between two approaches on how to set up your flow. 60 | 61 | ## Approach A: One form type for the entire flow 62 | 63 | This approach makes it easy to turn an existing (common) form into a form flow. 64 | 65 | ### Create a flow class 66 | 67 | ```php 68 | // src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 69 | use Craue\FormFlowBundle\Form\FormFlow; 70 | use Craue\FormFlowBundle\Form\FormFlowInterface; 71 | use MyCompany\MyBundle\Form\CreateVehicleForm; 72 | 73 | class CreateVehicleFlow extends FormFlow { 74 | 75 | protected function loadStepsConfig() { 76 | return [ 77 | [ 78 | 'label' => 'wheels', 79 | 'form_type' => CreateVehicleForm::class, 80 | ], 81 | [ 82 | 'label' => 'engine', 83 | 'form_type' => CreateVehicleForm::class, 84 | 'skip' => function($estimatedCurrentStepNumber, FormFlowInterface $flow) { 85 | return $estimatedCurrentStepNumber > 1 && !$flow->getFormData()->canHaveEngine(); 86 | }, 87 | ], 88 | [ 89 | 'label' => 'confirmation', 90 | ], 91 | ]; 92 | } 93 | 94 | } 95 | ``` 96 | 97 | ### Create a form type class 98 | 99 | You only have to create one form type class for a flow. 100 | There is an option called `flow_step` you can use to decide which fields will be added to the form 101 | according to the step to render. 102 | 103 | ```php 104 | // src/MyCompany/MyBundle/Form/CreateVehicleForm.php 105 | use MyCompany\MyBundle\Form\Type\VehicleEngineType; 106 | use Symfony\Component\Form\AbstractType; 107 | use Symfony\Component\Form\Extension\Core\Type\ChoiceType; 108 | use Symfony\Component\Form\FormBuilderInterface; 109 | 110 | class CreateVehicleForm extends AbstractType { 111 | 112 | public function buildForm(FormBuilderInterface $builder, array $options) { 113 | switch ($options['flow_step']) { 114 | case 1: 115 | $validValues = [2, 4]; 116 | $builder->add('numberOfWheels', ChoiceType::class, [ 117 | 'choices' => array_combine($validValues, $validValues), 118 | 'placeholder' => '', 119 | ]); 120 | break; 121 | case 2: 122 | // This form type is not defined in the example. 123 | $builder->add('engine', VehicleEngineType::class, [ 124 | 'placeholder' => '', 125 | ]); 126 | break; 127 | } 128 | } 129 | 130 | public function getBlockPrefix() { 131 | return 'createVehicle'; 132 | } 133 | 134 | } 135 | ``` 136 | 137 | ## Approach B: One form type per step 138 | 139 | This approach makes it easy to reuse the form types to compose other forms. 140 | 141 | ### Create a flow class 142 | 143 | ```php 144 | // src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 145 | use Craue\FormFlowBundle\Form\FormFlow; 146 | use Craue\FormFlowBundle\Form\FormFlowInterface; 147 | use MyCompany\MyBundle\Form\CreateVehicleStep1Form; 148 | use MyCompany\MyBundle\Form\CreateVehicleStep2Form; 149 | 150 | class CreateVehicleFlow extends FormFlow { 151 | 152 | protected function loadStepsConfig() { 153 | return [ 154 | [ 155 | 'label' => 'wheels', 156 | 'form_type' => CreateVehicleStep1Form::class, 157 | ], 158 | [ 159 | 'label' => 'engine', 160 | 'form_type' => CreateVehicleStep2Form::class, 161 | 'skip' => function($estimatedCurrentStepNumber, FormFlowInterface $flow) { 162 | return $estimatedCurrentStepNumber > 1 && !$flow->getFormData()->canHaveEngine(); 163 | }, 164 | ], 165 | [ 166 | 'label' => 'confirmation', 167 | ], 168 | ]; 169 | } 170 | 171 | } 172 | ``` 173 | 174 | ### Create form type classes 175 | 176 | ```php 177 | // src/MyCompany/MyBundle/Form/CreateVehicleStep1Form.php 178 | use Symfony\Component\Form\AbstractType; 179 | use Symfony\Component\Form\Extension\Core\Type\ChoiceType; 180 | use Symfony\Component\Form\FormBuilderInterface; 181 | 182 | class CreateVehicleStep1Form extends AbstractType { 183 | 184 | public function buildForm(FormBuilderInterface $builder, array $options) { 185 | $validValues = [2, 4]; 186 | $builder->add('numberOfWheels', ChoiceType::class, [ 187 | 'choices' => array_combine($validValues, $validValues), 188 | 'placeholder' => '', 189 | ]); 190 | } 191 | 192 | public function getBlockPrefix() { 193 | return 'createVehicleStep1'; 194 | } 195 | 196 | } 197 | ``` 198 | 199 | ```php 200 | // src/MyCompany/MyBundle/Form/CreateVehicleStep2Form.php 201 | use MyCompany\MyBundle\Form\Type\VehicleEngineType; 202 | use Symfony\Component\Form\AbstractType; 203 | use Symfony\Component\Form\FormBuilderInterface; 204 | 205 | class CreateVehicleStep2Form extends AbstractType { 206 | 207 | public function buildForm(FormBuilderInterface $builder, array $options) { 208 | $builder->add('engine', VehicleEngineType::class, [ 209 | 'placeholder' => '', 210 | ]); 211 | } 212 | 213 | public function getBlockPrefix() { 214 | return 'createVehicleStep2'; 215 | } 216 | 217 | } 218 | ``` 219 | 220 | ## Register your flow as a service 221 | 222 | XML 223 | ```xml 224 | 225 | 228 | 229 | 230 | ``` 231 | 232 | YAML 233 | ```yaml 234 | services: 235 | myCompany.form.flow.createVehicle: 236 | class: MyCompany\MyBundle\Form\CreateVehicleFlow 237 | autoconfigure: true 238 | ``` 239 | 240 | When not using autoconfiguration, you may let your flow inherit the required dependencies from a parent service. 241 | 242 | XML 243 | ```xml 244 | 245 | 248 | 249 | 250 | ``` 251 | 252 | YAML 253 | ```yaml 254 | services: 255 | myCompany.form.flow.createVehicle: 256 | class: MyCompany\MyBundle\Form\CreateVehicleFlow 257 | parent: craue.form.flow 258 | ``` 259 | 260 | ## Create a form template 261 | 262 | You only need one template for a flow. 263 | The instance of your flow class is passed to the template in a variable called `flow` so you can use it to render the 264 | form according to the current step. 265 | 266 | ```twig 267 | {# in src/MyCompany/MyBundle/Resources/views/Vehicle/createVehicle.html.twig #} 268 |
269 | Steps: 270 | {% include '@CraueFormFlow/FormFlow/stepList.html.twig' %} 271 |
272 | {{ form_start(form) }} 273 | {{ form_errors(form) }} 274 | 275 | {% if flow.getCurrentStepNumber() == 1 %} 276 |
277 | When selecting four wheels you have to choose the engine in the next step.
278 | {{ form_row(form.numberOfWheels) }} 279 |
280 | {% endif %} 281 | 282 | {{ form_rest(form) }} 283 | 284 | {% include '@CraueFormFlow/FormFlow/buttons.html.twig' %} 285 | {{ form_end(form) }} 286 | ``` 287 | 288 | ### CSS 289 | 290 | Some CSS is needed to render the buttons correctly. Load the provided file in your base template: 291 | 292 | ```twig 293 | 294 | ``` 295 | 296 | ...and install the assets in your project: 297 | 298 | ```sh 299 | # in a shell 300 | php bin/console assets:install --symlink web 301 | ``` 302 | 303 | ### Buttons 304 | 305 | You can customize the default button look by using these variables to add one or more CSS classes to them: 306 | 307 | - `craue_formflow_button_class_last` will apply either to the __next__ or __finish__ button 308 | - `craue_formflow_button_class_finish` will specifically apply to the __finish__ button 309 | - `craue_formflow_button_class_next` will specifically apply to the __next__ button 310 | - `craue_formflow_button_class_back` will apply to the __back__ button 311 | - `craue_formflow_button_class_reset` will apply to the __reset__ button 312 | 313 | Example with Bootstrap button classes: 314 | 315 | ```twig 316 | {% include '@CraueFormFlow/FormFlow/buttons.html.twig' with { 317 | craue_formflow_button_class_last: 'btn btn-primary', 318 | craue_formflow_button_class_back: 'btn', 319 | craue_formflow_button_class_reset: 'btn btn-warning', 320 | } %} 321 | ``` 322 | 323 | In the same manner you can customize the button labels: 324 | 325 | - `craue_formflow_button_label_last` for either the __next__ or __finish__ button 326 | - `craue_formflow_button_label_finish` for the __finish__ button 327 | - `craue_formflow_button_label_next` for the __next__ button 328 | - `craue_formflow_button_label_back` for the __back__ button 329 | - `craue_formflow_button_label_reset` for the __reset__ button 330 | 331 | Example: 332 | 333 | ```twig 334 | {% include '@CraueFormFlow/FormFlow/buttons.html.twig' with { 335 | craue_formflow_button_label_finish: 'submit', 336 | craue_formflow_button_label_reset: 'reset the flow', 337 | } %} 338 | ``` 339 | 340 | You can also remove the reset button by setting `craue_formflow_button_render_reset` to `false`. 341 | 342 | ## Create an action 343 | 344 | ```php 345 | // in src/MyCompany/MyBundle/Controller/VehicleController.php 346 | public function createVehicleAction() { 347 | $formData = new Vehicle(); // Your form data class. Has to be an object, won't work properly with an array. 348 | 349 | $flow = $this->get('myCompany.form.flow.createVehicle'); // must match the flow's service id 350 | $flow->bind($formData); 351 | 352 | // form of the current step 353 | $form = $flow->createForm(); 354 | if ($flow->isValid($form)) { 355 | $flow->saveCurrentStepData($form); 356 | 357 | if ($flow->nextStep()) { 358 | // form for the next step 359 | $form = $flow->createForm(); 360 | } else { 361 | // flow finished 362 | $em = $this->getDoctrine()->getManager(); 363 | $em->persist($formData); 364 | $em->flush(); 365 | 366 | $flow->reset(); // remove step data from the session 367 | 368 | return $this->redirectToRoute('home'); // redirect when done 369 | } 370 | } 371 | 372 | return $this->render('@MyCompanyMy/Vehicle/createVehicle.html.twig', [ 373 | 'form' => $form->createView(), 374 | 'flow' => $flow, 375 | ]); 376 | } 377 | ``` 378 | 379 | ## DoctrineStorage 380 | You can configure CraueFormFlowBundle to use the DoctrineStorage instead of the SessionStorage. 381 | If a user then starts to fill out the form, the data will always be saved to the database instead of the session. 382 | DoctrineStorage will use an extra table (`craue_form_flow_storage`) for this purpose. 383 | You can use this example configuration as a starting point: 384 | ```yaml 385 | # config/packages/craue_form_flow.yaml 386 | services: 387 | Craue\FormFlowBundle\Storage\UserSessionStorageKeyGenerator: 388 | arguments: [ '@security.token_storage', '@request_stack' ] 389 | Craue\FormFlowBundle\Storage\DoctrineStorage: 390 | arguments: [ '@doctrine.dbal.default_connection', '@Craue\FormFlowBundle\Storage\UserSessionStorageKeyGenerator' ] 391 | myCompany.form.flow.storage.doctrine_storage: 392 | class: 'Craue\FormFlowBundle\Storage\DataManager' 393 | arguments: [ '@Craue\FormFlowBundle\Storage\DoctrineStorage' ] 394 | ``` 395 | 396 | ```yaml 397 | # config/services.yaml 398 | services: 399 | myCompany.form.flow.createVehicle: 400 | autoconfigure: false 401 | calls: 402 | - [ setDataManager, [ '@myCompany.form.flow.storage.doctrine_storage'] ] 403 | - [ setFormFactory, [ '@form.factory' ] ] 404 | - [ setRequestStack, [ '@request_stack' ] ] 405 | - [ setEventDispatcher, [ '@?event_dispatcher' ] ] 406 | ``` 407 | 408 | # Explanations 409 | 410 | ## How the flow works 411 | 412 | 1. Dispatch `PreBindEvent`. 413 | 1. Dispatch `GetStepsEvent`. 414 | 1. Update the form data class with previously saved data of all steps. For each one, dispatch `PostBindSavedDataEvent`. 415 | 1. Evaluate which steps are skipped. Determine the current step. 416 | 1. Dispatch `PostBindFlowEvent`. 417 | 1. Create the form for the current step. 418 | 1. Bind the request to that form. 419 | 1. Dispatch `PostBindRequestEvent`. 420 | 1. Validate the form data. 421 | 1. Dispatch `PostValidateEvent`. 422 | 1. Save the form data. 423 | 1. Proceed to the next step. 424 | 425 | ## Method `loadStepsConfig` 426 | 427 | The array returned by that method is used to create all steps of the flow. 428 | The first item will be the first step. You can, however, explicitly index the array for easier readability. 429 | 430 | Valid options per step are: 431 | - `label` (`string`|`StepLabel`|`null`) 432 | - If you'd like to render an overview of all steps you have to set the `label` option for each step. 433 | - If using a callable on a `StepLabel` instance, it has to return a string value or `null`. 434 | - By default, the labels will be translated using the `messages` domain when rendered in Twig. 435 | - `form_type` (`FormTypeInterface`|`string`|`null`) 436 | - The form type used to build the form for that step. 437 | - This value is passed to Symfony's form factory, thus the same rules apply as for creating common forms. If using a string, it has to be the FQCN of the form type. 438 | - `form_options` (`array`) 439 | - Options passed to the form type of that step. 440 | - `skip` (`callable`|`bool`) 441 | - Decides whether the step will be skipped. 442 | - If using a callable... 443 | - it will receive the estimated current step number and the flow as arguments; 444 | - it has to return a boolean value; 445 | - it might be called more than once until the actual current step number has been determined. 446 | 447 | ### Examples 448 | 449 | ```php 450 | protected function loadStepsConfig() { 451 | return [ 452 | [ 453 | 'form_type' => CreateVehicleStep1Form::class, 454 | ], 455 | [ 456 | 'form_type' => CreateVehicleStep2Form::class, 457 | 'skip' => true, 458 | ], 459 | [ 460 | ], 461 | ]; 462 | } 463 | ``` 464 | 465 | ```php 466 | protected function loadStepsConfig() { 467 | return [ 468 | 1 =>[ 469 | 'label' => 'wheels', 470 | 'form_type' => CreateVehicleStep1Form::class, 471 | ], 472 | 2 => [ 473 | 'label' => StepLabel::createCallableLabel(function() { return 'engine'; }) 474 | 'form_type' => CreateVehicleStep2Form::class, 475 | 'form_options' => [ 476 | 'validation_groups' => ['Default'], 477 | ], 478 | 'skip' => function($estimatedCurrentStepNumber, FormFlowInterface $flow) { 479 | return $estimatedCurrentStepNumber > 1 && !$flow->getFormData()->canHaveEngine(); 480 | }, 481 | ], 482 | 3 => [ 483 | 'label' => 'confirmation', 484 | ], 485 | ]; 486 | } 487 | ``` 488 | 489 | # Advanced stuff 490 | 491 | ## Validation groups 492 | 493 | To validate the form data class bound to the flow, a step-based validation group is passed to the form type. 494 | By default, if the flow's `getName` method returns `createVehicle`, such a group is named `flow_createVehicle_step1` 495 | for the first step. You can customize this name by setting the flow's property `validationGroupPrefix` explicitly. 496 | The step number (1, 2, 3, etc.) will be appended by the flow. 497 | 498 | Compared to standalone forms, setting the `validation_groups` option in your form type's `configureOptions` 499 | method won't have any effect in the context of a flow. The value is just ignored, i.e. will be overwritten by the flow. 500 | But there are other ways of defining custom validation groups: 501 | 502 | - override the flow's `getFormOptions` method, 503 | - use the `form_options` step option, or 504 | - use the flow's `setGenericFormOptions` method. 505 | 506 | The generated step-based validation group will be added by the flow, unless the `validation_groups` option is set to `false`, a closure, or a GroupSequence. 507 | In this case, it will **not** be added by the flow, so ensure the step forms are validated as expected. 508 | 509 | ## Disabling revalidation of previous steps 510 | 511 | Take a look at [#98](https://github.com/craue/CraueFormFlowBundle/issues/98) for an example on why it's useful to 512 | revalidate previous steps by default. But if you want (or need) to avoid revalidating previous steps, add this to your flow class: 513 | 514 | ```php 515 | // in src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 516 | class CreateVehicleFlow extends FormFlow { 517 | 518 | protected $revalidatePreviousSteps = false; 519 | 520 | // ... 521 | 522 | } 523 | ``` 524 | 525 | ## Passing generic options to the form type 526 | 527 | To set options common for the form type(s) of all steps you can use method `setGenericFormOptions`: 528 | 529 | ```php 530 | // in src/MyCompany/MyBundle/Controller/VehicleController.php 531 | public function createVehicleAction() { 532 | // ... 533 | $flow->setGenericFormOptions(['action' => 'targetUrl']); 534 | $flow->bind($formData); 535 | $form = $flow->createForm(); 536 | // ... 537 | } 538 | ``` 539 | 540 | ## Passing step-based options to the form type 541 | 542 | To pass individual options to each step's form type you can use the step config option `form_options`: 543 | 544 | ```php 545 | // in src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 546 | protected function loadStepsConfig() { 547 | return [ 548 | [ 549 | 'label' => 'wheels', 550 | 'form_type' => CreateVehicleStep1Form:class, 551 | 'form_options' => [ 552 | 'validation_groups' => ['Default'], 553 | ], 554 | ], 555 | ]; 556 | } 557 | ``` 558 | 559 | Alternatively, to set options based on previous steps (e.g. to render fields depending on submitted data) you can override method 560 | `getFormOptions` of your flow class: 561 | 562 | ```php 563 | // in src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 564 | public function getFormOptions($step, array $options = []) { 565 | $options = parent::getFormOptions($step, $options); 566 | 567 | $formData = $this->getFormData(); 568 | 569 | if ($step === 2) { 570 | $options['numberOfWheels'] = $formData->getNumberOfWheels(); 571 | } 572 | 573 | return $options; 574 | } 575 | ``` 576 | 577 | ## Enabling dynamic step navigation 578 | 579 | Dynamic step navigation means that the step list rendered will contain links to go back/forth to a specific step 580 | (which has been done already) directly. 581 | To enable it, add this to your flow class: 582 | 583 | ```php 584 | // in src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 585 | class CreateVehicleFlow extends FormFlow { 586 | 587 | protected $allowDynamicStepNavigation = true; 588 | 589 | // ... 590 | 591 | } 592 | ``` 593 | 594 | If you'd like to remove the parameters (added by using such a direct link) when submitting the form 595 | you should modify the action for the opening form tag in the template like this: 596 | 597 | ```twig 598 | {{ form_start(form, {'action': path(app.request.attributes.get('_route'), 599 | app.request.query.all | craue_removeDynamicStepNavigationParameters(flow))}) }} 600 | ``` 601 | 602 | ## Handling of file uploads 603 | 604 | File uploads are transparently handled by Base64-encoding the content and storing it in the session, so it may affect performance. 605 | This feature is enabled by default for convenience, but can be disabled in the flow class as follows: 606 | 607 | ```php 608 | // in src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 609 | class CreateVehicleFlow extends FormFlow { 610 | 611 | protected $handleFileUploads = false; 612 | 613 | // ... 614 | 615 | } 616 | ``` 617 | 618 | By default, the system's directory for temporary files will be used for files restored from the session while loading step data. 619 | You can set a custom one: 620 | 621 | ```php 622 | // in src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 623 | class CreateVehicleFlow extends FormFlow { 624 | 625 | protected $handleFileUploadsTempDir = '/path/for/flow/uploads'; 626 | 627 | // ... 628 | 629 | } 630 | ``` 631 | 632 | ## Enabling redirect after submit 633 | 634 | This feature will allow performing a redirect after submitting a step to load the page containing the next step using a GET request. 635 | To enable it, add this to your flow class: 636 | 637 | ```php 638 | // in src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 639 | class CreateVehicleFlow extends FormFlow { 640 | 641 | protected $allowRedirectAfterSubmit = true; 642 | 643 | // ... 644 | 645 | } 646 | ``` 647 | 648 | But you still have to perform the redirect yourself, so update your action like this: 649 | 650 | ```php 651 | // in src/MyCompany/MyBundle/Controller/VehicleController.php 652 | public function createVehicleAction() { 653 | // ... 654 | $flow->bind($formData); 655 | $form = $submittedForm = $flow->createForm(); 656 | if ($flow->isValid($submittedForm)) { 657 | $flow->saveCurrentStepData($submittedForm); 658 | // ... 659 | } 660 | 661 | if ($flow->redirectAfterSubmit($submittedForm)) { 662 | $request = $this->getRequest(); 663 | $params = $this->get('craue_formflow_util')->addRouteParameters(array_merge($request->query->all(), 664 | $request->attributes->get('_route_params')), $flow); 665 | 666 | return $this->redirectToRoute($request->attributes->get('_route'), $params); 667 | } 668 | 669 | // ... 670 | // return ... 671 | } 672 | ``` 673 | 674 | ## Using events 675 | 676 | There are some events which you can subscribe to. Using all of them right inside your flow class could look like this: 677 | 678 | ```php 679 | // in src/MyCompany/MyBundle/Form/CreateVehicleFlow.php 680 | use Craue\FormFlowBundle\Event\GetStepsEvent; 681 | use Craue\FormFlowBundle\Event\PostBindFlowEvent; 682 | use Craue\FormFlowBundle\Event\PostBindRequestEvent; 683 | use Craue\FormFlowBundle\Event\PostBindSavedDataEvent; 684 | use Craue\FormFlowBundle\Event\PostValidateEvent; 685 | use Craue\FormFlowBundle\Event\PreBindEvent; 686 | use Craue\FormFlowBundle\Form\FormFlowEvents; 687 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 688 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 689 | 690 | class CreateVehicleFlow extends FormFlow implements EventSubscriberInterface { 691 | 692 | /** 693 | * This method is only needed when _not_ using autoconfiguration. If it's there even with autoconfiguration enabled, 694 | * the `removeSubscriber` call ensures that subscribed events won't occur twice. 695 | * (You can remove the `removeSubscriber` call if you'll definitely never use autoconfiguration for that flow.) 696 | */ 697 | public function setEventDispatcher(EventDispatcherInterface $dispatcher) { 698 | parent::setEventDispatcher($dispatcher); 699 | $dispatcher->removeSubscriber($this); 700 | $dispatcher->addSubscriber($this); 701 | } 702 | 703 | public static function getSubscribedEvents() { 704 | return [ 705 | FormFlowEvents::PRE_BIND => 'onPreBind', 706 | FormFlowEvents::GET_STEPS => 'onGetSteps', 707 | FormFlowEvents::POST_BIND_SAVED_DATA => 'onPostBindSavedData', 708 | FormFlowEvents::POST_BIND_FLOW => 'onPostBindFlow', 709 | FormFlowEvents::POST_BIND_REQUEST => 'onPostBindRequest', 710 | FormFlowEvents::POST_VALIDATE => 'onPostValidate', 711 | ]; 712 | } 713 | 714 | public function onPreBind(PreBindEvent $event) { 715 | // ... 716 | } 717 | 718 | public function onGetSteps(GetStepsEvent $event) { 719 | // ... 720 | } 721 | 722 | public function onPostBindSavedData(PostBindSavedDataEvent $event) { 723 | // ... 724 | } 725 | 726 | public function onPostBindFlow(PostBindFlowEvent $event) { 727 | // ... 728 | } 729 | 730 | public function onPostBindRequest(PostBindRequestEvent $event) { 731 | // ... 732 | } 733 | 734 | public function onPostValidate(PostValidateEvent $event) { 735 | // ... 736 | } 737 | 738 | // ... 739 | 740 | } 741 | ``` 742 | -------------------------------------------------------------------------------- /Form/FormFlow.php: -------------------------------------------------------------------------------- 1 | 28 | * @author Marcus Stöhr 29 | * @author Toni Uebernickel 30 | * @copyright 2011-2025 Christian Raue 31 | * @license http://opensource.org/licenses/mit-license.php MIT License 32 | */ 33 | abstract class FormFlow implements FormFlowInterface { 34 | 35 | const TRANSITION_BACK = 'back'; 36 | const TRANSITION_RESET = 'reset'; 37 | 38 | /** 39 | * @var FormFactoryInterface 40 | */ 41 | protected $formFactory; 42 | 43 | /** 44 | * @var DataManagerInterface 45 | */ 46 | protected $dataManager; 47 | 48 | /** 49 | * @var EventDispatcherInterface|null 50 | */ 51 | protected $eventDispatcher = null; 52 | 53 | /** 54 | * @var string|null 55 | */ 56 | protected $transition; 57 | 58 | /** 59 | * @var bool 60 | */ 61 | protected $revalidatePreviousSteps = true; 62 | 63 | /** 64 | * @var bool 65 | */ 66 | protected $allowDynamicStepNavigation = false; 67 | 68 | /** 69 | * @var bool If file uploads should be handled by serializing them into the storage. 70 | */ 71 | protected $handleFileUploads = true; 72 | 73 | /** 74 | * @var string|null Directory for storing temporary files while handling uploads. If null, the system's default will be used. 75 | */ 76 | protected $handleFileUploadsTempDir = null; 77 | 78 | /** 79 | * @var bool 80 | */ 81 | protected $allowRedirectAfterSubmit = false; 82 | 83 | /** 84 | * @var string 85 | */ 86 | protected $dynamicStepNavigationInstanceParameter = 'instance'; 87 | 88 | /** 89 | * @var string 90 | */ 91 | protected $dynamicStepNavigationStepParameter = 'step'; 92 | 93 | /** 94 | * @var RequestStack 95 | */ 96 | private $requestStack; 97 | 98 | /** 99 | * @var string|null Is only null if not yet initialized. 100 | */ 101 | private $id = null; 102 | 103 | /** 104 | * @var string|null Is only null if not yet initialized. 105 | */ 106 | private $instanceKey = null; 107 | 108 | /** 109 | * @var string|null Is only null if not yet initialized. 110 | */ 111 | private $instanceId = null; 112 | 113 | /** 114 | * @var string|null Is only null if not yet initialized. 115 | */ 116 | private $formStepKey = null; 117 | 118 | /** 119 | * @var string|null Is only null if not yet initialized. 120 | */ 121 | private $formTransitionKey = null; 122 | 123 | /** 124 | * @var string|null Is only null if not yet initialized. 125 | */ 126 | private $validationGroupPrefix = null; 127 | 128 | /** 129 | * @var StepInterface[]|null Is only null if not yet initialized. 130 | */ 131 | private $steps = null; 132 | 133 | /** 134 | * @var int|null Is only null if not yet initialized. 135 | */ 136 | private $stepCount = null; 137 | 138 | /** 139 | * @var string[]|null Is only null if not yet initialized. 140 | */ 141 | private $stepLabels = null; 142 | 143 | /** 144 | * @var mixed|null Is only null if not yet initialized. 145 | */ 146 | private $formData = null; 147 | 148 | /** 149 | * @var int|null Is only null if not yet initialized. 150 | */ 151 | private $currentStepNumber = null; 152 | 153 | /** 154 | * @var FormInterface[] 155 | */ 156 | private $stepForms = []; 157 | 158 | /** 159 | * Options applied to forms of all steps. 160 | * @var array 161 | */ 162 | private $genericFormOptions = []; 163 | 164 | /** 165 | * Flow was determined to be expired. 166 | * @var bool 167 | */ 168 | private $expired = false; 169 | 170 | /** 171 | * {@inheritDoc} 172 | */ 173 | public function setFormFactory(FormFactoryInterface $formFactory) { 174 | $this->formFactory = $formFactory; 175 | } 176 | 177 | /** 178 | * {@inheritDoc} 179 | */ 180 | public function setRequestStack(RequestStack $requestStack) { 181 | $this->requestStack = $requestStack; 182 | } 183 | 184 | /** 185 | * @return Request 186 | * @throws \RuntimeException If the request is not available. 187 | */ 188 | public function getRequest() { 189 | $currentRequest = $this->requestStack->getCurrentRequest(); 190 | 191 | if ($currentRequest === null) { 192 | throw new \RuntimeException('The request is not available.'); 193 | } 194 | 195 | return $currentRequest; 196 | } 197 | 198 | /** 199 | * {@inheritDoc} 200 | */ 201 | public function setDataManager(DataManagerInterface $dataManager) { 202 | $this->dataManager = $dataManager; 203 | } 204 | 205 | /** 206 | * {@inheritDoc} 207 | */ 208 | public function getDataManager() { 209 | return $this->dataManager; 210 | } 211 | 212 | /** 213 | * {@inheritDoc} 214 | */ 215 | public function setEventDispatcher(EventDispatcherInterface $eventDispatcher) { 216 | $this->eventDispatcher = $eventDispatcher; 217 | } 218 | 219 | public function setId($id) { 220 | $this->id = $id; 221 | } 222 | 223 | /** 224 | * {@inheritDoc} 225 | */ 226 | public function getId() { 227 | if ($this->id === null) { 228 | $this->id = 'flow_' . $this->getName(); 229 | } 230 | 231 | return $this->id; 232 | } 233 | 234 | /** 235 | * {@inheritDoc} 236 | */ 237 | public function getName() { 238 | return StringUtil::fqcnToFlowName(get_class($this)); 239 | } 240 | 241 | public function setInstanceKey($instanceKey) { 242 | $this->instanceKey = $instanceKey; 243 | } 244 | 245 | public function getInstanceKey() { 246 | if ($this->instanceKey === null) { 247 | $this->instanceKey = $this->getId() . '_instance'; 248 | } 249 | 250 | return $this->instanceKey; 251 | } 252 | 253 | public function setInstanceId($instanceId) { 254 | $this->instanceId = $instanceId; 255 | } 256 | 257 | /** 258 | * {@inheritDoc} 259 | */ 260 | public function getInstanceId() { 261 | if ($this->instanceId === null) { 262 | $this->instanceId = $this->getId(); 263 | } 264 | 265 | return $this->instanceId; 266 | } 267 | 268 | public function setFormStepKey($formStepKey) { 269 | $this->formStepKey = $formStepKey; 270 | } 271 | 272 | public function getFormStepKey() { 273 | if ($this->formStepKey === null) { 274 | $this->formStepKey = $this->getId() . '_step'; 275 | } 276 | 277 | return $this->formStepKey; 278 | } 279 | 280 | public function setFormTransitionKey($formTransitionKey) { 281 | $this->formTransitionKey = $formTransitionKey; 282 | } 283 | 284 | public function getFormTransitionKey() { 285 | if ($this->formTransitionKey === null) { 286 | $this->formTransitionKey = $this->getId() . '_transition'; 287 | } 288 | 289 | return $this->formTransitionKey; 290 | } 291 | 292 | public function setValidationGroupPrefix($validationGroupPrefix) { 293 | $this->validationGroupPrefix = $validationGroupPrefix; 294 | } 295 | 296 | public function getValidationGroupPrefix() { 297 | if ($this->validationGroupPrefix === null) { 298 | $this->validationGroupPrefix = $this->getId() . '_step'; 299 | } 300 | 301 | return $this->validationGroupPrefix; 302 | } 303 | 304 | /** 305 | * {@inheritDoc} 306 | */ 307 | public function getStepCount() { 308 | if ($this->stepCount === null) { 309 | $this->stepCount = count($this->getSteps()); 310 | } 311 | 312 | return $this->stepCount; 313 | } 314 | 315 | /** 316 | * {@inheritDoc} 317 | */ 318 | public function getFormData() { 319 | if ($this->formData === null) { 320 | throw new \RuntimeException('Form data has not been evaluated yet and thus cannot be accessed.'); 321 | } 322 | 323 | return $this->formData; 324 | } 325 | 326 | /** 327 | * {@inheritDoc} 328 | */ 329 | public function getCurrentStepNumber() { 330 | if ($this->currentStepNumber === null) { 331 | throw new \RuntimeException('The current step has not been determined yet and thus cannot be accessed.'); 332 | } 333 | 334 | return $this->currentStepNumber; 335 | } 336 | 337 | public function setRevalidatePreviousSteps($revalidatePreviousSteps) { 338 | $this->revalidatePreviousSteps = (bool) $revalidatePreviousSteps; 339 | } 340 | 341 | /** 342 | * {@inheritDoc} 343 | */ 344 | public function isRevalidatePreviousSteps() { 345 | return $this->revalidatePreviousSteps; 346 | } 347 | 348 | public function setAllowDynamicStepNavigation($allowDynamicStepNavigation) { 349 | $this->allowDynamicStepNavigation = (bool) $allowDynamicStepNavigation; 350 | } 351 | 352 | /** 353 | * {@inheritDoc} 354 | */ 355 | public function isAllowDynamicStepNavigation() { 356 | return $this->allowDynamicStepNavigation; 357 | } 358 | 359 | public function setHandleFileUploads($handleFileUploads) { 360 | $this->handleFileUploads = (bool) $handleFileUploads; 361 | } 362 | 363 | /** 364 | * {@inheritDoc} 365 | */ 366 | public function isHandleFileUploads() { 367 | return $this->handleFileUploads; 368 | } 369 | 370 | public function setHandleFileUploadsTempDir($handleFileUploadsTempDir) { 371 | $this->handleFileUploadsTempDir = $handleFileUploadsTempDir !== null ? (string) $handleFileUploadsTempDir : null; 372 | } 373 | 374 | /** 375 | * {@inheritDoc} 376 | */ 377 | public function getHandleFileUploadsTempDir() { 378 | return $this->handleFileUploadsTempDir; 379 | } 380 | 381 | public function setAllowRedirectAfterSubmit($allowRedirectAfterSubmit) { 382 | $this->allowRedirectAfterSubmit = (bool) $allowRedirectAfterSubmit; 383 | } 384 | 385 | /** 386 | * {@inheritDoc} 387 | */ 388 | public function isAllowRedirectAfterSubmit() { 389 | return $this->allowRedirectAfterSubmit; 390 | } 391 | 392 | public function setDynamicStepNavigationInstanceParameter($dynamicStepNavigationInstanceParameter) { 393 | $this->dynamicStepNavigationInstanceParameter = $dynamicStepNavigationInstanceParameter; 394 | } 395 | 396 | public function getDynamicStepNavigationInstanceParameter() { 397 | return $this->dynamicStepNavigationInstanceParameter; 398 | } 399 | 400 | public function setDynamicStepNavigationStepParameter($dynamicStepNavigationStepParameter) { 401 | $this->dynamicStepNavigationStepParameter = $dynamicStepNavigationStepParameter; 402 | } 403 | 404 | public function getDynamicStepNavigationStepParameter() { 405 | return $this->dynamicStepNavigationStepParameter; 406 | } 407 | 408 | public function setGenericFormOptions(array $genericFormOptions) { 409 | $this->genericFormOptions = $genericFormOptions; 410 | } 411 | 412 | public function getGenericFormOptions() { 413 | return $this->genericFormOptions; 414 | } 415 | 416 | /** 417 | * {@inheritDoc} 418 | */ 419 | public function isStepSkipped($stepNumber) { 420 | return $this->getStep($stepNumber)->isSkipped(); 421 | } 422 | 423 | /** 424 | * @param int $stepNumber Assumed step to which skipped steps shall be applied to. 425 | * @param int $direction Either 1 (to skip forwards) or -1 (to skip backwards). 426 | * @param int $boundsReached Internal counter to avoid endlessly bouncing back and forth. 427 | * @return int Target step number with skipping applied. 428 | * @throws \InvalidArgumentException If the value of $direction is invalid. 429 | */ 430 | protected function applySkipping($stepNumber, $direction = 1, $boundsReached = 0) { 431 | if ($direction !== 1 && $direction !== -1) { 432 | throw new \InvalidArgumentException(sprintf('Argument of either -1 or 1 expected, "%s" given.', $direction)); 433 | } 434 | 435 | $stepNumber = $this->ensureStepNumberRange($stepNumber); 436 | 437 | if ($this->isStepSkipped($stepNumber)) { 438 | $stepNumber += $direction; 439 | 440 | // change direction if outer bounds are reached 441 | if ($direction === 1 && $stepNumber > $this->getStepCount()) { 442 | $direction = -1; 443 | ++$boundsReached; 444 | } elseif ($direction === -1 && $stepNumber < 1) { 445 | $direction = 1; 446 | ++$boundsReached; 447 | } 448 | 449 | if ($boundsReached > 2) { 450 | throw new AllStepsSkippedException(); 451 | } 452 | 453 | return $this->applySkipping($stepNumber, $direction, $boundsReached); 454 | } 455 | 456 | return $stepNumber; 457 | } 458 | 459 | /** 460 | * {@inheritDoc} 461 | */ 462 | public function reset() { 463 | $this->dataManager->drop($this); 464 | $this->currentStepNumber = $this->getFirstStepNumber(); 465 | 466 | // re-evaluate to not keep steps marked as skipped when resetting 467 | foreach ($this->getSteps() as $step) { 468 | $step->evaluateSkipping($this->currentStepNumber, $this); 469 | } 470 | } 471 | 472 | /** 473 | * {@inheritDoc} 474 | */ 475 | public function getFirstStepNumber() { 476 | return $this->applySkipping(1); 477 | } 478 | 479 | /** 480 | * {@inheritDoc} 481 | */ 482 | public function getLastStepNumber() { 483 | return $this->applySkipping($this->getStepCount(), -1); 484 | } 485 | 486 | /** 487 | * {@inheritDoc} 488 | */ 489 | public function nextStep() { 490 | $currentStepNumber = $this->currentStepNumber + 1; 491 | 492 | foreach ($this->getSteps() as $step) { 493 | $step->evaluateSkipping($currentStepNumber, $this); 494 | } 495 | 496 | // There is no "next" step as the target step exceeds the actual step count. 497 | if ($currentStepNumber > $this->getLastStepNumber()) { 498 | return false; 499 | } 500 | 501 | $currentStepNumber = $this->applySkipping($currentStepNumber); 502 | 503 | if ($currentStepNumber <= $this->getStepCount()) { 504 | $this->currentStepNumber = $currentStepNumber; 505 | 506 | return true; 507 | } 508 | 509 | return false; // should never be reached, but just in case 510 | } 511 | 512 | /** 513 | * {@inheritDoc} 514 | */ 515 | public function isStepDone($stepNumber) { 516 | if ($this->isStepSkipped($stepNumber)) { 517 | return true; 518 | } 519 | 520 | return array_key_exists($stepNumber, $this->retrieveStepData()); 521 | } 522 | 523 | public function getRequestedTransition() { 524 | if (!is_string($this->transition) || $this->transition === '') { 525 | $this->transition = strtolower($this->getRequest()->request->get($this->getFormTransitionKey(), '')); 526 | } 527 | 528 | return $this->transition; 529 | } 530 | 531 | protected function getRequestedStepNumber() { 532 | $defaultStepNumber = 1; 533 | 534 | $request = $this->getRequest(); 535 | 536 | switch ($request->getMethod()) { 537 | case 'PUT': 538 | case 'POST': 539 | return intval($request->request->get($this->getFormStepKey(), $defaultStepNumber)); 540 | case 'GET': 541 | return $this->allowDynamicStepNavigation || $this->allowRedirectAfterSubmit ? 542 | intval($request->get($this->dynamicStepNavigationStepParameter, $defaultStepNumber)) : 543 | $defaultStepNumber; 544 | } 545 | 546 | return $defaultStepNumber; 547 | } 548 | 549 | /** 550 | * Finds out which step is the current one. 551 | * @return int 552 | */ 553 | protected function determineCurrentStepNumber() { 554 | $requestedStepNumber = $this->getRequestedStepNumber(); 555 | 556 | if ($this->getRequestedTransition() === self::TRANSITION_BACK) { 557 | --$requestedStepNumber; 558 | } 559 | 560 | $requestedStepNumber = $this->ensureStepNumberRange($requestedStepNumber); 561 | $requestedStepNumber = $this->refineCurrentStepNumber($requestedStepNumber); 562 | 563 | if ($this->getRequestedTransition() === self::TRANSITION_BACK) { 564 | $requestedStepNumber = $this->applySkipping($requestedStepNumber, -1); 565 | 566 | // re-evaluate to not keep following steps marked as skipped (after skipping them while going back) 567 | foreach ($this->getSteps() as $step) { 568 | $step->evaluateSkipping($requestedStepNumber, $this); 569 | } 570 | } else { 571 | $requestedStepNumber = $this->applySkipping($requestedStepNumber); 572 | } 573 | 574 | return $requestedStepNumber; 575 | } 576 | 577 | /** 578 | * Ensures that the step number is within the range of defined steps to avoid a possible OutOfBoundsException. 579 | * @param int $stepNumber 580 | * @return int 581 | */ 582 | private function ensureStepNumberRange($stepNumber) { 583 | return max(min($stepNumber, $this->getStepCount()), 1); 584 | } 585 | 586 | /** 587 | * Refines the current step number by evaluating and considering skipped steps. 588 | * @param int $refinedStepNumber 589 | * @return int 590 | */ 591 | protected function refineCurrentStepNumber($refinedStepNumber) { 592 | foreach ($this->getSteps() as $step) { 593 | $step->evaluateSkipping($refinedStepNumber, $this); 594 | } 595 | 596 | return $refinedStepNumber; 597 | } 598 | 599 | /** 600 | * {@inheritDoc} 601 | */ 602 | public function bind($formData) { 603 | $this->setInstanceId($this->determineInstanceId()); 604 | 605 | if ($this->hasListeners(FormFlowEvents::PRE_BIND)) { 606 | $this->dispatchEvent(new PreBindEvent($this), FormFlowEvents::PRE_BIND); 607 | } 608 | 609 | $this->formData = $formData; 610 | 611 | $this->bindFlow(); 612 | 613 | if ($this->hasListeners(FormFlowEvents::POST_BIND_FLOW)) { 614 | $this->dispatchEvent(new PostBindFlowEvent($this, $this->formData), FormFlowEvents::POST_BIND_FLOW); 615 | } 616 | 617 | if (!$this->dataManager->exists($this)) { 618 | // initialize storage slot 619 | $this->dataManager->save($this, []); 620 | } 621 | } 622 | 623 | protected function determineInstanceId() { 624 | $request = $this->getRequest(); 625 | $instanceId = null; 626 | 627 | if ($this->allowDynamicStepNavigation || $this->allowRedirectAfterSubmit) { 628 | $instanceId = $request->get($this->getDynamicStepNavigationInstanceParameter()); 629 | } 630 | 631 | if ($instanceId === null) { 632 | $instanceId = $request->request->get($this->getInstanceKey()); 633 | } 634 | 635 | $instanceIdLength = 10; 636 | if ($instanceId === null || !StringUtil::isRandomString($instanceId, $instanceIdLength)) { 637 | $instanceId = StringUtil::generateRandomString($instanceIdLength); 638 | } 639 | 640 | return $instanceId; 641 | } 642 | 643 | protected function bindFlow() { 644 | $request = $this->getRequest(); 645 | $reset = false; 646 | 647 | if (!$this->allowDynamicStepNavigation && !$this->allowRedirectAfterSubmit && $request->isMethod('GET')) { 648 | $reset = true; 649 | } 650 | 651 | if ($this->getRequestedTransition() === self::TRANSITION_RESET) { 652 | $reset = true; 653 | } 654 | 655 | if (in_array($request->getMethod(), ['POST', 'PUT'], true) && $request->get($this->getFormStepKey()) !== null && !$this->dataManager->exists($this)) { 656 | // flow is expired, drop posted data and reset 657 | $request->request->replace(); 658 | $reset = true; 659 | $this->expired = true; 660 | 661 | // Regenerate instance ID so resubmits of the form will continue to give error. Otherwise, submitting 662 | // the new form, then backing up to the old form won't give the error. 663 | $this->setInstanceId($this->determineInstanceId()); 664 | } 665 | 666 | if (!$reset) { 667 | $this->applyDataFromSavedSteps(); 668 | } 669 | 670 | $requestedStepNumber = $this->determineCurrentStepNumber(); 671 | 672 | if ($reset) { 673 | $this->reset(); 674 | return; 675 | } 676 | 677 | // ensure that the requested step fits the current progress 678 | if ($requestedStepNumber > $this->getFirstStepNumber()) { 679 | for ($step = $this->getFirstStepNumber(); $step < $requestedStepNumber; ++$step) { 680 | if (!$this->isStepDone($step)) { 681 | $this->reset(); 682 | return; 683 | } 684 | } 685 | } 686 | 687 | $this->currentStepNumber = $requestedStepNumber; 688 | 689 | if (!$this->allowDynamicStepNavigation && $this->getRequestedTransition() === self::TRANSITION_BACK) { 690 | /* 691 | * Don't invalidate data for the current step to properly show the filled out form for that step after 692 | * pressing "back" and refreshing the page. Otherwise, the form would be blank since the data has already 693 | * been invalidated previously. 694 | */ 695 | $this->invalidateStepData($this->currentStepNumber + 1); 696 | } 697 | } 698 | 699 | /** 700 | * {@inheritDoc} 701 | */ 702 | public function saveCurrentStepData(FormInterface $form) { 703 | $stepData = $this->retrieveStepData(); 704 | 705 | $request = $this->getRequest(); 706 | $formName = $form->getName(); 707 | 708 | $currentStepData = $request->request->all($formName); 709 | 710 | if ($this->handleFileUploads) { 711 | $currentStepData = array_replace_recursive($currentStepData, $request->files->get($formName, [])); 712 | } 713 | 714 | $stepData[$this->getCurrentStepNumber()] = $currentStepData; 715 | 716 | $this->saveStepData($stepData); 717 | } 718 | 719 | /** 720 | * Invalidates data for steps >= $fromStepNumber. 721 | * @param int $fromStepNumber 722 | */ 723 | public function invalidateStepData($fromStepNumber) { 724 | $stepData = $this->retrieveStepData(); 725 | 726 | for ($step = $fromStepNumber, $stepCount = $this->getStepCount(); $step < $stepCount; ++$step) { 727 | unset($stepData[$step]); 728 | } 729 | 730 | $this->saveStepData($stepData); 731 | } 732 | 733 | /** 734 | * Updates form data class with previously saved form data of all steps. 735 | */ 736 | protected function applyDataFromSavedSteps() { 737 | $stepData = $this->retrieveStepData(); 738 | 739 | $this->stepForms = []; 740 | 741 | $options = []; 742 | if (!$this->revalidatePreviousSteps) { 743 | $options['validation_groups'] = false; // disable validation 744 | } 745 | 746 | foreach ($this->getSteps() as $step) { 747 | $stepNumber = $step->getNumber(); 748 | 749 | if (array_key_exists($stepNumber, $stepData)) { 750 | $stepForm = $this->createFormForStep($stepNumber, $options); 751 | $stepForm->submit($stepData[$stepNumber]); // the form is validated here 752 | 753 | if ($this->revalidatePreviousSteps) { 754 | $this->stepForms[$stepNumber] = $stepForm; 755 | } 756 | 757 | if ($this->hasListeners(FormFlowEvents::POST_BIND_SAVED_DATA)) { 758 | $this->dispatchEvent(new PostBindSavedDataEvent($this, $this->formData, $stepNumber), FormFlowEvents::POST_BIND_SAVED_DATA); 759 | } 760 | } 761 | } 762 | } 763 | 764 | /** 765 | * {@inheritDoc} 766 | */ 767 | public function createForm() { 768 | $form = $this->createFormForStep($this->currentStepNumber); 769 | 770 | if ($this->expired && $this->hasListeners(FormFlowEvents::FLOW_EXPIRED)) { 771 | $this->dispatchEvent(new FlowExpiredEvent($this, $form), FormFlowEvents::FLOW_EXPIRED); 772 | } 773 | 774 | return $form; 775 | } 776 | 777 | public function getFormOptions($step, array $options = []) { 778 | // override options in a specific order 779 | $options = array_merge( 780 | $this->getGenericFormOptions(), 781 | $this->getStep($step)->getFormOptions(), 782 | $options 783 | ); 784 | 785 | // add the generated step-based validation group, unless it's explicitly set to false, a closure, or a GroupSequence 786 | if (!array_key_exists('validation_groups', $options)) { 787 | $options['validation_groups'] = [$this->getValidationGroupPrefix() . $step]; 788 | } else { 789 | $vg = $options['validation_groups']; 790 | 791 | if ($vg !== false && !is_a($vg, 'Closure') && !$vg instanceof GroupSequence) { 792 | $options['validation_groups'] = array_merge( 793 | [$this->getValidationGroupPrefix() . $step], 794 | (array) $vg 795 | ); 796 | } 797 | } 798 | 799 | $options['flow_instance'] = $this->getInstanceId(); 800 | $options['flow_instance_key'] = $this->getInstanceKey(); 801 | 802 | $options['flow_step'] = $step; 803 | $options['flow_step_key'] = $this->getFormStepKey(); 804 | 805 | return $options; 806 | } 807 | 808 | /** 809 | * {@inheritDoc} 810 | */ 811 | public function getStep($stepNumber) { 812 | if (!is_int($stepNumber)) { 813 | throw new InvalidTypeException($stepNumber, 'int'); 814 | } 815 | 816 | $steps = $this->getSteps(); 817 | $index = $stepNumber - 1; 818 | 819 | if (array_key_exists($index, $steps)) { 820 | return $steps[$index]; 821 | } 822 | 823 | throw new \OutOfBoundsException(sprintf('The step "%d" does not exist.', $stepNumber)); 824 | } 825 | 826 | /** 827 | * {@inheritDoc} 828 | */ 829 | public function getSteps() { 830 | // The steps have been loaded already. 831 | if ($this->steps !== null) { 832 | return $this->steps; 833 | } 834 | 835 | if ($this->hasListeners(FormFlowEvents::GET_STEPS)) { 836 | $event = new GetStepsEvent($this); 837 | $this->dispatchEvent($event, FormFlowEvents::GET_STEPS); 838 | 839 | // A listener has provided the steps for this flow. 840 | if ($event->isPropagationStopped()) { 841 | $this->steps = $event->getSteps(); 842 | 843 | return $this->steps; 844 | } 845 | } 846 | 847 | // There are either no listeners on the event at all or none created the steps for this flow, so load from configuration. 848 | $this->steps = $this->createStepsFromConfig($this->loadStepsConfig()); 849 | 850 | return $this->steps; 851 | } 852 | 853 | /** 854 | * {@inheritDoc} 855 | */ 856 | public function getStepLabels() { 857 | if ($this->stepLabels === null) { 858 | $stepLabels = []; 859 | 860 | foreach ($this->getSteps() as $step) { 861 | $stepLabels[] = $step->getLabel(); 862 | } 863 | 864 | $this->stepLabels = $stepLabels; 865 | } 866 | 867 | return $this->stepLabels; 868 | } 869 | 870 | /** 871 | * {@inheritDoc} 872 | */ 873 | public function getCurrentStepLabel() { 874 | return $this->getStep($this->currentStepNumber)->getLabel(); 875 | } 876 | 877 | /** 878 | * {@inheritDoc} 879 | */ 880 | public function isValid(FormInterface $form) { 881 | $request = $this->getRequest(); 882 | 883 | if (in_array($request->getMethod(), ['POST', 'PUT'], true) && !in_array($this->getRequestedTransition(), [ 884 | self::TRANSITION_BACK, 885 | self::TRANSITION_RESET, 886 | ], true)) { 887 | $form->handleRequest($request); 888 | 889 | if (!$form->isSubmitted()) { 890 | return false; 891 | } 892 | 893 | if ($this->hasListeners(FormFlowEvents::POST_BIND_REQUEST)) { 894 | $this->dispatchEvent(new PostBindRequestEvent($this, $form->getData(), $this->currentStepNumber), FormFlowEvents::POST_BIND_REQUEST); 895 | } 896 | 897 | if ($this->revalidatePreviousSteps) { 898 | // check if forms of previous steps are still valid 899 | foreach ($this->stepForms as $stepNumber => $stepForm) { 900 | // ignore form of the current step 901 | if ($this->currentStepNumber === $stepNumber) { 902 | break; 903 | } 904 | 905 | // ignore forms of skipped steps 906 | if ($this->isStepSkipped($stepNumber)) { 907 | break; 908 | } 909 | 910 | if (!$stepForm->isValid()) { 911 | if ($this->hasListeners(FormFlowEvents::PREVIOUS_STEP_INVALID)) { 912 | $this->dispatchEvent(new PreviousStepInvalidEvent($this, $form, $stepNumber), FormFlowEvents::PREVIOUS_STEP_INVALID); 913 | } 914 | 915 | return false; 916 | } 917 | } 918 | } 919 | 920 | if ($form->isValid()) { 921 | if ($this->hasListeners(FormFlowEvents::POST_VALIDATE)) { 922 | $this->dispatchEvent(new PostValidateEvent($this, $form->getData()), FormFlowEvents::POST_VALIDATE); 923 | } 924 | 925 | return true; 926 | } 927 | } 928 | 929 | return false; 930 | } 931 | 932 | /** 933 | * @param FormInterface $submittedForm 934 | * @return bool If a redirection should be performed. 935 | */ 936 | public function redirectAfterSubmit(FormInterface $submittedForm) { 937 | if ($this->allowRedirectAfterSubmit && in_array($this->getRequest()->getMethod(), ['POST', 'PUT'], true)) { 938 | switch ($this->getRequestedTransition()) { 939 | case self::TRANSITION_BACK: 940 | case self::TRANSITION_RESET: 941 | return true; 942 | default: 943 | // redirect after submit only if there are no errors for the submitted form 944 | return $submittedForm->isSubmitted() && $submittedForm->isValid(); 945 | } 946 | } 947 | 948 | return false; 949 | } 950 | 951 | /** 952 | * Creates the form for the given step number. 953 | * @param int $stepNumber 954 | * @param array $options 955 | * @return FormInterface 956 | */ 957 | protected function createFormForStep($stepNumber, array $options = []) { 958 | $formType = $this->getStep($stepNumber)->getFormType(); 959 | $options = $this->getFormOptions($stepNumber, $options); 960 | 961 | if ($formType === null) { 962 | $formType = FormType::class; 963 | } 964 | 965 | return $this->formFactory->create($formType, $this->formData, $options); 966 | } 967 | 968 | /** 969 | * Creates all steps from the given configuration. 970 | * @param array $stepsConfig 971 | * @return StepInterface[] Value with index 0 is step 1. 972 | */ 973 | public function createStepsFromConfig(array $stepsConfig) { 974 | $steps = []; 975 | 976 | // fix array indexes not starting at 0 977 | $stepsConfig = array_values($stepsConfig); 978 | 979 | foreach ($stepsConfig as $index => $stepConfig) { 980 | $steps[] = Step::createFromConfig($index + 1, $stepConfig); 981 | } 982 | 983 | return $steps; 984 | } 985 | 986 | /** 987 | * Defines the configuration for all steps of this flow. 988 | * @return array 989 | */ 990 | protected function loadStepsConfig() { 991 | return []; 992 | } 993 | 994 | protected function retrieveStepData() { 995 | return $this->dataManager->load($this); 996 | } 997 | 998 | protected function saveStepData(array $data) { 999 | $this->dataManager->save($this, $data); 1000 | } 1001 | 1002 | /** 1003 | * @param string $eventName 1004 | * @return bool 1005 | */ 1006 | protected function hasListeners($eventName) { 1007 | return $this->eventDispatcher !== null && $this->eventDispatcher->hasListeners($eventName); 1008 | } 1009 | 1010 | /** 1011 | * @param FormFlowEvent $event 1012 | * @param string $eventName 1013 | */ 1014 | private function dispatchEvent($event, $eventName) { 1015 | $this->eventDispatcher->dispatch($event, $eventName); 1016 | } 1017 | 1018 | /** 1019 | * {@inheritDoc} 1020 | */ 1021 | public function getStepsDone() { 1022 | $stepsDone = []; 1023 | 1024 | foreach ($this->getSteps() as $step) { 1025 | if ($this->isStepDone($step->getNumber())) { 1026 | $stepsDone[] = $step; 1027 | } 1028 | } 1029 | 1030 | return $stepsDone; 1031 | } 1032 | 1033 | /** 1034 | * {@inheritDoc} 1035 | */ 1036 | public function getStepsRemaining() { 1037 | $stepsRemaining = []; 1038 | 1039 | foreach ($this->getSteps() as $step) { 1040 | if (!$this->isStepDone($step->getNumber())) { 1041 | $stepsRemaining[] = $step; 1042 | } 1043 | } 1044 | 1045 | return $stepsRemaining; 1046 | } 1047 | 1048 | /** 1049 | * {@inheritDoc} 1050 | */ 1051 | public function getStepsDoneCount() { 1052 | return count($this->getStepsDone()); 1053 | } 1054 | 1055 | /** 1056 | * {@inheritDoc} 1057 | */ 1058 | public function getStepsRemainingCount() { 1059 | return count($this->getStepsRemaining()); 1060 | } 1061 | 1062 | // methods for BC with third-party templates (e.g. MopaBootstrapBundle) 1063 | 1064 | public function getCurrentStep() { 1065 | @trigger_error('Method ' . __METHOD__ . ' is deprecated since CraueFormFlowBundle 2.0. Use method getCurrentStepNumber instead.', E_USER_DEPRECATED); 1066 | return $this->getCurrentStepNumber(); 1067 | } 1068 | 1069 | public function getCurrentStepDescription() { 1070 | @trigger_error('Method ' . __METHOD__ . ' is deprecated since CraueFormFlowBundle 2.0. Use method getCurrentStepLabel instead.', E_USER_DEPRECATED); 1071 | return $this->getCurrentStepLabel(); 1072 | } 1073 | 1074 | public function getMaxSteps() { 1075 | @trigger_error('Method ' . __METHOD__ . ' is deprecated since CraueFormFlowBundle 2.0. Use method getStepCount instead.', E_USER_DEPRECATED); 1076 | return $this->getStepCount(); 1077 | } 1078 | 1079 | public function getStepDescriptions() { 1080 | @trigger_error('Method ' . __METHOD__ . ' is deprecated since CraueFormFlowBundle 2.0. Use method getStepLabels instead.', E_USER_DEPRECATED); 1081 | return $this->getStepLabels(); 1082 | } 1083 | 1084 | public function getFirstStep() { 1085 | @trigger_error('Method ' . __METHOD__ . ' is deprecated since CraueFormFlowBundle 2.0. Use method getFirstStepNumber instead.', E_USER_DEPRECATED); 1086 | return $this->getFirstStepNumber(); 1087 | } 1088 | 1089 | public function getLastStep() { 1090 | @trigger_error('Method ' . __METHOD__ . ' is deprecated since CraueFormFlowBundle 2.0. Use method getLastStepNumber instead.', E_USER_DEPRECATED); 1091 | return $this->getLastStepNumber(); 1092 | } 1093 | 1094 | public function hasSkipStep($stepNumber) { 1095 | @trigger_error('Method ' . __METHOD__ . ' is deprecated since CraueFormFlowBundle 2.0. Use method isStepSkipped instead.', E_USER_DEPRECATED); 1096 | return $this->isStepSkipped($stepNumber); 1097 | } 1098 | 1099 | } 1100 | --------------------------------------------------------------------------------