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
- CRUD Architecture
- Ban System
- Impersonation ("Follow")
- Activity Logging
- Search & Filtering
- Testing
- 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),
);
}
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),
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(),
]);
}
Three key decisions here:
-
Filter injection -
AdminUsersFilteris injected, applied via scope. Controller doesn't know how filtering works. - UserResource - one transformation layer for all responses. The Vue frontend gets consistent data.
- 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,
];
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'],
]);
}
}
});
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)],
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'));
}
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',
]
Add a ban reason = add a config entry. No code changes. No migrations.
Block Flow
When an admin clicks "Block":
-
Database update -
user_blocked_at = now(),user_blocked_status = $reasonId -
Event dispatch -
event(new UserBlocked(auth()->user(), $user, $reasonId)) -
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(),
]);
}
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,
];
}
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'));
}
}
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()
]
The Vue frontend can show a warning bar: "You are viewing as User X" with a "Leave" button.
Security
- Route requires
AdminAccessmiddleware - 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>
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
Plus custom event properties in the properties JSON column.
Logging Flow
- Event fires (e.g.,
UserBlocked) -
ActivityLogServiceProviderlistener captures it -
ActivityLogService::logActivity()creates the record with all metadata -
RetrieveActivityGeoDatajob 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,
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}%"]);
});
}
"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 */ }
}
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',
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'),
]);
}
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');
});
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();
});
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);
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
});
Pest v4 + Laravel's HTTP testing. No mocking middleware. No testing implementation details. Assert what users experience.
Design Decisions
Timestamps, not booleans.
user_blocked_atinstead ofis_blocked. You need to know WHEN it happened. "Blocked 3 days ago" is actionable. "Blocked: yes" tells you nothing.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.
Config-driven ban reasons. Adding a reason shouldn't require a migration or a code change. Config array entry is enough.
Filter auto-discovery reuse. Same abstract
Filterclass from the Traits module.AdminUsersFilterjust defines the methods. Zero framework code duplicated.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.
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.
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)