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.
  • Once-only events — record() skips any step whose completedAt() is non-null, so StepCompleted fires once per step across requests.

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 step marked dismissible(), and is a no-op without a store, for an unknown step and for a 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.

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.