├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE ├── README.md ├── artisan ├── composer.json ├── phpstan.neon.dist ├── phpunit.xml ├── src └── MultiStepForm.php └── tests ├── Fixtures ├── Invoke.php └── views │ └── form.blade.php ├── TestCase.php └── Unit ├── DataTest.php ├── DeleteTest.php ├── GuardTest.php ├── HooksTest.php ├── StepFlow.php └── ValidationTest.php /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | env: 3 | XDEBUG_MODE: 'coverage' 4 | on: 5 | push: 6 | branches: 7 | - master 8 | paths-ignore: 9 | - 'README.md' 10 | - 'LICENSE' 11 | pull_request: 12 | branches: 13 | - master 14 | paths-ignore: 15 | - 'README.md' 16 | - 'LICENSE' 17 | jobs: 18 | build: 19 | runs-on: ubuntu-24.04 20 | steps: 21 | - uses: actions/checkout@v1 22 | with: 23 | fetch-depth: 1 24 | - name: Cache Composer 25 | uses: actions/cache@v4 26 | with: 27 | path: vendor 28 | key: ${{ runner.OS }}-build-${{ hashFiles('**/composer.lock') }} 29 | - name: Composer Dependencies 30 | run: composer install --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist 31 | - name: Lint 32 | run: composer lint 33 | - name: Unit Tests 34 | run: composer test 35 | - name: Codecov 36 | uses: codecov/codecov-action@v1 37 | with: 38 | token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.phpunit.cache/ 3 | /.phpunit.result.cache 4 | /build/ 5 | /vendor/ 6 | /composer.lock 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Dan Alvidrez 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel MultiStep Forms 2 | 3 | ![](https://github.com/bayareawebpro/laravel-multistep-forms/workflows/ci/badge.svg) 4 | ![](https://codecov.io/gh/bayareawebpro/laravel-multistep-forms/branch/master/graph/badge.svg) 5 | ![](https://img.shields.io/github/v/release/bayareawebpro/laravel-multistep-forms.svg) 6 | ![](https://img.shields.io/packagist/dt/bayareawebpro/laravel-multistep-forms.svg) 7 | ![](https://img.shields.io/badge/License-MIT-success.svg) 8 | 9 | > https://packagist.org/packages/bayareawebpro/laravel-multistep-forms 10 | 11 | Multistep Form Builder is a "[responsable](https://laravel-news.com/laravel-5-5-responsable)" class that can be returned from controllers. 12 | 13 | * Specify a view to use Blade or go headless with JSON for use with Javascript frameworks. 14 | * Configure the rules, messages and supporting data for each step with simple arrays. 15 | * Submit to the same route multiple times to merge each validated request into a namespaced session key. 16 | * Hook into each step **before** or **after** validation to interact with the form or return a response. 17 | 18 | ## Installation 19 | 20 | ```shell script 21 | composer require bayareawebpro/laravel-multistep-forms 22 | ``` 23 | 24 | ### Example Usage 25 | 26 | ```php 27 | 'MultiStep Form' 34 | ]) 35 | 36 | // Namespace the session data. 37 | ->namespaced('my-session-key') 38 | 39 | // Allow backwards navigation via get request. ?form_step=x 40 | ->canNavigateBack(true) 41 | 42 | // Tap invokable Class __invoke(Form $form) 43 | ->tap(new InvokableClass) 44 | 45 | // Before x step validation... 46 | ->beforeStep(1, function (MultiStepForm $form) { 47 | // Maybe return early or redirect? 48 | }) 49 | // Before all step validation... 50 | ->beforeStep('*', function (MultiStepForm $form) { 51 | // Maybe return early or redirect? 52 | }) 53 | 54 | // Validate Step 1 55 | ->addStep(1, [ 56 | 'rules' => ['name' => 'required'], 57 | 'messages' => ['name.required' => 'Your name is required.'], 58 | ]) 59 | 60 | // Validate Step 2 61 | ->addStep(2, [ 62 | 'rules' => ['role' => 'required|string'], 63 | 'data' => ['roles' => fn()=>Role::forSelection()] // Lazy Loaded Closure 64 | ]) 65 | 66 | // Add non-validated step... 67 | ->addStep(3,[ 68 | 'data' => ['message' => "Great Job, Your Done!"] 69 | ]) 70 | 71 | // After step validation... 72 | ->onStep(3, function (MultiStepForm $form) { 73 | // Specific step, logic if needed. 74 | }) 75 | ->onStep('*', function (MultiStepForm $form) { 76 | // All steps, logic if needed. 77 | }) 78 | 79 | // Modify data before saved to session after each step. 80 | ->beforeSave(function(array $data) { 81 | 82 | // Transform non-serializable objects to paths, array data etc... 83 | return $data; 84 | }) 85 | 86 | // Modify data before saved to session after each step. 87 | ->onComplete(function(MultiStepForm $form) { 88 | 89 | // Final submission logic. 90 | }) 91 | ; 92 | ``` 93 | 94 | --- 95 | 96 | ### Make New Instance 97 | 98 | Make a new instance of the builder class with optional view and data array. You 99 | should always set the `namespace` for the form session to avoid conflicts with 100 | other parts of your application that use the session store. 101 | 102 | * `GET` requests will load the form state and data for the saved current step or fallback to step 1. 103 | * `POST`,`PUT`,`PATCH` etc... will validate and process the request for any step and proceed to the next configured step. 104 | * `DELETE` will reset the session state and redirect back (blade), or return a `JsonResponse`. 105 | * Backwards navigation (via get param) can be enabled via the `canNavigateBack` method. 106 | 107 | ```php 108 | 'Setup your account' 114 | ]); 115 | 116 | $form->namespaced('onboarding'); 117 | $form->canNavigateBack(true); 118 | ``` 119 | 120 | --- 121 | 122 | ### Configure Steps 123 | 124 | Define the rules, messages and data for the step. Data will be merged 125 | with any view data defined in the `make` method and be included in the `JsonResponse`. 126 | 127 | ** Use a `Closure` to lazy load data per-key. 128 | 129 | **Use an array**: 130 | 131 | ```php 132 | $form->addStep(2, [ 133 | 'rules' => [ 134 | 'role' => 'required|string' 135 | ], 136 | 'messages' => [ 137 | 'role.required' => 'Your name is required.' 138 | ], 139 | 'data' => [ 140 | 'roles' => fn() => Role::query()..., 141 | ], 142 | ]) 143 | ``` 144 | 145 | **Or use an invokable class** (recommended) 146 | 147 | ```php 148 | use BayAreaWebPro\MultiStepForms\MultiStepForm; 149 | 150 | class ProfileStep 151 | { 152 | public function __construct(private int $step) 153 | { 154 | // 155 | } 156 | 157 | public function __invoke(MultiStepForm $form) 158 | { 159 | $form->addStep($this->step, [ 160 | 'rules' => [ 161 | 'name' => 'required|string' 162 | ], 163 | 'messages' => [ 164 | 'name.required' => 'Your name is required.' 165 | ], 166 | 'data' => [ 167 | 'placeholders' => [ 168 | 'name' => 'Enter your name.' 169 | ] 170 | ], 171 | ]); 172 | } 173 | } 174 | ``` 175 | 176 | ```php 177 | $form->tap(new ProfileStep(1)); 178 | ``` 179 | 180 | --- 181 | 182 | ### BeforeStep / OnStep Hooks 183 | 184 | Define a callback to fired **before** a step has been validated. Step Number or * for all. 185 | 186 | - Use a step integer, or asterisk (*) for all steps. 187 | - You can return a response from these hooks. 188 | 189 | ```php 190 | $form->beforeStep('*', function(MultiStepForm $form){ 191 | // 192 | }); 193 | $form->onStep('*', function(MultiStepForm $form){ 194 | // 195 | }); 196 | $form->onComplete(function(MultiStepForm $form){ 197 | // 198 | }); 199 | ``` 200 | 201 | ### Handle UploadedFiles 202 | 203 | Specify a callback used to transform UploadedFiles into paths. 204 | 205 | ```php 206 | use Illuminate\Http\UploadedFile; 207 | 208 | $form->beforeSave(function(array $data){ 209 | if($data['avatar'] instanceof UploadedFile){ 210 | $data['avatar'] = $data['avatar']->store('avatars'); 211 | } 212 | return $data; 213 | }); 214 | ``` 215 | 216 | ### Reset / Clear Form 217 | 218 | - Ajax: Submit a DELETE request to the form route. 219 | - Blade: Use an additional submit button that passes a boolean (truthy) value. 220 | 221 | ``` 222 | 223 | ``` 224 | 225 | ### JSON Response Schema 226 | 227 | The response returned will have two properties: 228 | 229 | ```json 230 | { 231 | "form": { 232 | "form_step": 1 233 | }, 234 | "data": {} 235 | } 236 | ``` 237 | 238 | ### Public Helper Methods 239 | 240 | 241 | #### stepConfig 242 | Get the current step configuration (default), or pass an integer for a specific step: 243 | ```php 244 | $form->stepConfig(2): Collection 245 | ``` 246 | 247 | #### getValue 248 | Get a field value (session / old input) or fallback: 249 | 250 | ```php 251 | $form->getValue('name', 'John Doe'): mixed 252 | ``` 253 | 254 | #### setValue 255 | Set a field value and store in the session: 256 | ```php 257 | $form->setValue('name', 'Jane Doe'): MultiStepForm 258 | ``` 259 | 260 | #### save 261 | Merge and save key/values array directly to the session (does not fire `beforeSaveCallback`): 262 | 263 | ```php 264 | $form->save(['name' => 'Jane Doe']): MultiStepForm 265 | ``` 266 | 267 | #### reset 268 | 269 | Reset the form state to defaults passing an optional array of data to seed. 270 | 271 | ```php 272 | $form->reset(['name' => 'Jane Doe']): MultiStepForm 273 | ``` 274 | 275 | #### withData 276 | Add additional non-form data to all views and responses: 277 | 278 | ```php 279 | $form->withData(['date' => now()->toDateString()]); 280 | ``` 281 | 282 | #### currentStep 283 | Get the current saved step number: 284 | 285 | ```php 286 | $form->currentStep(): int 287 | ``` 288 | 289 | #### requestedStep 290 | Get the incoming client-requested step number: 291 | 292 | ```php 293 | $form->requestedStep(): int 294 | ``` 295 | 296 | #### isStep 297 | Is the current step the provided step: 298 | 299 | ```php 300 | $form->isStep(3): bool 301 | ``` 302 | 303 | #### prevStepUrl 304 | Get the previous step url. 305 | 306 | ```php 307 | $form->prevStepUrl(): string|null 308 | ``` 309 | 310 | #### lastStep 311 | Get the last step number: 312 | 313 | ```php 314 | $form->lastStep(): int 315 | ``` 316 | 317 | #### isLastStep 318 | Is the current step the last step: 319 | 320 | ```php 321 | $form->isLastStep(): bool 322 | ``` 323 | 324 | #### isPast,isActive,isFuture 325 | 326 | ```php 327 | // Boolean Usage 328 | $form->isPast(2): bool 329 | $form->isActive(2): bool 330 | $form->isFuture(2): bool 331 | 332 | // Usage as HTML Class Helpers 333 | $form->isPast(2, 'truthy-class', 'falsy-class'): string 334 | $form->isActive(2, 'truthy-class', 'falsy-class'): string 335 | $form->isFuture(2, 'truthy-class', 'falsy-class'): string 336 | ``` 337 | 338 | --- 339 | 340 | ### Blade Example 341 | 342 | Data will be injected into the view as well as the form itself allowing you to access the form values and other helper methods. 343 | 344 | ```php 345 | namespaced('onboarding'); 350 | $form->canNavigateBack(true); 351 | ``` 352 | 353 | ```blade 354 |
355 | 356 | @csrf 357 | 360 | Step 1 361 | 362 | 365 | Step 2 366 | 367 | 370 | Step 3 371 | 372 | 373 | 374 | @switch($form->currentStep()) 375 | 376 | @case(1) 377 | 378 | 379 | @error('name') 380 |

{{ $errors->first('name') }}

381 | @enderror 382 | @break 383 | 384 | @case(2) 385 | 386 | 387 | @error('role') 388 |

{{ $errors->first('role') }}

389 | @enderror 390 | @break 391 | 392 | @case(3) 393 |

Review your submission:

394 |

395 | Name: {{ $form->getValue('name') }}
396 | Role: {{ $form->getValue('role') }}
397 |

398 | @break 399 | 400 | @endswitch 401 | 402 | @if($form->isLastStep()) 403 | 404 | 405 | @else 406 | 407 | @endif 408 | 409 |
410 | ``` 411 | 412 | ### Vue Example 413 | 414 | Form state and data will be returned as JSON when no view is 415 | specified or the request prefers JSON. You can combine both 416 | techniques to use Vue within blade as well. 417 | 418 | ```html 419 | 420 | 421 | 518 | 519 | ``` 520 | 521 | #### Example Form Component 522 | 523 | ```vue 524 | 525 | 570 | 575 | ``` 576 | 577 | #### Example Input Component 578 | 579 | ```vue 580 | 596 | 610 | ``` 611 | 612 | #### Example Select Component 613 | 614 | ```vue 615 | 631 | 645 | ``` 646 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ./tests/Unit 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | src/ 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/MultiStepForm.php: -------------------------------------------------------------------------------- 1 | after = new Collection; 36 | $this->before = new Collection; 37 | $this->steps = new Collection; 38 | $this->request = $request; 39 | $this->session = $session; 40 | $this->view = $view; 41 | $this->data = $data; 42 | } 43 | 44 | public static function make(?string $view = null, array $data = []): self 45 | { 46 | return app(static::class, [ 47 | 'view' => $view, 48 | 'data' => $data, 49 | ]); 50 | } 51 | 52 | public function withData(array $data = []): self 53 | { 54 | $this->data = array_merge($this->data, $data); 55 | return $this; 56 | } 57 | 58 | protected function validate(): array 59 | { 60 | $step = $this->stepConfig($this->requestedStep()); 61 | 62 | return $this->request->validate( 63 | array_merge($step->get('rules', []), [ 64 | 'form_step' => ['required', 'numeric', Rule::in(range(1, $this->getNextAccessibleStep()))], 65 | ]), 66 | $step->get('messages', []) 67 | ); 68 | } 69 | 70 | protected function getNextAccessibleStep(): int 71 | { 72 | if ($this->isFuture($nextStep = $this->currentStep() + 1)) { 73 | return $nextStep; 74 | } 75 | return $this->currentStep(); 76 | } 77 | 78 | protected function handleShow(): Response|JsonResponse 79 | { 80 | if ($this->usesViews() && !$this->needsJsonResponse()) { 81 | 82 | return new Response(View::make($this->view, $this->getData([ 83 | 'form' => $this, 84 | ]))); 85 | } 86 | 87 | return $this->getJsonResponse(); 88 | } 89 | 90 | protected function handleDelete(): RedirectResponse|JsonResponse 91 | { 92 | $this->reset(); 93 | 94 | return $this->getModificationResponse(); 95 | } 96 | 97 | protected function handleModification(): mixed 98 | { 99 | $callbackResponse = ( 100 | $this->handleBefore('*') ?? 101 | $this->handleBefore($this->requestedStep()) 102 | ); 103 | 104 | if ($callbackResponse) { 105 | return $callbackResponse; 106 | } 107 | 108 | if ($this->wasReset) { 109 | return $this->getModificationResponse(); 110 | } 111 | 112 | $this->handleSave($this->validate()); 113 | 114 | $afterResponse = ( 115 | $this->handleAfter('*') ?? 116 | $this->handleAfter($this->currentStep()) 117 | ); 118 | 119 | $isLastStep = $this->isLastStep(); 120 | 121 | if (!$isLastStep) { 122 | $this->incrementStep(); 123 | } 124 | 125 | if ($afterResponse) { 126 | return $afterResponse; 127 | } 128 | 129 | if ($isLastStep) { 130 | $completedCallback = $this->handleCompleteCallback(); 131 | 132 | $this->reset(); 133 | 134 | if ($completedCallback) { 135 | return $completedCallback; 136 | } 137 | } 138 | 139 | return $this->getModificationResponse(); 140 | } 141 | 142 | public function toResponse($request = null): mixed 143 | { 144 | $this->request = ($request ?? $this->request); 145 | 146 | $this->setupSession(); 147 | 148 | return match (true) { 149 | $this->isDeleteRequest() => $this->handleDelete(), 150 | $this->isModificationRequest() => $this->handleModification(), 151 | $this->isNavigationRequest() => $this->handleNavigation(), 152 | default => $this->handleShow(), 153 | }; 154 | } 155 | 156 | protected function getData(array $data = []): array 157 | { 158 | return [...$data, ...Collection::make($this->data) 159 | ->merge($this->stepConfig()->get('data', [])) 160 | ->map(fn($value) => is_callable($value) ? call_user_func($value, $this) : $value)]; 161 | } 162 | 163 | protected function isShowRequest(): bool 164 | { 165 | return $this->request->isMethod('GET'); 166 | } 167 | 168 | protected function isModificationRequest(): bool 169 | { 170 | return in_array($this->request->method(), [ 171 | 'POST', 'PUT', 'PATCH' 172 | ]); 173 | } 174 | 175 | protected function getModificationResponse(): RedirectResponse|JsonResponse 176 | { 177 | if ($this->usesViews() && !$this->needsJsonResponse()) { 178 | return Redirect::back(); 179 | } 180 | 181 | return $this->getJsonResponse(); 182 | } 183 | 184 | protected function isDeleteRequest(): bool 185 | { 186 | return $this->request->isMethod('DELETE') || $this->request->boolean('reset'); 187 | } 188 | 189 | protected function getJsonResponse(): JsonResponse 190 | { 191 | return new JsonResponse((object)[ 192 | 'data' => $this->getData(), 193 | 'form' => $this->toCollection(), 194 | ]); 195 | } 196 | 197 | protected function isNavigationRequest(): bool 198 | { 199 | return 200 | $this->isShowRequest() && 201 | $this->request->filled('form_step') && 202 | $this->requestedStep() !== $this->currentStep(); 203 | } 204 | 205 | protected function handleNavigation(): RedirectResponse|JsonResponse 206 | { 207 | if ($this->isPreviousStepRequest()) { 208 | $this->setValue('form_step', $this->requestedStep()); 209 | } 210 | 211 | if ($this->usesViews() && !$this->needsJsonResponse()) { 212 | return Redirect::back(); 213 | } 214 | 215 | return $this->getJsonResponse(); 216 | } 217 | 218 | protected function isPreviousStepRequest(): bool 219 | { 220 | return ( 221 | $this->canGoBack && 222 | $this->isPast($this->requestedStep()) 223 | ); 224 | } 225 | 226 | public function canNavigateBack(bool $enabled = true): self 227 | { 228 | $this->canGoBack = $enabled; 229 | return $this; 230 | } 231 | 232 | protected function needsJsonResponse(): bool 233 | { 234 | return $this->request->isJson() || $this->request->wantsJson() || $this->request->expectsJson() || $this->request->isXmlHttpRequest(); 235 | } 236 | 237 | protected function usesViews(): bool 238 | { 239 | return is_string($this->view); 240 | } 241 | 242 | public function isActive(int $step, $active = true, $fallback = false): mixed 243 | { 244 | return $this->isStep($step) ? $active : $fallback; 245 | } 246 | 247 | public function isFuture(int $step, $active = true, $fallback = false) 248 | { 249 | if ($this->steps->has($step) && $this->currentStep() < $step) { 250 | return $active; 251 | } 252 | return $fallback; 253 | } 254 | 255 | public function isPast(int $step, $active = true, $fallback = false) 256 | { 257 | if ($this->steps->has($step) && $this->currentStep() > $step) { 258 | return $active; 259 | } 260 | return $fallback; 261 | } 262 | 263 | protected function setupSession(): void 264 | { 265 | if (!is_numeric($this->getValue('form_step', false))) { 266 | $this->setValue('form_step', 1); 267 | } 268 | } 269 | 270 | public function namespaced(string $namespace): self 271 | { 272 | $this->namespace = $namespace; 273 | 274 | return $this; 275 | } 276 | 277 | public function tap($closure): self 278 | { 279 | call_user_func($closure, $this); 280 | 281 | return $this; 282 | } 283 | 284 | 285 | public function beforeStep(int|string $step, Closure $closure): self 286 | { 287 | $this->before->put($step, $closure); 288 | 289 | return $this; 290 | } 291 | 292 | public function onStep(int|string $step, Closure $closure): self 293 | { 294 | $this->after->put($step, $closure); 295 | 296 | return $this; 297 | } 298 | 299 | public function addStep(int $step, array $config = []): self 300 | { 301 | $this->steps->put($step, $config); 302 | 303 | return $this; 304 | } 305 | 306 | public function currentStep(): int 307 | { 308 | return (int)$this->session->get("{$this->namespace}.form_step", 1); 309 | } 310 | 311 | public function requestedStep(): int 312 | { 313 | return (int)$this->request->get("form_step", 1); 314 | } 315 | 316 | public function stepConfig(?int $step = null): Collection 317 | { 318 | return Collection::make($this->steps->get($step ?? $this->currentStep())); 319 | } 320 | 321 | public function isStep(int $step = 1): bool 322 | { 323 | return $this->currentStep() === $step; 324 | } 325 | 326 | public function getValue(string $key, $fallback = null): mixed 327 | { 328 | return $this->session->get("{$this->namespace}.$key", $this->session->getOldInput($key, $fallback)); 329 | } 330 | 331 | public function hasValue(string $key): bool 332 | { 333 | return $this->session->has("{$this->namespace}.$key"); 334 | } 335 | 336 | public function setValue(string $key, mixed $value): self 337 | { 338 | $this->session->put("{$this->namespace}.$key", $value); 339 | 340 | return $this; 341 | } 342 | 343 | public function prevStepUrl(): ?string 344 | { 345 | if (!$this->canGoBack || !$this->isPast($prevStep = ($this->currentStep() - 1))) { 346 | return null; 347 | } 348 | return url($this->request->fullUrlWithQuery(['form_step' => $prevStep])); 349 | } 350 | 351 | protected function incrementStep(): self 352 | { 353 | if (!$this->isStep($this->lastStep())) { 354 | $this->setValue('form_step', 1 + $this->requestedStep()); 355 | } 356 | 357 | return $this; 358 | } 359 | 360 | public function lastStep(): int 361 | { 362 | return $this->steps->keys()->filter(fn($value) => is_int($value))->max() ?: 1; 363 | } 364 | 365 | public function isLastStep(): bool 366 | { 367 | return $this->isStep($this->lastStep()); 368 | } 369 | 370 | public function beforeSave(Closure $callback): self 371 | { 372 | $this->beforeSaveCallback = $callback; 373 | 374 | return $this; 375 | } 376 | 377 | protected function handleSave(array $data = []): self 378 | { 379 | if (is_callable($this->beforeSaveCallback)) { 380 | $data = call_user_func($this->beforeSaveCallback, $data); 381 | } 382 | 383 | $this->save($data); 384 | 385 | return $this; 386 | } 387 | 388 | public function save(array $data = []): self 389 | { 390 | $this->session->put($this->namespace, array_merge( 391 | $this->session->get($this->namespace, []), $data 392 | )); 393 | return $this; 394 | } 395 | 396 | public function reset(array $data = []): self 397 | { 398 | $this->session->put($this->namespace, array_merge($data, ['form_step' => 1])); 399 | $this->wasReset = true; 400 | return $this; 401 | } 402 | 403 | protected function handleBefore(int|string $key): mixed 404 | { 405 | if ($callback = $this->before->get($key)) { 406 | return call_user_func($callback, $this); 407 | } 408 | return null; 409 | } 410 | 411 | protected function handleAfter(int|string $key): mixed 412 | { 413 | if ($callback = $this->after->get($key)) { 414 | return call_user_func($callback, $this); 415 | } 416 | return null; 417 | } 418 | 419 | public function onComplete(Closure $callback): self 420 | { 421 | $this->completeCallback = $callback; 422 | return $this; 423 | } 424 | 425 | protected function handleCompleteCallback(): mixed 426 | { 427 | if (is_callable($this->completeCallback)) { 428 | return call_user_func($this->completeCallback, $this); 429 | } 430 | return null; 431 | } 432 | 433 | public function toCollection(): Collection 434 | { 435 | return Collection::make($this->session->get($this->namespace, [])); 436 | } 437 | 438 | public function toArray(): array 439 | { 440 | return $this->toCollection()->toArray(); 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /tests/Fixtures/Invoke.php: -------------------------------------------------------------------------------- 1 | addStep(1); 12 | $form->onStep(1, fn()=>$form->setValue('invoked', true)); 13 | } 14 | } -------------------------------------------------------------------------------- /tests/Fixtures/views/form.blade.php: -------------------------------------------------------------------------------- 1 | 2 | {{ $title ?? null }} 3 | 4 | {{ $description ?? null }} 5 | 6 | {{ $form->prevStepUrl() }} 7 | 8 |
9 | @csrf 10 | 11 | 12 | @switch($form->currentStep()) 13 | @case(1) 14 | 15 | {{ $errors->first('name') }} 16 | @break 17 | @case(2) 18 | 19 | {{ $errors->first('role') }} 20 | @break 21 | @case(3) 22 | Name: {{ $form->getValue('name') }}
23 | Role: {{ $form->getValue('role') }}
24 | @break 25 | @endswitch 26 | 27 | @if($form->isStep(3)) 28 | 29 | 30 | @else 31 | 32 | @endif 33 |
34 | 35 | {{ $form->toCollection() }} 36 |
-------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 'Test' 34 | ])->namespaced('test'); 35 | 36 | call_user_func($closure, $form); 37 | 38 | $this->app->instance(MultiStepForm::class, $form); 39 | 40 | } 41 | 42 | /** 43 | * Define routes setup. 44 | * 45 | * @param \Illuminate\Routing\Router $router 46 | * @return void 47 | */ 48 | protected function defineRoutes($router) 49 | { 50 | $router->any('multi-step-form', function () { 51 | return $this->app->make(MultiStepForm::class); 52 | }) 53 | ->middleware('web') 54 | ->name('test'); 55 | } 56 | 57 | /** 58 | * Setup the test environment. 59 | * @return void 60 | */ 61 | protected function setUp(): void 62 | { 63 | parent::setUp(); 64 | $this->app['config']->set('view.paths', [ 65 | __DIR__.'/Fixtures/views' 66 | ]); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Unit/DataTest.php: -------------------------------------------------------------------------------- 1 | withoutExceptionHandling(); 13 | 14 | $this->setupForm(function (MultiStepForm $form) { 15 | 16 | $form->withData([ 17 | 'title' => 'test:global:data', 18 | 'lazy' => fn() => 'lazy:data', 19 | ]); 20 | 21 | $form->addStep(1, [ 22 | 'data' => [ 23 | 'description' => 'test:step:data', 24 | 'lazyStep' => fn() => 'lazy:data', 25 | ], 26 | ]); 27 | }); 28 | 29 | $this->get(route('test')) 30 | ->assertOk() 31 | ->assertViewIs('form') 32 | ->assertViewHasAll([ 33 | 'title' => 'test:global:data', 34 | 'description' => 'test:step:data', 35 | 'lazy' => 'lazy:data', 36 | 'lazyStep' => 'lazy:data', 37 | 'form', 38 | ]); 39 | 40 | $this->json('GET', route('test')) 41 | ->assertOk() 42 | ->assertJson([ 43 | 'data' => [ 44 | 'title' => 'test:global:data', 45 | 'description' => 'test:step:data', 46 | 'lazy' => 'lazy:data', 47 | ], 48 | 'form' => [ 49 | 'form_step' => 1 50 | ], 51 | ]); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /tests/Unit/DeleteTest.php: -------------------------------------------------------------------------------- 1 | setupForm(function (MultiStepForm $form) { 13 | $form 14 | ->addStep(1) 15 | ->addStep(2) 16 | ->beforeStep(2, function (MultiStepForm $form){ 17 | $form->reset([ 18 | 'name'=> 'Updated', 19 | ]); 20 | }) 21 | ->addStep(3) 22 | ; 23 | }); 24 | 25 | $this->withSession([ 26 | 'test.form_step' => 1, 27 | 'test.name' => 'John', 28 | ]); 29 | 30 | $this 31 | ->post(route('test'), [ 32 | 'form_step' => 2 33 | ], [ 34 | 'HTTP_REFERER' => route('test') 35 | ]) 36 | ->assertRedirect(route('test')) 37 | ->assertSessionHasAll([ 38 | 'test.form_step' => 1, 39 | 'test.name'=> 'Updated', 40 | ]); 41 | } 42 | public function test_delete() 43 | { 44 | $this->setupForm(function (MultiStepForm $form) { 45 | $form 46 | ->addStep(1) 47 | ->addStep(2) 48 | ->addStep(3); 49 | }); 50 | 51 | $this->withSession([ 52 | 'test.form_step' => 3, 53 | 'test.name' => 'John', 54 | ]); 55 | 56 | $this 57 | ->delete(route('test'), [], [ 58 | 'HTTP_REFERER' => route('test') 59 | ]) 60 | ->assertRedirect(route('test')) 61 | ->assertSessionHasAll([ 62 | 'test.form_step' => 1, 63 | ]) 64 | ->assertSessionMissing([ 65 | 'test.name', 66 | ]); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Unit/GuardTest.php: -------------------------------------------------------------------------------- 1 | withSession([ 13 | 'test.form_step' => 2, 14 | ]); 15 | 16 | $this->setupForm(function (MultiStepForm $form) { 17 | $form 18 | ->canNavigateBack() 19 | ->addStep(1) 20 | ->addStep(2); 21 | }); 22 | 23 | 24 | $this 25 | ->get(route('test', [ 26 | 'form_step' => 1, 27 | ]), [ 28 | 'HTTP_REFERER' => route('test') 29 | ]) 30 | ->assertRedirect(route('test')) 31 | ->assertSessionHas([ 32 | 'test.form_step' => 1 33 | ]); 34 | 35 | $this 36 | ->getJson(route('test',[ 37 | 'form_step' => 1, 38 | ])) 39 | ->assertSessionHas([ 40 | 'test.form_step' => 1 41 | ]); 42 | } 43 | 44 | public function test_cannot_access_previous_step() 45 | { 46 | $this->withSession([ 47 | 'test.form_step' => 2, 48 | ]); 49 | 50 | $this->setupForm(function (MultiStepForm $form) { 51 | $form 52 | ->addStep(1) 53 | ->addStep(2); 54 | }); 55 | 56 | 57 | $this->get(route('test', [ 58 | 'form_step' => 1, 59 | ]), [ 60 | 'HTTP_REFERER' => route('test') 61 | ]) 62 | ->assertRedirect(route('test')) 63 | ->assertSessionHas([ 64 | 'test.form_step' => 2 65 | ]); 66 | 67 | $this->getJson(route('test',[ 68 | 'form_step' => 1, 69 | ])) 70 | ->assertSessionHas([ 71 | 'test.form_step' => 2 72 | ]); 73 | } 74 | 75 | public function test_cannot_access_future_step() 76 | { 77 | $this->setupForm(function (MultiStepForm $form) { 78 | $form 79 | ->addStep(1) 80 | ->addStep(2) 81 | ; 82 | }); 83 | 84 | 85 | $this->get(route('test', [ 86 | 'form_step' => 2, 87 | ]), [ 88 | 'HTTP_REFERER' => route('test') 89 | ]) 90 | ->assertRedirect(route('test')) 91 | ->assertSessionHas([ 92 | 'test.form_step' => 1 93 | ]); 94 | 95 | $this->getJson(route('test',[ 96 | 'form_step' => 2, 97 | ])) 98 | ->assertSessionHas([ 99 | 'test.form_step' => 1 100 | ]); 101 | } 102 | 103 | 104 | } 105 | -------------------------------------------------------------------------------- /tests/Unit/HooksTest.php: -------------------------------------------------------------------------------- 1 | setupForm(function (MultiStepForm $form) { 15 | $form->addStep(1); 16 | $form->addStep(2); 17 | $form->beforeSave(function (array $data){ 18 | $data['saved'] = true; 19 | return $data; 20 | }); 21 | }); 22 | 23 | $this 24 | ->post(route('test', ['form_step' => 1])) 25 | ->assertSessionHasAll([ 26 | 'test.saved' => true 27 | ]); 28 | } 29 | 30 | public function test_invokable_configuration() 31 | { 32 | $this->setupForm(function (MultiStepForm $form) { 33 | $form->tap(new Invoke); 34 | }); 35 | $this 36 | ->post(route('test', ['form_step' => 1])) 37 | ->assertSessionHasAll([ 38 | 'test.invoked' => true 39 | ]); 40 | 41 | } 42 | 43 | 44 | public function test_hook_called_and_can_return_response() 45 | { 46 | //before:any 47 | $this->setupForm(function (MultiStepForm $form) { 48 | $form->beforeStep('*', function (MultiStepForm $form) { 49 | return response('test'); 50 | }); 51 | }); 52 | $this 53 | ->post(route('test', ['form_step' => 1])) 54 | ->assertContent('test'); 55 | 56 | 57 | //before:step 58 | $this->setupForm(function (MultiStepForm $form) { 59 | $form->beforeStep(1, function (MultiStepForm $form) { 60 | return response('test'); 61 | }); 62 | }); 63 | $this 64 | ->post(route('test', ['form_step' => 1])) 65 | ->assertContent('test'); 66 | 67 | 68 | //on:any 69 | $this->setupForm(function (MultiStepForm $form) { 70 | $form->onStep('*', function (MultiStepForm $form) { 71 | return response('test'); 72 | }); 73 | }); 74 | $this 75 | ->post(route('test', ['form_step' => 1])) 76 | ->assertContent('test'); 77 | 78 | //on:step 79 | $this->setupForm(function (MultiStepForm $form) { 80 | $form->onStep(1, function (MultiStepForm $form) { 81 | return response('test'); 82 | }); 83 | }); 84 | $this 85 | ->post(route('test', ['form_step' => 1])) 86 | ->assertContent('test'); 87 | 88 | //on:completed 89 | $this->setupForm(function (MultiStepForm $form) { 90 | $form->addStep(1); 91 | $form->onComplete(function (MultiStepForm $form) { 92 | return response('test'); 93 | }); 94 | }); 95 | $this 96 | ->post(route('test', ['form_step' => 1])) 97 | ->assertContent('test'); 98 | 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/Unit/StepFlow.php: -------------------------------------------------------------------------------- 1 | setupForm(function (MultiStepForm $form) { 15 | $form 16 | ->canNavigateBack() 17 | ->addStep(1) 18 | ->addStep(2) 19 | ->addStep(3); 20 | }); 21 | 22 | /** 23 | * @var MultiStepForm $form 24 | */ 25 | $form = $this->app->make(MultiStepForm::class); 26 | 27 | $this->withSession([ 28 | 'test.form_step' => 2, 29 | ]); 30 | 31 | $this->assertSame( 32 | $form->request->fullUrlWithQuery(['form_step' => 1]), 33 | $form->prevStepUrl() 34 | ); 35 | 36 | $this->withSession([ 37 | 'test.form_step' => 1, 38 | ]); 39 | 40 | $this->assertNull($form->prevStepUrl()); 41 | } 42 | 43 | 44 | public function test_step_helpers() 45 | { 46 | $this->setupForm(function (MultiStepForm $form) { 47 | $form 48 | ->addStep(1) 49 | ->addStep(2, [ 50 | 'data' => ['test' => true] 51 | ]) 52 | ->addStep(3); 53 | }); 54 | 55 | $this->withSession([ 56 | 'test.form_step' => 2, 57 | 'test.test_value' => true, 58 | ]); 59 | 60 | /** 61 | * @var MultiStepForm $form 62 | */ 63 | $form = App::make(MultiStepForm::class); 64 | 65 | $this->assertSame('value', $form->isPast(1, 'value', 'fallback')); 66 | $this->assertSame('value', $form->isActive(2, 'value', 'fallback')); 67 | $this->assertSame('value', $form->isFuture(3, 'value', 'fallback')); 68 | 69 | $this->assertSame('fallback', $form->isPast(10, 'value', 'fallback')); 70 | $this->assertSame('fallback', $form->isActive(10, 'value', 'fallback')); 71 | $this->assertSame('fallback', $form->isFuture(10, 'value', 'fallback')); 72 | 73 | $this->assertTrue($form->hasValue('test_value')); 74 | $this->assertTrue($form->stepConfig()->get('data')['test']); 75 | 76 | } 77 | 78 | public function test_step_incremented() 79 | { 80 | $this->setupForm(function (MultiStepForm $form) { 81 | $form 82 | ->canNavigateBack() 83 | ->addStep(1) 84 | ->addStep(2) 85 | ->addStep(3); 86 | }); 87 | 88 | foreach (range(1, 2) as $step) { 89 | $this 90 | ->postJson(route('test'), [ 91 | 'form_step' => $step 92 | ]) 93 | ->assertSessionHasAll([ 94 | 'test.form_step' => $step + 1, 95 | ]) 96 | ->assertJson([ 97 | 'form' => [ 98 | 'form_step' => $step + 1 99 | ] 100 | ]); 101 | } 102 | 103 | $this 104 | ->post(route('test'), [ 105 | 'form_step' => 3 106 | ]) 107 | ->assertSessionHasAll([ 108 | 'test.form_step' => 1, 109 | ]); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/Unit/ValidationTest.php: -------------------------------------------------------------------------------- 1 | setupForm(function (MultiStepForm $form) { 13 | $form 14 | ->addStep(1, [ 15 | 'rules' => [ 16 | 'name' => ['required'] 17 | ], 18 | 'messages' => [ 19 | 'name.required' => 'test:message', 20 | 'form_step.required' => 'test:message' 21 | ], 22 | ]) 23 | ->addStep(2); 24 | }); 25 | 26 | $this 27 | ->post(route('test')) 28 | ->assertSessionHasErrors([ 29 | 'name' => 'test:message', 30 | 'form_step' => 'test:message', 31 | ]); 32 | } 33 | } 34 | --------------------------------------------------------------------------------