Scaling User Onboarding with Laravel Job Chaining and Redis
I’ve been working on an accounting software for Amazon sellers that requires users to connect their Amazon Seller account, Amazon Ads, and one or more bank accounts to fetch financial and operational data.
The Problem
The original onboarding experience relied on a multi-step registration flow, which introduced both UX and performance issues:
- User details (email, password, contact info, company details)
- Amazon Seller account connection
- Amazon Ads connection
- Bank account connection via Plaid
Beyond the UI complexity, the biggest issue was what happened after registration. During signup, the system performed a large amount of synchronous setup logic, including:
- Creating default charts of accounts
- Creating default sales channels
- Initializing forecast settings
- Mapping default accounting structures
This logic lived directly inside the registration controller and blocked the request lifecycle, often causing 5–10 second delays before users were redirected—sometimes staring at a blank screen with no indication of progress.
Old Onboarding Controller (Before)
class RegisteredUserController extends Controller
{
public function store(Request $request)
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'company_name' => ['required'],
'company_telephone' => ['required'],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'type' => 'company',
'timezone' => $request->input('user_timezone'),
]);
DB::table('settings')->insert([
[
'name' => 'company_name',
'value' => $request->company_name,
'created_by' => $user->id,
], [
'name' => 'company_telephone',
'value' => $request->company_telephone,
'created_by' => $user->id,
],
]);
Auth::login($user);
$chartOfAccountController = new ChartOfAccountController;
$chartOfAccountController->addDefaultAccounts($user->id);
$channelController = new ChannelController;
$channelController->createDefaultChannelsForUser($user->id);
dispatch(new SetupUserAccountJob($user->id));
return redirect('/dashboard');
}
}
Key Issues with This Approach
- Fat controller with multiple responsibilities
- Synchronous execution of heavy setup logic
- Poor testability and hard-to-maintain code
- Bad user experience due to blocking requests
The Solution
I redesigned both the onboarding UX and the backend architecture.
UX Improvements
- Eliminated the multi-step registration flow
- Replaced it with a single-page onboarding experience
- Account connections (Amazon, Ads, Bank) now happen asynchronously
- Users can proceed immediately without waiting on backend setup
Backend Improvements
Instead of running all setup logic inside the controller, I:
- Extracted onboarding logic into dedicated jobs
- Broke the monolithic setup process into small, focused responsibilities
- Executed them as chained background jobs
- Ran everything on a Redis-backed queue
Refactored Onboarding Logic (After)
class RegisteredUserController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'company_name' => ['required'],
'company_telephone' => ['required'],
]);
DB::transaction(function () use ($validated, $request) {
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'type' => 'company',
'timezone' => $request->input('user_timezone'),
]);
Setting::insert([
[
'name' => 'company_name',
'value' => $validated['company_name'],
'created_by' => $user->id,
],
[
'name' => 'company_telephone',
'value' => $validated['company_telephone'],
'created_by' => $user->id,
],
]);
$user->assignRole(Role::findByName('company'));
Auth::login($user);
OnboardNewAccount::run($user->id);
});
return redirect()->route('onboarding.show');
}
}
class OnboardNewAccount
{
public static function run($userId): void
{
Bus::chain([
new SetupDefaultChartOfAccounts($userId),
new SetupDefaultChannels($userId),
new SetupDefaultForecastSettings($userId),
new MapDefaultChartOfAccounts($userId),
])->catch(function ($e) use ($userId) {
Log::error("Onboarding failed for account {$userId}: " . $e->getMessage());
})->onQueue('bulk')->dispatch();
}
}
Why This Works Better
- Non-blocking onboarding: users are no longer waiting on backend setup
- Clear separation of concerns
- Easier testing and maintenance
- Better fault tolerance (failed jobs don’t break registration)
- Scales cleanly as onboarding complexity grows
Result
The onboarding experience now feels instant and transparent, even though significant setup work is happening behind the scenes. Users are no longer confused by blank screens or slow transitions, and the codebase is cleaner, more maintainable, and far easier to evolve.
