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:
- Multi-provider authentication - Form + OAuth2 (Google, Facebook, Twitter)
- Smart avatar system - Gravatar detection with generated fallback
- Session & device tracking - Full device fingerprinting on every login
- Auth event logging - 7 events logged automatically from Day 1
- 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'));
}
Notice what happens in a single request:
- User created with auto-generated avatar
-
Registeredevent 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');
}
Key design decisions:
-
updateOrCreateon 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>
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)
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',
];
}
}
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'
]);
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
);
});
}
}
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
- Suspicious activity: 5 failed logins from a new country → trigger alert
- OAuth analytics: 73% Google, 20% form, 7% Facebook → invest in Google UX
- GDPR compliance: Full audit trail of who accessed what, when, from where
- 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));
});
}
The flow
- Admin sends invitation → generates unique token
- Invitee clicks link → registers or logs in (form or OAuth)
- Registration detects invitation token
- Validates email match (security)
- Adds user to company with assigned role
- 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);
});
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();
});
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');
});
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?
- Join the waitlist: larafoundry.com
- Follow on Twitter/X: @d_isaenko_dev
- Connect on LinkedIn: Dmitry Isaenko
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)