Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Onboard Laravel Package

calebporzio/onboard

Onboard is a small Laravel package for adding simple, customizable onboarding flows to your app. Define steps with familiar Blade views, guide new users through them, and track completion so you can send them into your app when they're ready.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and scaffold the schema (Laravel 8 only):

composer require calebporzio/onboard
php artisan vendor:publish --provider="Onboard\OnboardServiceProvider"
php artisan migrate

This adds two columns to users: a JSON onboarded_steps array and an onboarded_at timestamp. No pivot tables, no separate migrations to reconcile.

Add the trait to your authenticatable model:

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Onboard\Onboardable;

class User extends Authenticatable
{
    use Onboardable;
}

Read src/Onboardable.php before writing any application code. The whole package is one trait, one migration, and a service provider — ten minutes of reading. The README is short, the package is archived, and the method names are the single most common source of confusion: docs, README, and community write-ups variously reference markAsComplete / isComplete versus markStepAsComplete / hasCompletedStep / isOnboarded. Confirm against your installed version in tinker:

$u = User::first();
$u->markStepAsComplete('verify-email');
$u->hasCompletedStep('verify-email'); // true
$u->isOnboarded();                    // false until onboarded_at is set
$u->markAsOnboarded();                // or whatever the trait names it

First use case: the post-registration funnel. Mark a step from the code path that handles each setup action (email verified, profile completed, first project created), then gate the authenticated dashboard on isOnboarded() so new users land on a checklist instead of an empty product. This is the whole value proposition of the package in one screen of code — get it working end-to-end before you build anything nicer.

Before you commit, decide whether you're adopting or borrowing. The package is archived, the last release (v1.7, Sept 2020) existed only to widen constraints for Laravel 8, and Composer will refuse to install it on Laravel 9+. If you're on a modern stack, copy the trait and migration into app/ and own two files. The API is small enough that the cost of forking exceeds the cost of reimplementation.

Implementation Patterns

Gate onboarding with one middleware, not per-controller checks. Put an EnsureOnboarded middleware on your authenticated route group with an allowlist for onboarding routes:

public function handle($request, Closure $next)
{
    $user = $request->user();

    if ($user && ! $user->isOnboarded() && ! $request->routeIs('onboarding.*')) {
        return redirect()->route('onboarding.index');
    }

    return $next($request);
}

This keeps the package out of every controller and gives you one place to change the rule (exempt API routes, admins, imported/backfilled users).

Keep a canonical step registry in config. The package stores whatever string you hand it and never validates it, so define steps once and read everything — labels, order, routes, validation — from that array:

// config/onboarding.php
return [
    'steps' => [
        'verify-email'    => ['label' => 'Verify your email',     'route' => 'verification.notice'],
        'add-company'     => ['label' => 'Add your company',      'route' => 'onboarding.company'],
        'connect-source'  => ['label' => 'Connect a data source', 'route' => 'onboarding.source'],
        'invite-teammate' => ['label' => 'Invite a teammate',     'route' => 'onboarding.invite'],
    ],
];

This is your single source of truth for ordering, copy, and the validation the package lacks.

Funnel every trait call through one application service. Don't sprinkle markStepAsComplete() across controllers, listeners, and Livewire components:

class OnboardingProgress
{
    public function complete(User $user, string $step): void
    {
        abort_unless(array_key_exists($step, config('onboarding.steps')), 400);

        if ($user->hasCompletedStep($step)) {
            return;
        }

        $user->markStepAsComplete($step);

        OnboardingStepCompleted::dispatch($user, $step);
    }

    public function percent(User $user): int
    {
        $all  = array_keys(config('onboarding.steps'));
        $done = array_intersect($all, $user->onboarded_steps ?? []);

        return (int) round(count($done) / max(count($all), 1) * 100);
    }
}

This adds the validation, gives you one seam to emit analytics events, and makes swapping the package for your own table a one-file change.

Mark steps from listeners, not controllers, where possible. Listen for your own domain events (EmailVerified, ProjectCreated, TeamMemberInvited) so steps also complete through the API, console commands, or queued jobs — not only the HTTP request that happened to trigger them.

Render the checklist from config. A Blade partial loops over config('onboarding.steps'), calls $user->hasCompletedStep($key), renders a checkmark, and surfaces a "Next: …" CTA for the first incomplete step. Feed a progress bar from OnboardingProgress::percent(). Never hardcode step names in the view.

Handle the completion transition explicitly. The moment isOnboarded() flips true is your activation event: stamp onboarded_at, fire UserOnboarded, send a confirmation notification, clear any feature-flag override. Keeping this inside the service means it fires on whatever step happened to finish last, not on a step you hardcoded.

Give yourself reset and replay tooling. Support and QA will ask. Add an artisan command (or tinker action) that clears the trait's step state and re-runs the checks, and add a factory state so tests can start from a known point:

// UserFactory
public function onboarded(): static
{
    return $this->afterCreating(fn (User $u) => $u->markAsOnboarded());
}

Test through your own service and always call fresh():

$user = User::factory()->create();
$this->actingAs($user)->post('/projects', [...])->assertRedirect();

$this->assertTrue($user->fresh()->hasCompletedStep('add-company'));

The fresh() is not paranoia — the JSON cast means the in-memory instance you acted as still holds the pre-write array.

Gotchas and Tips

Archived and capped at Laravel 8. v1.7 exists solely to widen constraints to Laravel 8 — no features, no fixes, no security content. On Laravel 9+ Composer fails on constraints even though the code is trivially compatible. On a modern stack, fork it or copy the trait and migration into app/; the surface is small enough that ownership costs less than a fork.

The JSON column is read-modify-write, so concurrent completions can be lost. Two requests marking different steps on the same user can clobber each other's array. For a UI checklist this is usually tolerable (the user re-triggers the step), but if queued jobs mark steps, wrap the write in a transaction with a row lock or accept the eventual re-check.

Stale model instances will lie to you. Because onboarded_steps is a JSON cast, a User hydrated before a write elsewhere still holds the old array. Any check following a write in a different object graph — tests, queued listeners, Livewire components, Octane requests — needs fresh() or refresh().

Nothing validates or orders your step keys. markStepAsComplete('verfy-email') succeeds and silently creates a step no checklist renders, and isOnboarded() can then be reached without the real step being done. The config registry plus the abort_unless guard is the fix — don't skip it.

isOnboarded() and the step list are decoupled. Adding a fifth step later does not un-onboard users who already finished — onboarded_at stays set. Decide deliberately: recompute completion for existing users, or keep onboarded_at as a historical record and accept that "onboarded" means two different things across cohorts. Version the flow (a flow_version config key or your own column) if it will change.

Keep the columns out of $fillable. onboarded_steps and onboarded_at being mass-assignable is a self-serve way for users to mark themselves activated. Write only through your service.

There are no package-level events. If growth wants activation numbers, emit OnboardingStepCompleted / UserOnboarded yourself or ship them to PostHog/Amplitude. A JSON array per user is not a funnel — querying it across cohorts (especially on SQLite) is painful.

Trait collisions on User are fatal. If another package defines isOnboarded() or a similarly generic method, PHP will fatal at class load. Alias the trait (use Onboardable { isOnboarded as onboardedFlag; }) or rename your own methods.

Debugging is mostly one query. When a checklist looks wrong, dump the raw column and compare against config:

dump($user->getRawOriginal('onboarded_steps')); // the truth
dump($user->onboarded_steps);                   // what this instance believes

Most "stuck onboarding" tickets are a typo'd key, a stale instance, a middleware short-circuit that prevented the marking code path from ever running, or a user created before the step existed.

Extension points are your own code — don't edit vendor. Since the package is unmaintained, override behavior in your OnboardingProgress service or a subclassed trait. Keep an exit plan (a user_onboarding_steps table keyed by user, step, completed timestamp) so removal is a backfill plus a dropColumn, not a rewrite. If you find yourself adding branching, versioning, or multiple tracks, you've outgrown the package — that's the state machine or pipeline you should have built instead.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console