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.
- 🚀 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
- PHP 8.4 or higher
- Laravel 11.0 or 12.0
composer require websystem-studio/wizard-packagephp artisan vendor:publish --tag="wizard-config"This creates config/wizard.php where you can configure storage, routes, and behavior.
If you want to use database storage instead of session:
php artisan vendor:publish --tag="wizard-migrations"
php artisan migrateCustomize messages in your language:
php artisan vendor:publish --tag="wizard-translations"Translation files will be published to lang/vendor/wizard/.
php artisan wizard:makeInteractive 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
php artisan wizard:make-stepInteractive 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 # ReviewEdit 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.',
];
}
}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'
);
}
}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
// 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>
);
}<!-- 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>// 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>
);
}<?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(),
]);
}
}<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>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);Wizard Lifecycle:
initialize(string $wizardId, array $config = []): voidreset(): voidcomplete(): StepResultisComplete(): bool
Step Management:
getCurrentStep(): ?WizardStepInterfacegetStep(string $stepId): WizardStepInterfaceprocessStep(string $stepId, array $data): StepResultskipStep(string $stepId): void
Navigation:
canAccessStep(string $stepId): boolnavigateToStep(string $stepId): voidgetNavigation(): array<NavigationItem>getNextStep(): ?WizardStepInterfacegetPreviousStep(): ?WizardStepInterface
Data Access:
getAllData(): arraygetStepData(string $stepId): ?arrayisStepCompleted(string $stepId): boolgetCompletedSteps(): array
Progress:
getProgress(): WizardProgressValue
Database Operations:
loadFromStorage(string $wizardId, int $instanceId): voiddeleteWizard(string $wizardId, int $instanceId): void
{
"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"
}{
"success": false,
"message": "The given data was invalid.",
"errors": {
"email": [
"The email field is required."
],
"phone": [
"Please enter a valid phone number."
]
}
}{
"success": true,
"data": {
"personal-info": {...},
"preferences": {...},
"review": {...}
},
"message": "Wizard completed successfully"
}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' });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';
}
}Ensure required steps are completed first:
class ReviewStep extends AbstractStep
{
public function getDependencies(): array
{
return ['personal-info', 'plan-selection', 'billing'];
}
}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');
}
}
}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,
],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;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']);
}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,
],
];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.jsonTranslatable messages:
- Error messages
- Validation messages
- Success messages
- Command output
This package leverages modern PHP 8.4 features:
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)
}All Actions, Middleware, and Value Objects are final readonly class for immutability:
final readonly class CompleteWizardAction
{
public function __construct(
private WizardManagerInterface $manager,
) {}
}Uses array_find() and array_any():
$step = array_find(
$steps,
fn($s) => $s->getId() === 'personal-info'
);
$hasCompleted = array_any(
$completedSteps,
fn($id) => $id === 'review'
);# Interactive
php artisan wizard:make
# With name
php artisan wizard:make Onboarding
# Force overwrite
php artisan wizard:make Onboarding --force# 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 --forceBoth commands:
- ✅ Auto-register in
config/wizard.php - ✅ Create directory structure
- ✅ Generate boilerplate code
- ✅ Clear config cache
- ✅ Show next steps
# 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 formatPackage includes 131 tests:
- Unit tests (core functionality)
- Integration tests (full wizard flows)
- Feature tests (commands, validation)
- Architecture tests (SOLID principles, PHP 8.4 compliance)
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
});Clear config cache:
php artisan config:clearMake sure FormRequest returns validation rules:
public function rules(): array
{
return [
'email' => 'required|email',
];
}Run migrations:
php artisan migrateCheck config:
'storage' => [
'driver' => 'database',
],- 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.
See CHANGELOG.md for version history.
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
MIT License. See LICENSE.md for details.