DEV Community

Cover image for Building a Production-Grade Registration Module for Laravel SaaS — The Complete Breakdown
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building a Production-Grade Registration Module for Laravel SaaS — The Complete Breakdown

The Problem

Every SaaS project starts the same way. You scaffold authentication, wire up OAuth, handle email verification, build avatar logic, and set up session tracking. Then you do it all over again for your next project.

After my fourth SaaS project in three years, I decided to stop copy-pasting and start extracting.

LaraFoundry is a modular SaaS engine I'm building in public - extracted from Kohana.io, a production CRM/ERP system. Every module is battle-tested with real users before it's packaged.

This article is a complete technical breakdown of the first module: Registration.


What's Inside

The Registration module isn't just "create user + hash password." Here's what it handles:

  1. Multi-provider authentication - Form + OAuth2 (Google, Facebook, Twitter)
  2. Smart avatar system - Gravatar detection with generated fallback
  3. Session & device tracking - Full device fingerprinting on every login
  4. Auth event logging - 7 events logged automatically from Day 1
  5. Team onboarding - Company invitation with auto-acceptance

Let's dive into each one.


1. Multi-Provider Authentication

Form Registration

The traditional path. User submits name, email, password - standard Laravel validation with a configurable password minimum.

// RegisteredUserController.php
public function store(Request $request, UserLogoService $userLogo)
{
    $credentials = $request->validate([
        'name'     => 'required|string|max:255',
        'email'    => 'required|string|lowercase|email|max:255|unique:' . User::class,
        'password' => ['required', 'confirmed', 'min:' . config('own.minmal_password_lenght')],
    ]);

    $credentials['avatar']           = $userLogo->assignDefaultUserLogo($credentials, true);
    $credentials['last_login_at']    = now();
    $credentials['last_activity_at'] = now();
    $credentials['locale']           = Session::get('locale', config('app.locale', 'en'));

    $user = User::create($credentials);

    event(new Registered($user));   // Email verification
    $user->notify(new WelcomeEmailNotification);  // Queued welcome email

    Auth::login($user);

    return Inertia::location(route('home'));
}
Enter fullscreen mode Exit fullscreen mode

Notice what happens in a single request:

  • User created with auto-generated avatar
  • Registered event fires (triggers email verification)
  • Welcome email queued (non-blocking)
  • User logged in immediately
  • Session recorded with device info

OAuth2 Flow

LaraFoundry supports Google, Facebook, and Twitter via Laravel Socialite. The callback handler does everything in one request:

// OAuthLoginController.php
public function callback(string $provider, UserLogoService $userLogo)
{
    $socialUser = Socialite::driver($provider)->user();

    $user = User::updateOrCreate(
        ['email' => $socialUser->email],
        [
            'provider_id'            => $socialUser->id,
            'provider_name'          => $provider,
            'name'                   => $socialUser->name,
            'email_verified_at'      => now(),  // OAuth = pre-verified
            'provider_token'         => $socialUser->token,
            'provider_refresh_token' => $socialUser->refreshToken,
            'avatar'                 => $userLogo->assignDefaultUserLogo([
                'name'  => $socialUser->name,
                'email' => $socialUser->email,
            ], true),
            'last_login_at'  => now(),
            'locale'         => Session::get('locale', config('app.locale', 'en')),
        ]
    );

    Auth::login($user, session('oauth_remember_me', false));

    // Record session with device fingerprint
    $user->userSessions()->create([
        'session_id'       => request()->session()->getId(),
        'ip_address'       => request()->getClientIp(),
        'login_method'     => $provider,
        'user_agent'       => request()->userAgent(),
        'last_activity'    => now(),
        'user_device_type' => self::getUserDeviceType(),
        'user_device_name' => Agent::device(),
        'user_os'          => Agent::platform(),
        'user_browser'     => Agent::browser(),
    ]);

    // Auto-accept team invitation if token exists
    if (session()->has('invitation_token')) {
        $this->acceptInvitation(session('invitation_token'), $user);
    }

    return redirect()->route('home');
}
Enter fullscreen mode Exit fullscreen mode

Key design decisions:

  • updateOrCreate on email - If the user already exists (registered via form), their account is linked to the OAuth provider. No duplicate accounts.
  • email_verified_at = now() - OAuth providers have already verified the email. Skip the verification step.
  • Invitation token in session - Since OAuth redirects leave the page, the invitation token is stored in the session before the redirect and retrieved in the callback.

Frontend (Vue 3 + Inertia v2)

The registration form is a modal component using Inertia's useForm:

<!-- AppGuestRegisterModalLayout.vue -->
<script setup>
import { useForm } from '@inertiajs/vue3';

const form = useForm({
    name: '',
    email: props.invitation?.email || '',
    password: '',
    password_confirmation: '',
    invitation_token: props.invitation?.token || '',
});

const submit = () => {
    form.post(route('register'), {
        onSuccess: () => {
            form.reset('password', 'password_confirmation');
        },
    });
};
</script>
Enter fullscreen mode Exit fullscreen mode

OAuth buttons redirect to /oauth/{provider} with optional parameters for remember-me and invitation tokens.


2. Smart Avatar System

This is one of those "small details, big impact" features.

The Problem

When a user signs up, what do you show as their avatar? A grey circle? A generic silhouette? That screams "unfinished product."

The Solution: UserLogoService

LaraFoundry uses a 2-layer avatar pipeline:

User registers
    ↓
Check Gravatar (creativeorange/gravatar)
    ↓
Found? → Download → Resize to 128px → Save as JPEG
    ↓ No
Generate initials avatar (laravolt/avatar)
    → 15 curated colors
    → Bold typography (OpenSans/Rockwell)
    → Deterministic (same name = same color)
    ↓
Save to date-organized storage (user-logos/2026/02/{uuid}.jpg)
Enter fullscreen mode Exit fullscreen mode

Here's the actual service:

// UserLogoService.php
class UserLogoService
{
    public function assignDefaultUserLogo($userDataArray, $useGravatar = false)
    {
        $userLogoName = null;

        // Step 1: Try Gravatar
        if ($useGravatar && self::isGravatarExists($userDataArray['email'])) {
            $userLogoName = self::getAndStorageGravatar($userDataArray['email']);
        }

        // Step 2: Fallback to generated avatar
        if (!$useGravatar || $userLogoName === null) {
            $paths = self::userLogoPathPrepear();
            $avatar = new Avatar(config('laravolt.avatar'));
            $avatar->create($userDataArray['name'])
                   ->save($fullPath . '/' . $paths['uniqJpegFileName'], 100);
            return $paths['subfolder'] . '/' . $paths['uniqJpegFileName'];
        }

        return $userLogoName;
    }

    public static function isGravatarExists($email)
    {
        return Gravatar::exists($email);
    }

    public static function resizeAndSaveImage($image, $path)
    {
        return Image::read($image)->scale(height: 128)->toJpeg(90)->save($path);
    }

    public static function userLogoPathPrepear()
    {
        $folder    = config('own.user_logo_folder');
        $subfolder = Carbon::now()->format('Y') . '/' . Carbon::now()->format('m');

        Storage::disk('public')->makeDirectory($folder . '/' . $subfolder);

        return [
            'folder'           => $folder,
            'subfolder'        => $subfolder,
            'uniqJpegFileName' => Str::uuid() . '.jpg',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this matters

  • Gravatar users get their existing avatar - consistency across platforms
  • Everyone else gets a unique, colorful avatar - the app feels polished from second one
  • Deterministic colors - "John Smith" always gets the same color, making users recognizable in lists and comments
  • UUID filenames - zero collision risk, even at scale
  • Date-organized storage - easy cleanup, backup, and archival

Packages

Package Purpose
creativeorange/gravatar ~1.0 Check & download Gravatar
laravolt/avatar ^6.2 Generate initials-based avatars
intervention/image Resize & convert to JPEG

3. Session & Device Tracking

Every login in LaraFoundry creates a UserSession record:

$user->userSessions()->create([
    'session_id'       => $request->session()->getId(),
    'ip_address'       => $request->getClientIp(),
    'login_method'     => $provider,  // 'google', 'facebook', 'form'
    'user_agent'       => $request->userAgent(),
    'last_activity'    => now(),
    'user_device_type' => $this->getUserDeviceType(),  // desktop/tablet/mobile
    'user_device_name' => Agent::device(),              // e.g., 'Macintosh'
    'user_os'          => Agent::platform(),            // e.g., 'macOS'
    'user_browser'     => Agent::browser(),             // e.g., 'Chrome'
]);
Enter fullscreen mode Exit fullscreen mode

Device detection is handled by jenssegers/agent - a wrapper around Mobiledetect with a clean API.

What this enables

  • Security: Show users "Active Sessions" with device details (like GitHub does)
  • Analytics: Which devices are your users on? Do you need a mobile-optimized experience?
  • Support: "I can see you last logged in from Chrome on Windows at 3:42 PM - was that you?"
  • Compliance: GDPR access logs with full context

4. Auth Event Logging

This is the feature I wish I'd built from Day 1 in my first SaaS.

LaraFoundry uses spatie/laravel-activitylog with a declarative configuration:

// ActivityLogServiceProvider.php
protected array $events = [
    Registered::class     => ['Auth', 'New user registered', 200],
    Login::class          => ['Auth', 'Login', 200],
    Logout::class         => ['Auth', 'Logout', 200],
    Failed::class         => ['Auth', 'Login failed', 401],
    ProfileUpdate::class  => ['Auth', 'Profile update', 200],
    PasswordUpdate::class => ['Auth', 'Password update', 200],
    PasswordReset::class  => ['Auth', 'Password reset', 200],
];

public function boot()
{
    foreach ($this->events as $eventClass => [$group, $description, $code]) {
        Event::listen($eventClass, function ($event) use ($eventClass, $group, $description, $code) {
            ActivityLogService::logActivity(
                $event,
                class_basename($eventClass),
                $group,
                $description,
                $code
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The design

  • Declarative: Adding a new event = one line in the array
  • Grouped: Events belong to groups (Auth, Company, Tickets...) for filtering
  • HTTP-code aligned: Success = 200, failure = 401/500
  • Extensible: Events can implement getLogProperties() for custom data

Each log entry captures

  • User info (email, ID)
  • IP address
  • Device type, name, OS, browser
  • Route name + HTTP method
  • Full URL
  • Response code
  • Geo location (country, city)
  • Timestamp

Real-world value

  1. Suspicious activity: 5 failed logins from a new country → trigger alert
  2. OAuth analytics: 73% Google, 20% form, 7% Facebook → invest in Google UX
  3. GDPR compliance: Full audit trail of who accessed what, when, from where
  4. Support debugging: Instantly see a user's auth history when they report issues

5. Team Onboarding

In a B2B SaaS, registration doesn't end at "create account." Users often join through team invitations.

LaraFoundry handles this seamlessly:

// Works for both form registration and OAuth
private function acceptInvitation(string $token, User $user): void
{
    $invitation = CompanyInvitation::where('token', $token)
        ->with(['company', 'role'])
        ->first();

    if (!$invitation || !$invitation->canBeAccepted()) return;

    // CRITICAL: Prevent email spoofing
    if ($user->email !== $invitation->email) return;

    DB::transaction(function () use ($user, $invitation) {
        $invitation->company->addEmployee($user, $invitation->invited_by, $invitation->role);
        $invitation->markAsAccepted();
        event(new EmployeeInvitationAccepted($user, $invitation, $invitation->company));
    });
}
Enter fullscreen mode Exit fullscreen mode

The flow

  1. Admin sends invitation → generates unique token
  2. Invitee clicks link → registers or logs in (form or OAuth)
  3. Registration detects invitation token
  4. Validates email match (security)
  5. Adds user to company with assigned role
  6. Fires event for downstream processing (notifications, logging)

Tech Stack Summary

Layer Technology Version
Backend Laravel 12.x
PHP 8.3
Frontend Vue 3 + Inertia v2 3.x / 2.x
OAuth Laravel Socialite ^5.23
Avatars Gravatar + Laravolt Avatar ~1.0 / ^6.2
Logging Spatie Activity Log ^4.10
Device Detection Jenssegers Agent ^2.6
Image Processing Intervention Image latest
QR Codes SimpleQRCode ^4.2

6. Testing - Proof It Works

A registration module without tests is just a demo. Here's how I prove it works with Pest PHP.

Company creation flow:

it('user can create company with step 1 (basic info)', function () {
    Event::fake([CompanyCreate::class]);

    $response = $this->withCookie($cookieName, $sessionId)
        ->post(route('new_company.create.step1'), [
            'name' => 'Test Company',
            'country' => 'ukraine',
        ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('companies', [
        'name' => 'Test Company',
        'created_by_id' => $user->id,
    ]);

    $company = Company::where('name', 'Test Company')->first();
    expect($company->uuid)->not->toBeNull();
    expect($company->slug)->toContain('test-company');

    $this->assertDatabaseHas('company_user', [
        'company_id' => $company->id,
        'user_id' => $user->id,
        'is_owner' => true,
    ]);

    Event::assertDispatched(CompanyCreate::class);
});
Enter fullscreen mode Exit fullscreen mode

Email verification:

test('email can be verified', function () {
    $user = User::factory()->unverified()->create();
    Event::fake();

    $verificationUrl = URL::temporarySignedRoute(
        'verification.verify',
        now()->addMinutes(60),
        ['id' => $user->id, 'hash' => sha1($user->email)]
    );

    $this->actingAs($user)->get($verificationUrl);

    Event::assertDispatched(Verified::class);
    expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
});

test('email is not verified with invalid hash', function () {
    $user = User::factory()->unverified()->create();

    $verificationUrl = URL::temporarySignedRoute(
        'verification.verify',
        now()->addMinutes(60),
        ['id' => $user->id, 'hash' => sha1('wrong-email')]
    );

    $this->actingAs($user)->get($verificationUrl);
    expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});
Enter fullscreen mode Exit fullscreen mode

Edge cases:

it('step 1 generates unique slug when company name exists', function () {
    // Two companies with name "Duplicate Company"
    // Must get different slugs
    expect($company1->slug)->not->toBe($company2->slug);
});

it('sends company created email notification after step 1', function () {
    Queue::fake();
    // ...
    Queue::assertPushed(SendCompanyCreatedJob::class);
});

it('does not include sessions from other users', function () {
    // Session isolation test
    $sessions = collect($response->getData()['user']['user_sessions']);
    expect($sessions->pluck('session_id'))
        ->not->toContain('other-user-session');
});
Enter fullscreen mode Exit fullscreen mode

What the test suite covers:

  • Registration form (render, create, authenticate)
  • Email verification (valid hash, invalid hash, signed URLs)
  • Company creation (4-step flow, events, emails, slug uniqueness)
  • Session isolation (no cross-user leaks)
  • Invitation flow (job dispatch, promo codes)
  • Password reset (token-based flow)

What's Next

LaraFoundry is being built in public, module by module. Each module gets extracted from production code running on Kohana.io.

Coming up:

  • Roles & Permissions
  • Multi-tenancy & Team Management
  • Activity Logging (the full system)
  • Subscription & Billing

Want to follow along?


LaraFoundry is not a starter kit. It's a production-proven SaaS engine - extracted from a real product, battle-tested with real users. Build faster. Ship sooner. Focus on what makes your SaaS unique.

Top comments (0)