Skip to content

WebSystem-studio/laravel--wizard

Repository files navigation

Laravel Multi-Step Wizard Package (Headless)

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

A powerful headless multi-step wizard package for Laravel applications. Build complex, multi-page forms with progress tracking, navigation, validation, and conditional steps. Bring your own frontend - works with React, Vue, Inertia, Livewire, Alpine.js, or any JavaScript framework.

🎯 Why This Package?

  • 🚀 Zero Frontend Lock-in: Pure JSON API - use any frontend framework
  • ⚡ Interactive Generators: Beautiful CLI commands with Laravel Prompts
  • ✅ Laravel-Native Validation: Uses FormRequest classes, not custom rules
  • 💾 Flexible Storage: Session, database, or cache - your choice
  • 🎨 Clean Facade API: Intuitive, fluent, discoverable methods
  • 📊 Smart Progress Tracking: Real-time completion percentages
  • 🔀 Conditional Logic: Optional steps, dynamic flows, dependencies
  • 🔔 Event-Driven: Hook into every wizard lifecycle event
  • ✨ Modern PHP 8.4: Property hooks, readonly classes, strict types
  • 🌍 Translatable: Built-in i18n support for all messages

Requirements

  • PHP 8.4 or higher
  • Laravel 11.0 or 12.0

Installation

1. Install via Composer

composer require websystem-studio/wizard-package

2. Publish Configuration

php artisan vendor:publish --tag="wizard-config"

This creates config/wizard.php where you can configure storage, routes, and behavior.

3. Publish Migrations (Optional)

If you want to use database storage instead of session:

php artisan vendor:publish --tag="wizard-migrations"
php artisan migrate

4. Publish Translations (Optional)

Customize messages in your language:

php artisan vendor:publish --tag="wizard-translations"

Translation files will be published to lang/vendor/wizard/.

Quick Start (5 Minutes)

Step 1: Generate a Wizard

php artisan wizard:make

Interactive CLI prompts:

✔ What is the wizard name? › Onboarding
✔ Must be PascalCase (e.g., Onboarding, Registration)

✓ Wizard class created: app/Wizards/Onboarding.php
✓ Registered in config: config/wizard.php
✓ Config cache cleared

Next steps:
  • Generate first step: php artisan wizard:make-step --wizard=onboarding
  • View wizard config: config/wizard.php

Step 2: Generate Steps

php artisan wizard:make-step

Interactive prompts:

✔ Which wizard should this step belong to? › onboarding
✔ What is the step name? › PersonalInfo
✔ What is the step title? › Personal Information
✔ What is the step order? › 1
✔ Is this step optional? › No

✓ Step class created: app/Wizards/Steps/PersonalInfoStep.php
✓ FormRequest created: app/Http/Requests/Wizards/PersonalInfoRequest.php
✓ Registered in wizard: onboarding
✓ Config cache cleared

Next steps:
  • Add validation rules: app/Http/Requests/Wizards/PersonalInfoRequest.php
  • Implement business logic: app/Wizards/Steps/PersonalInfoStep.php
  • Generate another step: php artisan wizard:make-step --wizard=onboarding

Generate multiple steps:

php artisan wizard:make-step --wizard=onboarding  # Preferences
php artisan wizard:make-step --wizard=onboarding  # Review

Step 3: Add Validation Rules

Edit app/Http/Requests/Wizards/PersonalInfoRequest.php:

<?php

namespace App\Http\Requests\Wizards;

use Illuminate\Foundation\Http\FormRequest;

class PersonalInfoRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'first_name' => ['required', 'string', 'max:255'],
            'last_name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'unique:users,email'],
            'phone' => ['nullable', 'string', 'regex:/^\+?[1-9]\d{1,14}$/'],
        ];
    }

    public function messages(): array
    {
        return [
            'email.unique' => 'This email address is already registered.',
            'phone.regex' => 'Please enter a valid phone number.',
        ];
    }
}

Step 4: Implement Business Logic (Optional)

Edit app/Wizards/Steps/PersonalInfoStep.php:

<?php

namespace App\Wizards\Steps;

use WebSystem\WizardPackage\Steps\AbstractStep;
use WebSystem\WizardPackage\ValueObjects\StepData;
use WebSystem\WizardPackage\ValueObjects\StepResult;

class PersonalInfoStep extends AbstractStep
{
    public function __construct()
    {
        parent::__construct(
            id: 'personal-info',
            title: 'Personal Information',
            order: 1,
            isOptional: false,
            canSkip: false,
        );
    }

    public function process(StepData $data): StepResult
    {
        // Data is already validated by PersonalInfoRequest
        
        // Optional: Transform or process data
        $processedData = [
            'full_name' => $data->get('first_name') . ' ' . $data->get('last_name'),
            'email' => strtolower($data->get('email')),
            'phone' => $data->get('phone'),
            'processed_at' => now()->toIso8601String(),
        ];
        
        // Optional: Perform side effects
        // Log::info('User registered', $processedData);
        // Cache::put("pending-user:{$data->get('email')}", $processedData, 3600);
        
        return StepResult::success(
            data: $processedData,
            message: 'Personal information saved successfully'
        );
    }
}

Step 5: Build Your Frontend

The package provides JSON API endpoints. Build your UI with any framework:

Available Routes:

GET  /wizard/{wizard}/{step}              - Show step
POST /wizard/{wizard}/{step}              - Process step
POST /wizard/{wizard}/{step}/skip         - Skip optional step
POST /wizard/{wizard}/complete            - Complete wizard
GET  /wizard/{wizard}/{id}/edit/{step}    - Edit mode
PUT  /wizard/{wizard}/{id}/edit/{step}    - Update step
DELETE /wizard/{wizard}/{id}              - Delete wizard

Frontend Integration

React + TypeScript

// hooks/useWizard.ts
import { useState, useEffect } from 'react';

interface WizardState {
  wizard_id: string;
  current_step: string;
  completed_steps: string[];
  progress: {
    total_steps: number;
    completed_steps: number;
    completion_percentage: number;
    is_complete: boolean;
  };
  navigation: Array<{
    id: string;
    title: string;
    status: 'completed' | 'current' | 'incomplete';
    label: string;
    icon: string;
  }>;
}

export function useWizard(wizardId: string) {
  const [state, setState] = useState<WizardState | null>(null);
  const [loading, setLoading] = useState(false);
  const [errors, setErrors] = useState<Record<string, string[]>>({});

  const fetchStep = async (stepId: string) => {
    const res = await fetch(`/wizard/${wizardId}/${stepId}`);
    const data = await res.json();
    if (data.success) {
      setState(data.data);
    }
  };

  const processStep = async (stepId: string, formData: object) => {
    setLoading(true);
    setErrors({});
    
    const res = await fetch(`/wizard/${wizardId}/${stepId}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
      },
      body: JSON.stringify(formData),
    });
    
    const data = await res.json();
    
    if (data.success) {
      setState(data.data);
      return { success: true, nextStep: data.data.next_step };
    } else {
      setErrors(data.errors || {});
      return { success: false, errors: data.errors };
    }
    
    setLoading(false);
  };

  return { state, loading, errors, processStep, fetchStep };
}

// components/Wizard.tsx
import { useWizard } from '../hooks/useWizard';

export function Wizard({ wizardId }: { wizardId: string }) {
  const { state, loading, errors, processStep, fetchStep } = useWizard(wizardId);
  const [formData, setFormData] = useState({});

  useEffect(() => {
    if (state?.current_step) {
      fetchStep(state.current_step);
    }
  }, []);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const result = await processStep(state!.current_step, formData);
    
    if (result.success && result.nextStep) {
      fetchStep(result.nextStep);
    }
  };

  if (!state) return <div>Loading...</div>;

  return (
    <div className="wizard">
      {/* Progress Bar */}
      <div className="progress-bar">
        <div 
          className="progress-fill" 
          style={{ width: `${state.progress.completion_percentage}%` }}
        />
        <span>{state.progress.completion_percentage}% Complete</span>
      </div>

      {/* Navigation Breadcrumbs */}
      <nav className="wizard-nav">
        {state.navigation.map((item) => (
          <div 
            key={item.id} 
            className={`nav-item ${item.status}`}
          >
            <span className="icon">{item.icon}</span>
            <span className="label">{item.label}</span>
          </div>
        ))}
      </nav>

      {/* Step Form */}
      <form onSubmit={handleSubmit}>
        <h2>{state.navigation.find(n => n.id === state.current_step)?.title}</h2>
        
        {/* Render fields based on current step */}
        {errors && Object.keys(errors).length > 0 && (
          <div className="errors">
            {Object.entries(errors).map(([field, messages]) => (
              <div key={field}>{messages.join(', ')}</div>
            ))}
          </div>
        )}
        
        <button type="submit" disabled={loading}>
          {loading ? 'Processing...' : 'Next'}
        </button>
      </form>
    </div>
  );
}

Vue 3 + Composition API

<!-- composables/useWizard.ts -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';

export function useWizard(wizardId: string) {
  const state = ref(null);
  const loading = ref(false);
  const errors = ref({});

  const fetchStep = async (stepId: string) => {
    const res = await fetch(`/wizard/${wizardId}/${stepId}`);
    const data = await res.json();
    if (data.success) {
      state.value = data.data;
    }
  };

  const processStep = async (stepId: string, formData: object) => {
    loading.value = true;
    errors.value = {};
    
    const res = await fetch(`/wizard/${wizardId}/${stepId}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData),
    });
    
    const data = await res.json();
    
    if (data.success) {
      state.value = data.data;
      loading.value = false;
      return { success: true, nextStep: data.data.next_step };
    } else {
      errors.value = data.errors;
      loading.value = false;
      return { success: false };
    }
  };

  return { state, loading, errors, processStep, fetchStep };
}
</script>

<!-- components/Wizard.vue -->
<template>
  <div v-if="state" class="wizard">
    <!-- Progress -->
    <div class="progress-bar">
      <div 
        class="progress-fill" 
        :style="{ width: state.progress.completion_percentage + '%' }"
      ></div>
    </div>

    <!-- Navigation -->
    <nav class="wizard-nav">
      <div 
        v-for="item in state.navigation" 
        :key="item.id"
        :class="['nav-item', item.status]"
      >
        <span>{{ item.label }}</span>
      </div>
    </nav>

    <!-- Form -->
    <form @submit.prevent="handleSubmit">
      <slot :step="state.current_step" :errors="errors"></slot>
      <button type="submit" :disabled="loading">Next</button>
    </form>
  </div>
</template>

<script setup lang="ts">
import { useWizard } from '../composables/useWizard';

const props = defineProps<{ wizardId: string }>();
const { state, loading, errors, processStep, fetchStep } = useWizard(props.wizardId);

onMounted(() => {
  fetchStep(state.value?.current_step || 'personal-info');
});
</script>

Inertia.js + React

// Pages/Wizard/Show.tsx
import { useForm } from '@inertiajs/react';

export default function WizardShow({ wizard, step, navigation, progress }) {
  const { data, setData, post, processing, errors } = useForm({
    first_name: '',
    last_name: '',
    email: '',
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    post(route('wizard.store', { wizard: wizard.id, step: step.id }));
  };

  return (
    <div>
      <ProgressBar percentage={progress.completion_percentage} />
      <Navigation items={navigation} />
      
      <form onSubmit={handleSubmit}>
        <input 
          value={data.first_name} 
          onChange={e => setData('first_name', e.target.value)}
        />
        {errors.first_name && <span>{errors.first_name}</span>}
        
        <button disabled={processing}>Next</button>
      </form>
    </div>
  );
}

Livewire

<?php

namespace App\Livewire;

use Livewire\Component;
use WebSystem\WizardPackage\Facades\WizardPackage;

class WizardForm extends Component
{
    public string $wizardId = 'onboarding';
    public array $formData = [];
    public array $errors = [];

    public function mount()
    {
        WizardPackage::initialize($this->wizardId);
    }

    public function submit()
    {
        $currentStep = WizardPackage::getCurrentStep();
        
        $result = WizardPackage::processStep($currentStep->getId(), $this->formData);
        
        if ($result->success) {
            $this->formData = [];
            $this->errors = [];
        } else {
            $this->errors = $result->errors;
        }
    }

    public function render()
    {
        return view('livewire.wizard-form', [
            'currentStep' => WizardPackage::getCurrentStep(),
            'progress' => WizardPackage::getProgress(),
            'navigation' => WizardPackage::getNavigation(),
        ]);
    }
}

Alpine.js + Blade

<div x-data="wizardData('onboarding')" x-init="initialize()">
    <!-- Progress Bar -->
    <div class="progress-bar">
        <div 
            :style="`width: ${state?.progress?.completion_percentage || 0}%`"
            class="progress-fill"
        ></div>
        <span x-text="`${state?.progress?.completion_percentage || 0}% Complete`"></span>
    </div>

    <!-- Navigation -->
    <nav class="wizard-nav">
        <template x-for="item in state?.navigation || []" :key="item.id">
            <div :class="`nav-item ${item.status}`">
                <span x-text="item.label"></span>
            </div>
        </template>
    </nav>

    <!-- Form -->
    <form @submit.prevent="submitStep()">
        <template x-if="errors && Object.keys(errors).length > 0">
            <div class="alert alert-error">
                <template x-for="(messages, field) in errors">
                    <div x-text="messages.join(', ')"></div>
                </template>
            </div>
        </template>

        <input 
            type="text" 
            x-model="formData.first_name" 
            placeholder="First Name"
        />
        <input 
            type="email" 
            x-model="formData.email" 
            placeholder="Email"
        />
        
        <button type="submit" :disabled="loading">
            <span x-show="!loading">Next</span>
            <span x-show="loading">Processing...</span>
        </button>
    </form>
</div>

<script>
function wizardData(wizardId) {
    return {
        state: null,
        formData: {},
        errors: {},
        loading: false,
        
        async initialize() {
            const res = await fetch(`/wizard/${wizardId}/${this.getCurrentStep()}`);
            const data = await res.json();
            if (data.success) {
                this.state = data.data;
            }
        },
        
        getCurrentStep() {
            return 'personal-info'; // Or get from URL
        },
        
        async submitStep() {
            this.loading = true;
            this.errors = {};
            
            const res = await fetch(`/wizard/${wizardId}/${this.state.current_step}`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
                },
                body: JSON.stringify(this.formData),
            });
            
            const data = await res.json();
            
            if (data.success) {
                this.state = data.data;
                this.formData = {};
                
                if (data.data.next_step) {
                    window.location.href = `/wizard/${wizardId}/${data.data.next_step}`;
                }
            } else {
                this.errors = data.errors || {};
            }
            
            this.loading = false;
        },
    };
}
</script>

Using the Facade API

For backend usage, the WizardPackage facade provides a clean, fluent API:

<?php

use WebSystem\WizardPackage\Facades\WizardPackage;

// Initialize wizard
WizardPackage::initialize('onboarding');

// Get current step
$step = WizardPackage::getCurrentStep();
echo $step->getTitle(); // "Personal Information"
echo $step->getId();    // "personal-info"
echo $step->getOrder(); // 1

// Process step (validation happens via FormRequest)
$result = WizardPackage::processStep('personal-info', [
    'first_name' => 'John',
    'last_name' => 'Doe',
    'email' => '[email protected]',
]);

if ($result->success) {
    echo $result->message; // "Step completed successfully"
}

// Get all collected data
$allData = WizardPackage::getAllData();
// [
//     'personal-info' => ['first_name' => 'John', ...],
//     'preferences' => ['theme' => 'dark', ...],
// ]

// Get specific step data
$personalInfo = WizardPackage::getStepData('personal-info');

// Navigation with status
$navigation = WizardPackage::getNavigation();
foreach ($navigation as $item) {
    echo $item->label;  // "1. Personal Information"
    echo $item->icon;   // "check" (completed), "arrow-right" (current), "circle" (incomplete)
    echo $item->status; // "completed", "current", "incomplete"
}

// Progress tracking
$progress = WizardPackage::getProgress();
echo $progress->completionPercentage; // 33
echo $progress->totalSteps;           // 3
echo $progress->completedSteps;       // 1
echo $progress->isComplete;           // false

// Check step access
if (WizardPackage::canAccessStep('review')) {
    // User can access this step
}

// Check if step is completed
if (WizardPackage::isStepCompleted('personal-info')) {
    // Step is done
}

// Get completed steps
$completed = WizardPackage::getCompletedSteps();
// ['personal-info', 'preferences']

// Skip optional step
WizardPackage::skipStep('newsletter');

// Navigate to specific step
WizardPackage::navigateToStep('review');

// Complete wizard
$result = WizardPackage::complete();
if ($result->success) {
    // Wizard completed successfully
    $allData = $result->data;
}

// Reset wizard (start over)
WizardPackage::reset();

// Load wizard from database for editing
WizardPackage::loadFromStorage('onboarding', $instanceId);

// Delete wizard instance
WizardPackage::deleteWizard('onboarding', $instanceId);

Available Facade Methods

Wizard Lifecycle:

  • initialize(string $wizardId, array $config = []): void
  • reset(): void
  • complete(): StepResult
  • isComplete(): bool

Step Management:

  • getCurrentStep(): ?WizardStepInterface
  • getStep(string $stepId): WizardStepInterface
  • processStep(string $stepId, array $data): StepResult
  • skipStep(string $stepId): void

Navigation:

  • canAccessStep(string $stepId): bool
  • navigateToStep(string $stepId): void
  • getNavigation(): array<NavigationItem>
  • getNextStep(): ?WizardStepInterface
  • getPreviousStep(): ?WizardStepInterface

Data Access:

  • getAllData(): array
  • getStepData(string $stepId): ?array
  • isStepCompleted(string $stepId): bool
  • getCompletedSteps(): array

Progress:

  • getProgress(): WizardProgressValue

Database Operations:

  • loadFromStorage(string $wizardId, int $instanceId): void
  • deleteWizard(string $wizardId, int $instanceId): void

API Response Formats

Success Response

{
  "success": true,
  "data": {
    "wizard_id": "onboarding",
    "current_step": "personal-info",
    "next_step": "preferences",
    "completed_steps": ["personal-info"],
    "progress": {
      "total_steps": 3,
      "completed_steps": 1,
      "current_step_position": 2,
      "completion_percentage": 33,
      "is_complete": false,
      "remaining_steps": ["preferences", "review"]
    },
    "navigation": [
      {
        "id": "personal-info",
        "title": "Personal Information",
        "order": 1,
        "status": "completed",
        "label": "1. Personal Information",
        "icon": "check"
      },
      {
        "id": "preferences",
        "title": "Preferences",
        "order": 2,
        "status": "current",
        "label": "2. Preferences",
        "icon": "arrow-right"
      },
      {
        "id": "review",
        "title": "Review",
        "order": 3,
        "status": "incomplete",
        "label": "3. Review",
        "icon": "circle"
      }
    ],
    "step_data": {
      "personal-info": {
        "first_name": "John",
        "last_name": "Doe",
        "email": "[email protected]"
      }
    }
  },
  "message": "Step completed successfully"
}

Validation Error Response

{
  "success": false,
  "message": "The given data was invalid.",
  "errors": {
    "email": [
      "The email field is required."
    ],
    "phone": [
      "Please enter a valid phone number."
    ]
  }
}

Wizard Completion Response

{
  "success": true,
  "data": {
    "personal-info": {...},
    "preferences": {...},
    "review": {...}
  },
  "message": "Wizard completed successfully"
}

Advanced Features

Optional Steps

class NewsletterStep extends AbstractStep
{
    public function __construct()
    {
        parent::__construct(
            id: 'newsletter',
            title: 'Newsletter Subscription',
            order: 4,
            isOptional: true,  // Step can be skipped
            canSkip: true      // Show skip button
        );
    }
}

Frontend Usage:

// Skip optional step
await fetch(`/wizard/onboarding/newsletter/skip`, { method: 'POST' });

Conditional Steps

Show/hide steps based on previous data:

class BillingStep extends AbstractStep
{
    public function shouldSkip(array $wizardData): bool
    {
        // Skip billing if user selected "free" plan
        return ($wizardData['plan-selection']['plan'] ?? null) === 'free';
    }
}

class CompanyInfoStep extends AbstractStep
{
    public function shouldShow(array $wizardData): bool
    {
        // Only show if account type is "business"
        return ($wizardData['personal-info']['account_type'] ?? null) === 'business';
    }
}

Step Dependencies

Ensure required steps are completed first:

class ReviewStep extends AbstractStep
{
    public function getDependencies(): array
    {
        return ['personal-info', 'plan-selection', 'billing'];
    }
}

Complex Data Processing

class PersonalInfoStep extends AbstractStep
{
    public function __construct(
        private readonly UserService $userService,
        private readonly NotificationService $notifications,
    ) {
        parent::__construct(
            id: 'personal-info',
            title: 'Personal Information',
            order: 1,
        );
    }

    public function beforeProcess(StepData $data): void
    {
        // Runs before validation
        Log::info('Starting personal info step', ['email' => $data->get('email')]);
    }

    public function process(StepData $data): StepResult
    {
        // Main business logic
        try {
            $user = $this->userService->createPendingUser([
                'name' => $data->get('first_name') . ' ' . $data->get('last_name'),
                'email' => $data->get('email'),
            ]);

            return StepResult::success(
                data: ['user_id' => $user->id],
                message: 'Personal information saved'
            );
        } catch (\Exception $e) {
            return StepResult::failure([
                'email' => ['Failed to create user: ' . $e->getMessage()]
            ]);
        }
    }

    public function afterProcess(StepResult $result): void
    {
        // Runs after successful processing
        if ($result->success) {
            $this->notifications->send('Personal info step completed');
        }
    }
}

Storage Backends

Session Storage (Default):

// config/wizard.php
'storage' => [
    'driver' => 'session',
],

Database Storage:

'storage' => [
    'driver' => 'database',
],

'database' => [
    'table' => 'wizard_progress',
    'connection' => null, // Use default connection
],

Cache Storage:

'storage' => [
    'driver' => 'cache',
    'ttl' => 7200, // 2 hours
],

'cache' => [
    'driver' => 'redis',
    'ttl' => 7200,
],

Events

Listen to wizard lifecycle events:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use WebSystem\WizardPackage\Events\{
    WizardStarted,
    StepCompleted,
    StepSkipped,
    WizardCompleted
};

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        WizardStarted::class => [
            LogWizardStart::class,
        ],
        StepCompleted::class => [
            UpdateUserProgress::class,
            SendStepCompletionEmail::class,
        ],
        StepSkipped::class => [
            LogSkippedStep::class,
        ],
        WizardCompleted::class => [
            ProcessWizardData::class,
            CreateUserAccount::class,
            SendWelcomeEmail::class,
        ],
    ];
}

Event Properties:

// WizardStarted
$event->wizardId;
$event->userId;
$event->sessionId;
$event->initialData;

// StepCompleted
$event->wizardId;
$event->stepId;
$event->data;
$event->percentComplete;

// StepSkipped
$event->wizardId;
$event->stepId;
$event->sessionId;

// WizardCompleted
$event->wizardId;
$event->data; // All collected data
$event->completedAt;

Edit Mode (CRUD Operations)

Load existing wizard data for editing:

// Controller
public function edit(int $wizardInstanceId)
{
    WizardPackage::loadFromStorage('onboarding', $wizardInstanceId);
    
    $data = WizardPackage::getAllData();
    
    return response()->json([
        'data' => $data,
        'current_step' => WizardPackage::getCurrentStep(),
    ]);
}

// Update specific step
public function updateStep(int $wizardInstanceId, string $stepId, Request $request)
{
    WizardPackage::loadFromStorage('onboarding', $wizardInstanceId);
    
    $result = WizardPackage::processStep($stepId, $request->all());
    
    return response()->json($result);
}

// Delete wizard
public function destroy(int $wizardInstanceId)
{
    WizardPackage::deleteWizard('onboarding', $wizardInstanceId);
    
    return response()->json(['message' => 'Wizard deleted']);
}

Configuration

Full configuration reference in config/wizard.php:

return [
    'storage' => [
        'driver' => env('WIZARD_STORAGE', 'session'),
        'ttl' => 3600,
    ],

    'wizards' => [
        'onboarding' => [
            'class' => App\Wizards\Onboarding::class,
            'steps' => [
                App\Wizards\Steps\PersonalInfoStep::class,
                App\Wizards\Steps\PreferencesStep::class,
                App\Wizards\Steps\ReviewStep::class,
            ],
        ],
    ],

    'routes' => [
        'enabled' => true,
        'prefix' => env('WIZARD_ROUTE_PREFIX', 'wizard'),
        'middleware' => ['web', 'wizard.session'],
    ],

    'navigation' => [
        'allow_jump' => false,      // Allow direct navigation to any step
        'show_all_steps' => true,   // Show all steps in breadcrumbs
        'mark_completed' => true,   // Mark completed steps visually
    ],

    'validation' => [
        'validate_on_navigate' => true,
        'allow_skip_optional' => true,
    ],

    'events' => [
        'dispatch' => true,
        'log_progress' => false,
    ],

    'cleanup' => [
        'abandoned_after_days' => 30,
        'auto_cleanup' => false,
    ],
];

Internationalization

All messages are translatable. Publish translations:

php artisan vendor:publish --tag="wizard-translations"

Available languages:

  • English (lang/vendor/wizard/en.json)
  • Slovak (lang/vendor/wizard/sk.json)

Add your own language:

cp lang/vendor/wizard/en.json lang/vendor/wizard/es.json

Translatable messages:

  • Error messages
  • Validation messages
  • Success messages
  • Command output

PHP 8.4 Features

This package leverages modern PHP 8.4 features:

Property Hooks

Computed properties using property hooks:

$result = WizardPackage::processStep('step-id', $data);

// Computed via property hook (no method call)
echo $result->isSuccess;   // true/false
echo $result->hasErrors;   // true/false

$progress = WizardPackage::getProgress();
echo $progress->completionPercentage;  // 33

$navigation = WizardPackage::getNavigation();
foreach ($navigation as $item) {
    echo $item->label;  // "1. Personal Info" (computed)
    echo $item->icon;   // "check" (computed based on status)
}

Readonly Classes

All Actions, Middleware, and Value Objects are final readonly class for immutability:

final readonly class CompleteWizardAction
{
    public function __construct(
        private WizardManagerInterface $manager,
    ) {}
}

Modern Array Functions

Uses array_find() and array_any():

$step = array_find(
    $steps, 
    fn($s) => $s->getId() === 'personal-info'
);

$hasCompleted = array_any(
    $completedSteps, 
    fn($id) => $id === 'review'
);

Artisan Commands

Generate Wizard

# Interactive
php artisan wizard:make

# With name
php artisan wizard:make Onboarding

# Force overwrite
php artisan wizard:make Onboarding --force

Generate Step

# Interactive
php artisan wizard:make-step

# With options
php artisan wizard:make-step PersonalInfo \
    --wizard=onboarding \
    --order=1 \
    --optional=false

# Force overwrite
php artisan wizard:make-step PersonalInfo --wizard=onboarding --force

Both commands:

  • ✅ Auto-register in config/wizard.php
  • ✅ Create directory structure
  • ✅ Generate boilerplate code
  • ✅ Clear config cache
  • ✅ Show next steps

Testing

# Run all tests
composer test

# Run specific test suite
composer test -- --filter=WizardManagerTest

# Static analysis
composer analyse

# Code style check
composer format

# Run all quality checks
composer test && composer analyse && composer format

Package includes 131 tests:

  • Unit tests (core functionality)
  • Integration tests (full wizard flows)
  • Feature tests (commands, validation)
  • Architecture tests (SOLID principles, PHP 8.4 compliance)

Troubleshooting

Session Not Working

Make sure session middleware is registered:

// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \Illuminate\Session\Middleware\StartSession::class,
    ]);
})

// Or in routes/web.php
Route::middleware(['web'])->group(function () {
    // Your routes
});

Steps Not Auto-Registered

Clear config cache:

php artisan config:clear

Validation Not Working

Make sure FormRequest returns validation rules:

public function rules(): array
{
    return [
        'email' => 'required|email',
    ];
}

Database Storage Issues

Run migrations:

php artisan migrate

Check config:

'storage' => [
    'driver' => 'database',
],

Security

  • Never commit sensitive data in wizard steps
  • Always validate user input via FormRequest classes
  • Use proper authentication middleware
  • Sanitize file uploads
  • Implement rate limiting on wizard endpoints

Report security vulnerabilities via GitHub Security.

Changelog

See CHANGELOG.md for version history.

Contributing

Contributions welcome! See CONTRIBUTING.md.

Please ensure:

  • Tests pass (composer test)
  • PHPStan passes (composer analyse)
  • Code style passes (composer format)
  • Follow SOLID principles
  • Use PHP 8.4 features where appropriate

Credits

License

MIT License. See LICENSE.md for details.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •