NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Onboarding for Laravel

Persistence store

The package is stateless by default, but it will use a host-provided RoundlyConsulting\Onboarding\Contracts\OnboardingStore. It ships no table, model or migration and is a complete no-op without a store. Your application owns the storage: completions (typically by listening to StepCompleted) and dismissals, which the package hands to markDismissed().

namespace RoundlyConsulting\Onboarding\Contracts;

interface OnboardingStore
{
    public function isDismissed(Authenticatable|Model|null $subject, string $stepKey): bool;

    public function completedAt(Authenticatable|Model|null $subject, string $stepKey): ?DateTimeInterface;

    public function markDismissed(Authenticatable|Model|null $subject, string $stepKey): void;
}

What a store enables

  • Dismissible optional steps — an optional, dismissible() step the subject has dismissed drops out of steps() and the percentages. Required steps are never dismissed away.
  • Suppressed re-announcements — record() skips any step whose completedAt() is non-null, so once your listener has persisted a completion, later requests don’t announce that step again. It’s a read, not an atomic claim, so keep listeners idempotent — see Events.

Implementing a store

Back the contract with any storage you like. This example uses a table your application owns:

namespace App\Onboarding;

use DateTimeInterface;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RoundlyConsulting\Onboarding\Contracts\OnboardingStore;

// Your own table — the package ships none.
final class DatabaseOnboardingStore implements OnboardingStore
{
    public function isDismissed(Authenticatable|Model|null $subject, string $stepKey): bool
    {
        return $this->row($subject, $stepKey)?->dismissed_at !== null;
    }

    public function completedAt(Authenticatable|Model|null $subject, string $stepKey): ?DateTimeInterface
    {
        $at = $this->row($subject, $stepKey)?->completed_at;

        return $at === null ? null : Carbon::parse($at);
    }

    // Called by the manager when the subject dismisses an optional, dismissible step.
    public function markDismissed(Authenticatable|Model|null $subject, string $stepKey): void
    {
        if ($subject === null) {
            return;
        }

        DB::table('onboarding_steps')->updateOrInsert(
            ['user_id' => $subject->getKey(), 'step' => $stepKey],
            ['dismissed_at' => now()],
        );
    }

    private function row(Authenticatable|Model|null $subject, string $stepKey): ?object
    {
        if ($subject === null) {
            return null;
        }

        return DB::table('onboarding_steps')
            ->where('user_id', $subject->getKey())
            ->where('step', $stepKey)
            ->first();
    }
}

Plugging it in

Pass the store to Onboarding::useStore() — a class is resolved through the container on first use, a class that doesn’t implement the contract throws InvalidStoreException:

// app/Providers/AppServiceProvider.php — boot()
use App\Onboarding\DatabaseOnboardingStore;
use RoundlyConsulting\Onboarding\Facades\Onboarding;

Onboarding::useStore(DatabaseOnboardingStore::class);   // resolved through the container on first use
Onboarding::useStore(new DatabaseOnboardingStore);      // or an instance

Onboarding::hasStore();   // true
Onboarding::store();      // ?OnboardingStore — the store in use

Without useStore(), a store bound in the container is used:

// the fallback when useStore() was never called — app/Providers/AppServiceProvider.php, register()
use App\Onboarding\DatabaseOnboardingStore;
use RoundlyConsulting\Onboarding\Contracts\OnboardingStore;

$this->app->bind(OnboardingStore::class, DatabaseOnboardingStore::class);

Persisting completions

Listen to StepCompleted and write the timestamp — the next record() then skips that step:

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Onboarding\Events\StepCompleted;

Event::listen(StepCompleted::class, function (StepCompleted $e): void {
    if ($e->for === null) {
        return;
    }

    DB::table('onboarding_steps')->updateOrInsert(
        ['user_id' => $e->for->getKey(), 'step' => $e->step->stepKey()],
        ['completed_at' => now()],
    );
});

Dismissing a step

$flow = Onboarding::flow('default')->title('Profile Onboarding');

$flow->add('Add a bio')->key('add-bio')->optional()->dismissible()
    ->completeWhenFilled('bio');

// e.g. from a "Not now" button — all three go through the manager to markDismissed()
Onboarding::for($user)?->dismiss('add-bio');   // returns the Flow
$user->dismissOnboardingStep('add-bio');       // the trait shorthand
$user->onboarding()?->dismiss('add-bio');

Every dismissal goes through the manager, which calls the store’s markDismissed(). It acts only on a visible optional step marked dismissible(), and is a no-op — nothing is written to the store — without a store, for an unknown step, and for a required or non-dismissible one.

FlowCompleted is not suppressed — record() fires it whenever the flow is complete, and record($key) whenever it announced that required step. Make a “celebrate once” listener idempotent.

Step keys are shared across flows

The store and the events identify a step by subject and step key only — there is no flow key. Completing or dismissing verify-email in one flow counts in every flow that has a verify-email step, which is usually what you want for shared steps. For state that belongs to one flow only, give the step a flow-specific key, such as a prefix:

Onboarding::flow('vendor')->addStep(Step::make('Add a bio')->key('vendor.add-bio')->optional()->dismissible());

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.