---
title: "Persistence store — Onboarding for Laravel | Roundly"
description: "Opt into dismissible optional steps and stop re-announcing saved completions by plugging your own OnboardingStore in with Onboarding::useStore()."
url: https://roundly-consulting.com/open-source/docs/onboarding-for-laravel/persistence
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Onboarding for Laravel](https://roundly-consulting.com/open-source/docs/onboarding-for-laravel.md)

# 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().

```php
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:

```php
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:

```php
// 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:

```php
// 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:

```php
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

```php
$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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/onboarding-for-laravel.md)
