DEV Community

Cover image for Building an Admin User Management System for a Multi-Tenant Laravel SaaS
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building an Admin User Management System for a Multi-Tenant Laravel SaaS

You built a SaaS. Users signed up. Now you need to actually manage them. And "manage" means a lot more than a table with an edit button.

I built Kohana.io - a production CRM/ERP for small businesses. The admin user management module turned out to be one of the most complex pieces in the system. Now I'm extracting it into LaraFoundry, an open-source SaaS framework for Laravel.

This post covers the full implementation: CRUD, banning, impersonation, activity logging, and search/filtering.

Table of Contents

  1. CRUD Architecture
  2. Ban System
  3. Impersonation ("Follow")
  4. Activity Logging
  5. Search & Filtering
  6. Testing
  7. Design Decisions

CRUD Architecture

Create

User creation goes through AdminUserStoreRequest - validation happens before the controller.

Validated fields:

  • Required: name, email, password (with confirmation)
  • Optional: lastname, middlename, phone, sex, country, birth_date
  • Social links: array validation - URL must be valid, social network must exist in config

Password handling:

// User model
protected function password(): Attribute
{
    return Attribute::make(
        set: fn ($value) => Hash::make($value),
    );
}
Enter fullscreen mode Exit fullscreen mode

The controller never calls Hash::make(). The Eloquent Attribute mutator handles it. Create, update, import - doesn't matter. One place for hashing.

Social links are validated as an array:

'social_links' => 'nullable|array',
'social_links.*.url' => 'nullable|url|max:255',
'social_links.*.social_network' => 'required_with:social_links.*.url|in:' . implode(',', $configuredNetworks),
Enter fullscreen mode Exit fullscreen mode

Read (Index)

public function index(AdminUserFilterRequest $request, AdminUsersFilter $filter): Response
{
    $users = User::query()
        ->filter($filter)
        ->paginate(config('own.admin_users_per_page'));

    return Inertia::render('admin/users/Users', [
        'users' => UserResource::collection($users),
        'totalUsers' => User::count(),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Three key decisions here:

  1. Filter injection - AdminUsersFilter is injected, applied via scope. Controller doesn't know how filtering works.
  2. UserResource - one transformation layer for all responses. The Vue frontend gets consistent data.
  3. Config-driven pagination - page size lives in config, not hardcoded.

What the Admin Sees Per User

Every row in the admin users table shows:

Column Data
ID User ID
Avatar Profile image or default placeholder
Name Full name (lastname + name), with block/delete reason if applicable
Email / Phone Email with verification icon, phone with verification icon, social network links
Registration / Activity Registration date, last activity (with "long ago" warning highlight)
Companies Owned companies count / employee companies count
Country Localized country name
Sex Gender
Age Calculated from birth_date
Actions Create ticket, view logs, edit, block/unblock, delete/undelete, impersonate

The UserResource handles all transformation:

return [
    'id' => $this->id,
    'name' => trim($this->lastname . ' ' . $this->name),
    'avatar' => $this->avatar_url ?? '/images/default-user-logo.png',
    'email' => $this->email,
    'phone' => $this->phone,
    'email_verificated' => $this->email_verified_at !== null,
    'phone_verificated' => $this->phone_verified_at !== null,
    'social_links' => $this->socialLinks->map(/* ... */),
    'country' => $localizedCountryName,
    'age' => $this->birth_date?->age,
    'sex' => $genderLabel,
    'created_at' => $this->created_at->format('d-m-Y'),
    'last_activity_at' => $formattedActivity, // "5 min ago", "long_ago", "Never"
    'owned_companies_count' => $ownedCount,
    'employee_companies_count' => $employeeCount,
    'user_blocked_at' => $this->user_blocked_at,
    'block_code_text' => $formattedBlockReason, // "1. SPAM (3 days ago)"
    'user_deleted_at' => $this->user_deleted_at,
];
Enter fullscreen mode Exit fullscreen mode

Blocked users get a visual badge and the row is styled differently. Deleted users too. Action buttons change based on status: blocked users show "unblock" instead of "block", deleted users show "restore" instead of "delete".

Update

Social links are managed in a database transaction:

DB::transaction(function () use ($user, $validated) {
    $user->socialLinks()->delete();

    foreach ($validated['social_links'] ?? [] as $link) {
        if (trim($link['url'] ?? '') !== '') {
            $user->socialLinks()->create([
                'url' => $link['url'],
                'social_network' => $link['social_network'],
            ]);
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

Delete all existing, create new ones. Atomic operation. No orphaned records.

Email uniqueness on update ignores the current user:

'email' => ['required', 'email', Rule::unique(User::class)->ignore($this->route('user')->id)],
Enter fullscreen mode Exit fullscreen mode

The controller also supports a return_url parameter - so the admin lands back exactly where they navigated from.

Delete (Soft)

Deletion sets a user_deleted_at timestamp. Not Laravel's SoftDeletes trait - we use separate timestamps for blocking and deletion.

public function delete(User $user): RedirectResponse
{
    $user->update([
        'user_deleted_at' => now(),
        'user_blocked_at' => null,
        'user_blocked_status' => null,
    ]);

    return back()->with('success', __('User deleted'));
}
Enter fullscreen mode Exit fullscreen mode

Deleting clears block status. A user can't be both blocked and deleted.

Restoring is the reverse: clear user_deleted_at.


Ban System

Banning is not a boolean toggle. It's a multi-step process with events, notifications, and cascade effects.

Block Reasons (Config-Driven)

// config/own.php
'block_codes' => [
    1 => 'SPAM',
    2 => 'Violation of the rules of service',
    3 => 'Inappropriate behavior',
    4 => 'Fake account',
    5 => 'Reason 05',
]
Enter fullscreen mode Exit fullscreen mode

Add a ban reason = add a config entry. No code changes. No migrations.

Block Flow

When an admin clicks "Block":

  1. Database update - user_blocked_at = now(), user_blocked_status = $reasonId
  2. Event dispatch - event(new UserBlocked(auth()->user(), $user, $reasonId))
  3. Queue job - NotifyUserAboutBlocked::dispatch($user, $reasonId)

Two separate concerns: event for activity logging, job for email notification. The admin doesn't wait for SMTP.

Access Enforcement

CheckAccessMiddleware runs on every authenticated request. Three levels:

Level 1 - User ban:
The user is redirected to a "blocked" page. But they can still access:

  • Support tickets (to appeal the ban)
  • Notifications
  • Tutorials
  • Language switching
  • Logout
  • Leave impersonation

Locking someone out with zero recourse is bad UX and possibly a legal problem.

Level 2 - Company owner ban:
Every employee of the banned owner's company gets blocked. Different page, different message. They know it's the owner's issue, not theirs.

Level 3 - Payment expired:
Users can access payment pages and settings, but the rest of the app shows alerts.

Blocked User Page

The blocked user sees their ban reason and date:

public function show(Request $request): Response
{
    return Inertia::render('user/Blocked', [
        'blockReason' => $request->user()->getUserBlockedStatus(),
        'blockDate' => $request->user()->getUserBlockedDate(),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Access to this page is gated - only users with user_blocked_at !== null can see it.

Unblock

Clears timestamps, fires UserUnblocked event, dispatches NotifyUserAboutUnblocked job. Clean reverse.

Event Logging

Every block/unblock is captured in the activity log with full context:

public function getLogProperties(): array
{
    return [
        'user_id' => $this->user->id,
        'user_name' => $this->user->getFullname(),
        'user_email' => $this->user->email,
        'blocked_at' => $this->user->user_blocked_at?->toDateTimeString(),
        'block_reason_id' => $this->reasonId,
        'block_reason' => $reasonText,
    ];
}
Enter fullscreen mode Exit fullscreen mode

Who blocked whom, when, and why. Complete audit trail.


Impersonation

"I can't see that button on my dashboard."

One click - you're logged in as that user. Seeing exactly what they see.

Implementation

class ImpersonateController extends Controller
{
    public function take(Request $request, $userId, ImpersonateManager $impersonate)
    {
        $user = User::findOrFail($userId);
        $impersonate->take($request->user(), $user);
        return Inertia::location(route('home'));
    }

    public function leave(ImpersonateManager $impersonate)
    {
        $impersonate->leave();
        return Inertia::location(route('admin.users.index'));
    }
}
Enter fullscreen mode Exit fullscreen mode

Built on lab404/laravel-impersonate. The admin session is preserved.

Frontend Awareness

Every page knows if impersonation is active:

// HandleInertiaRequests middleware
'impersonate' => [
    'isActive' => $impersonate->isImpersonating()
]
Enter fullscreen mode Exit fullscreen mode

The Vue frontend can show a warning bar: "You are viewing as User X" with a "Leave" button.

Security

  • Route requires AdminAccess middleware
  • 2FA verification required
  • Activity log captures the event
  • "Leave impersonation" route is whitelisted even for banned users (admin can exit cleanly)
  • "Follow" button hidden for blocked or deleted users

UI

<Link @click.stop class="btn follow"
      :href="route('admin.impersonate.take', user.id)"
      method="post"
      :title="t('Follow')">
    <!-- person + arrow icon -->
</Link>
Enter fullscreen mode Exit fullscreen mode

Inertia <Link> component with POST method. No JavaScript fetch calls. No page reload after leaving - Inertia::location() handles full redirect.


Activity Logging

Every action in the system flows through ActivityLogServiceProvider. 30+ event types: login, logout, failed login, user blocked, company created, warehouse updated - everything.

What Gets Stored

Extended Spatie's Activity model with CustomActivity:

activity_log table:
├── core: log_name, description, subject_type, subject_id, causer_id
├── geo: geo_country, geo_city, geo_updated_at
├── device: user_device_type, user_device_name, user_os, user_browser
├── network: user_ip, user_agent
├── request: route_name, request_method, full_url, response_code
└── status: is_successful
Enter fullscreen mode Exit fullscreen mode

Plus custom event properties in the properties JSON column.

Logging Flow

  1. Event fires (e.g., UserBlocked)
  2. ActivityLogServiceProvider listener captures it
  3. ActivityLogService::logActivity() creates the record with all metadata
  4. RetrieveActivityGeoData job dispatched to resolve IP → country/city asynchronously

The geo lookup is a queued job. The original request isn't slowed by an external API call. Geo data appears in the activity log when the job completes.

Admin UI - User Activity Log

Route: GET /admin/logs/{user}?hours=1

Time range selector: Buttons for 1h, 6h, 12h, 24h, 48h, 72h. Because "show me the last hour" is 90% of debugging.

Table with interactive tooltips:

  • Hover IP → Device type, device name, OS, browser, country, city
  • Hover action → Event name, route name, middleware chain, request method, full URL, event properties

Desktop shows a table. Mobile shows cards. Same data, different layout via responsive Vue component.

General Activity Log

Route: GET /admin/logs?hours=24

System-wide activity. All events. 100 per page. Useful for spotting patterns across users.

Data Transformation

ActivityLogResource prepares the data:

'formatted_date' => $this->created_at->format('d.m.Y H:i:s'),
'human_date' => $this->created_at->locale(config('app.locale'))->diffForHumans(),
'response_code' => $this->response_code,
'is_successful' => $this->is_successful,
Enter fullscreen mode Exit fullscreen mode

Search and Filtering

Multi-Field Search

protected function search_string($value): Builder
{
    return $this->builder->where(function ($query) use ($value) {
        $query->where('id', $value)
            ->orWhere('name', 'like', "{$value}%")
            ->orWhere('lastname', 'like', "{$value}%")
            ->orWhere('middlename', 'like', "{$value}%")
            ->orWhere('email', 'like', "{$value}%")
            ->orWhere('phone', 'like', "{$value}%")
            ->orWhereRaw("lastname || ' ' || name LIKE ?", ["{$value}%"])
            ->orWhereRaw("name || ' ' || lastname LIKE ?", ["{$value}%"])
            ->orWhereRaw("lastname || ' ' || name || ' ' || middlename LIKE ?", ["{$value}%"])
            ->orWhereRaw("name || ' ' || lastname || ' ' || middlename LIKE ?", ["{$value}%"]);
    });
}
Enter fullscreen mode Exit fullscreen mode

"Smith John" works. "John Smith" works. "Smith" works. User ID works. Email prefix works.

Filter Auto-Discovery

Same pattern from the Traits & Middlewares module:

class AdminUsersFilter extends Filter
{
    protected function search_string($value) { /* multi-field search */ }
    protected function age_from($value) { /* birth_date <= now - age years */ }
    protected function age_to($value) { /* birth_date >= now - age years */ }
    protected function country($value) { /* exact match */ }
    protected function registrated($value) { /* today | this_month | this_year */ }
    protected function email_verificated($value) { /* verificated | unverificated */ }
    protected function phone_verificated($value) { /* verificated | unverificated */ }
    protected function recent_activity($value) { /* configurable threshold */ }
    protected function sex($value) { /* m | f */ }
}
Enter fullscreen mode Exit fullscreen mode

Request has ?age_from=25&country=DE? Base Filter class iterates request params, calls matching methods. No switch statements. No config mappings.

Validation

Every filter parameter goes through AdminUserFilterRequest:

'age_from' => 'nullable|integer|min:16|max:100',
'age_to' => 'nullable|integer|min:16|max:100',
'country' => 'nullable|string|in:' . implode(',', array_keys(config('app.available_countries'))),
'registrated' => 'nullable|string|in:all,today,this_month,this_year',
'recent_activity' => 'nullable|string|in:all,more,less_or_equal',
'email_verificated' => 'nullable|string|in:all,verificated,unverificated',
'sex' => 'nullable|string|in:m,f',
Enter fullscreen mode Exit fullscreen mode

Real-Time Search

Separate endpoint: GET /admin/users/search

public function search(AdminUserFilterRequest $request, AdminUsersFilter $filter): JsonResponse
{
    $users = User::query()
        ->where('email', '!=', config('own.admin_email'))
        ->filter($filter)
        ->paginate(config('own.admin_users_per_page'));

    return response()->json([
        'users' => UserResource::collection($users),
        'search' => $request->input('search_string'),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Returns JSON. Frontend component shows matching users as you type. Click a result = navigate to user.


Testing

Admin user management touches auth, permissions, events, jobs, middleware, and database. All tested through HTTP behavior.

CRUD

test('admin can create user with valid data', function () {
    actingAs($admin)
        ->post(route('admin.users.store'), [
            'name' => 'John',
            'email' => 'john@example.com',
            'password' => 'securepassword',
            'password_confirmation' => 'securepassword',
        ])
        ->assertRedirect();

    expect(User::where('email', 'john@example.com')->exists())->toBeTrue();
});

test('admin cannot create user with duplicate email', function () {
    User::factory()->create(['email' => 'taken@example.com']);

    actingAs($admin)
        ->post(route('admin.users.store'), [
            'name' => 'John',
            'email' => 'taken@example.com',
            'password' => 'securepassword',
            'password_confirmation' => 'securepassword',
        ])
        ->assertSessionHasErrors('email');
});
Enter fullscreen mode Exit fullscreen mode

Ban Cascade

test('blocking company owner blocks employees', function () {
    actingAs($admin)
        ->post(route('admin.users.block', $owner), ['reason_id' => 1]);

    actingAs($employee)
        ->get(route('dashboard'))
        ->assertRedirect(route('company.payment.blocked'));
});

test('blocked user can access support tickets', function () {
    actingAs($blockedUser)
        ->get(route('tickets.index'))
        ->assertOk();
});
Enter fullscreen mode Exit fullscreen mode

Events & Jobs

Event::fake();
Queue::fake();

actingAs($admin)
    ->post(route('admin.users.block', $user), ['reason_id' => 1]);

Event::assertDispatched(UserBlocked::class);
Queue::assertPushed(NotifyUserAboutBlocked::class);
Enter fullscreen mode Exit fullscreen mode

Impersonation

test('admin can impersonate user', function () {
    actingAs($admin)
        ->post(route('admin.impersonate.take', $user))
        ->assertRedirect(route('home'));
});

test('cannot impersonate blocked user', function () {
    // Follow button hidden in UI; route-level protection
});
Enter fullscreen mode Exit fullscreen mode

Pest v4 + Laravel's HTTP testing. No mocking middleware. No testing implementation details. Assert what users experience.


Design Decisions

  1. Timestamps, not booleans. user_blocked_at instead of is_blocked. You need to know WHEN it happened. "Blocked 3 days ago" is actionable. "Blocked: yes" tells you nothing.

  2. Events + jobs, not inline logic. Blocking fires an event (for logging) and a job (for notification). Two concerns, two mechanisms. The admin response isn't blocked by SMTP.

  3. Config-driven ban reasons. Adding a reason shouldn't require a migration or a code change. Config array entry is enough.

  4. Filter auto-discovery reuse. Same abstract Filter class from the Traits module. AdminUsersFilter just defines the methods. Zero framework code duplicated.

  5. Async geo lookup. Activity log stores device data synchronously (it's already in the request). Geo data resolved via queue job. Speed vs completeness trade-off.

  6. Separate timestamps for block and delete. A user can be blocked and later unblocked. Or blocked and then deleted (clearing block status). Different states, different semantics.

  7. Impersonation with guardrails. 2FA required. Activity logged. Frontend awareness built in. "Leave" route always available.


What's Next

This module is part of LaraFoundry - an open-source SaaS framework for Laravel, extracted from a production CRM/ERP.

  • GitHub: github.com/dmitryisaenko/larafoundry
  • Previous modules: Registration, Authentication, Multi-Tenancy, Logging, Multilanguage, Navigation, Vue Frontend, Traits & Middlewares

Follow for the next module deep-dive.

Top comments (0)