DEV Community

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

Posted on

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

Companies sign up. They pick a plan. They start paying. Some stop paying. Some owners get banned. Some trials expire and nobody notices.

I built Kohana.io - a production CRM/ERP for small businesses. The admin companies module became the financial nerve center of the whole system. Now I'm extracting it into LaraFoundry, an open-source SaaS framework for Laravel.

This post covers the full implementation: company dashboard, subscription engine, blocking system, expiry notifications, payment model, frontend blocked pages, and testing.

Table of Contents

  1. Company Dashboard
  2. Subscription Status Engine
  3. Blocking System
  4. Subscription Expiry Notifications
  5. Frontend Blocked Pages
  6. Payment Model
  7. Admin Filtering & Search
  8. Testing
  9. Design Decisions

Company Dashboard

What the Admin Sees

Every company row in the admin panel shows a complete operational picture:

Company info - name, logo, country, creation date
Owner info - full name, email, phone, age, companies count, last activity, blocked status badge
Subscription - plan name, status badge with color, billing period, expiry date
Finances - total payments sum, last payment date and amount, full payment history on hover
Activity - employee count (excluding soft-deleted), last activity across all users

The Query

public function index(AdminCompaniesFilterRequest $request, AdminCompaniesFilter $filter): Response
{
    $companies = Company::query()
        ->with(['createdBy', 'users', 'payments'])
        ->withCount(['users' => fn($q) => $q->wherePivot('is_deleted', false)])
        ->addSelect([
            'total_payments' => CompanyPayment::selectRaw('SUM(amount)')
                ->whereColumn('company_id', 'companies.id')
                ->where('payment_status', 'success'),
            'last_payment_date' => CompanyPayment::select('paid_at')
                ->whereColumn('company_id', 'companies.id')
                ->where('payment_status', 'success')
                ->latest('paid_at')
                ->limit(1),
        ])
        ->filter($filter)
        ->paginate(config('own.admin_companies_per_page'));

    return Inertia::render('admin/companies/IndexCompanies', [
        'companies' => AdminCompanyResource::collection($companies),
        'pagination' => /* pagination data */,
        'filters' => $request->validated(),
        'available_plans' => config('own.company_plans'),
        'available_countries' => config('app.available_countries'),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  1. Subqueries for aggregates - total payments and last payment calculated in SQL, not PHP. No N+1.
  2. Filter injection - AdminCompaniesFilter applied via scope. Controller doesn't know filtering internals.
  3. Config-driven everything - pagination, plans, countries all from config files.
  4. AdminCompanyResource - single transformation layer. The Vue frontend gets consistent, pre-formatted data.

AdminCompanyResource

The resource does heavy lifting:

public function toArray($request): array
{
    $owner = $this->createdBy;

    return [
        // Company
        'id' => $this->id,
        'uuid' => $this->uuid,
        'name' => $this->name,
        'country_name' => $this->getCountryName(),

        // Owner
        'owner_fullname' => $owner?->getFullname(),
        'owner_email' => $owner?->email,
        'owner_is_blocked' => $owner?->user_blocked_at !== null,
        'owner_last_activity' => $this->formatLastActivity($owner?->last_activity_at),

        // Subscription
        'plan_name' => $this->getPlanName(),
        'subscription_status' => $this->getSubscriptionStatus(),
        'subscription_status_text' => $this->getSubscriptionStatusText(),
        'subscription_ends_at_formatted' => $this->subscription_ends_at?->format('d-m-Y'),

        // Finances
        'total_payments_sum' => $this->total_payments,
        'last_payment_date' => $this->last_payment_date,

        // Activity
        'employees_count' => $this->users_count,
    ];
}
Enter fullscreen mode Exit fullscreen mode

Country names translated from config. Plan names resolved from config. Subscription status calculated with priority logic. Last activity formatted as "Recently", "Long time ago", or exact date.


Subscription Status Engine

A company can be in 5 states. The logic has a specific priority order:

private function getSubscriptionStatus(): string
{
    $company = $this->resource;

    // Priority 1: Never started
    if ($company->isInSetup()) {
        return 'never_activated';
    }

    // Priority 2: Trial is running
    if ($company->isOnTrial()) {
        return 'trial';
    }

    // Priority 3: Subscription ended
    if (!$company->subscription_ends_at || $company->subscription_ends_at->isPast()) {
        return 'expired';
    }

    // Priority 4: Expiring soon (within 7 days)
    if ($company->subscription_ends_at->diffInDays(now()) <= 7) {
        return 'expiring';
    }

    // Priority 5: All good
    return 'active';
}
Enter fullscreen mode Exit fullscreen mode

Each status gets a visual indicator in the admin panel:

  • active - company is paying
  • expiring - less than 7 days left (yellow flag - retention opportunity)
  • expired - subscription ended, access restricted
  • trial - free trial period running
  • never_activated - signed up, never picked a plan

Company Model Access Methods

public function isOnTrial(): bool
{
    return $this->trial_ends_at && $this->trial_ends_at->isFuture();
}

public function hasActiveSubscription(): bool
{
    return $this->subscription_ends_at && $this->subscription_ends_at->isFuture();
}

public function hasAccess(): bool
{
    return $this->isOnTrial() || $this->hasActiveSubscription();
}

public function isInSetup(): bool
{
    return !$this->plan_id && !$this->trial_ends_at && !$this->subscription_ends_at;
}
Enter fullscreen mode Exit fullscreen mode

hasAccess() checks both trial and subscription. A company on trial with an expired subscription still has access. Trial takes priority.


Blocking System

A company can be blocked for three distinct reasons. Each one triggers a different user experience.

GetBlockStatusAction

The single source of truth for access status:

class GetBlockStatusAction
{
    public function __invoke(): array
    {
        $user = auth()->user();
        $company = $user?->getActiveCompany();

        $status = [
            'user_blocked' => false,
            'user_ban_reason' => null,
            'company_blocked' => false,
            'company_block_reason' => '',
            'subscription_expires_soon' => false,
            'days_until_expiry' => null,
        ];

        // Level 1: User personally banned
        if ($user->user_blocked_at !== null) {
            $status['user_blocked'] = true;
            $status['user_ban_reason'] = /* from config */;
            return $status;
        }

        // Level 2: Company owner banned
        $owner = $company->createdBy;
        if ($owner->user_blocked_at !== null) {
            $status['company_blocked'] = true;
            $status['company_block_reason'] = 'owner_banned';
            return $status;
        }

        // Level 3: Payment issue
        if (!$company->hasAccess()) {
            $status['company_blocked'] = true;
            $status['company_block_reason'] = $hadSubscriptionBefore
                ? 'payment_expired'
                : 'first_payment_required';
            return $status;
        }

        // Warning: subscription expiring within 10 days
        if ($company->hasActiveSubscription()) {
            $daysLeft = now()->diffInDays($company->subscription_ends_at, false);
            if ($daysLeft <= 10 && $daysLeft > 0) {
                $status['subscription_expires_soon'] = true;
                $status['days_until_expiry'] = $daysLeft;
            }
        }

        return $status;
    }
}
Enter fullscreen mode Exit fullscreen mode

Three blocking scenarios with clear priority:

1. Owner banned - Admin blocked the owner. Every employee in the company is locked out. They see: "Your company owner's account has been restricted." Not their fault - different message.

2. First payment required - Company finished trial or skipped it. Never made a payment. Owner sees billing link. Non-owner sees "contact administrator".

3. Payment expired - Had a subscription, it ended. Same owner/employee split as above.

CheckAccessMiddleware

Enforces blocking at the request level:

class CheckAccessMiddleware
{
    public function handle($request, Closure $next)
    {
        $user = $request->user();

        // Priority 1: User personally banned
        if ($user->user_blocked_at !== null) {
            return $this->handleBannedUser($request);
        }

        // Priority 2: Company access check
        if ($user->getActiveCompany()) {
            $check = $this->checkCompanyAccess($user);
            if (!$check['allowed']) {
                return $this->handlePaymentRequired($request, $check);
            }
        }

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Banned user whitelist - even banned users can access:

  • Support tickets (to appeal)
  • Notifications
  • Tutorials
  • Language switching
  • Logout
  • Leave impersonation
  • Switch companies

That last one is important. If the owner is banned on Company A but has Company B with active subscription - they switch to B and keep working. The block is per-company, not per-user.

Gates

// UserBlockedGates
Gate::define('view-company-payment-blocked-page', function (User $user, string $type) {
    return match ($type) {
        'owner_banned' => $user->getActiveCompany()?->createdBy?->user_blocked_at !== null,
        'payment_required', 'payment_required_non_owner'
            => !$user->getActiveCompany()?->hasAccess(),
        default => false,
    };
});
Enter fullscreen mode Exit fullscreen mode

Direct URL protection. Users can't access the blocked page unless they're actually blocked.


Subscription Expiry Notifications

The system warns users before blocking them.

Scheduled Command

class CheckExpiringSubscriptions extends Command
{
    protected $signature = 'subscriptions:check-expiring';

    public function handle(): void
    {
        $this->checkAndNotify(daysBeforeExpiry: 10);
        $this->checkAndNotify(daysBeforeExpiry: 3);
    }

    private function checkAndNotify(int $daysBeforeExpiry): void
    {
        $targetDate = now()->addDays($daysBeforeExpiry)->toDateString();

        $companies = Company::query()
            ->whereDate('subscription_ends_at', $targetDate)
            ->get();

        foreach ($companies as $company) {
            $owner = $company->createdBy;

            // Skip if no owner or owner is banned
            if (!$owner || $owner->user_blocked_at !== null) {
                continue;
            }

            // Unique code prevents duplicates
            $code = "subscription_expiring_{$daysBeforeExpiry}d_{$company->id}";

            if (Notification::where('code', $code)->exists()) {
                continue;
            }

            // Create notification with all locale translations
            $notification = Notification::create([
                'code' => $code,
                'type' => 'subscription_expiring',
                // ... translations, data with company name, days, expiry date, payment URL
            ]);

            $notification->users()->attach($owner->id);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

10 days = friendly reminder. "Hey, renew when you can."
3 days = urgent. "You're about to lose access."

Unique notification codes (subscription_expiring_10d_42) prevent spam. Cron runs daily, notifications are created once.

Banned owners are skipped - no point notifying someone who can't access the system anyway.


Frontend Blocked Pages

CompanyBlocked.vue

One component handles all three blocking scenarios:

<script setup>
const props = defineProps({
    blockType: String,     // 'user_banned' | 'payment_required' | 'payment_required_non_owner'
    isOwner: Boolean,
    companyName: String,
});

const config = computed(() => ({
    user_banned: {
        icon: 'ban',
        title: 'Account blocked',
        description: 'All company operations are unavailable.',
        action: { label: 'Contact support', href: route('tickets.create') },
    },
    payment_required: {
        icon: 'credit-card',
        title: 'Subscription expired',
        description: 'Renew your subscription to continue.',
        action: { label: 'Go to billing', href: route('company.billing') },
    },
    payment_required_non_owner: {
        icon: 'credit-card',
        title: 'Subscription expired',
        description: 'Please contact your company administrator.',
        action: { label: 'Logout', href: route('logout') },
    },
}[props.blockType]));
</script>
Enter fullscreen mode Exit fullscreen mode

Different icons, titles, descriptions, and calls-to-action. The owner gets a billing link. The employee gets "contact your admin". The banned user gets support tickets.

Inertia Shared Props

// HandleInertiaRequests middleware
public function share(Request $request): array
{
    return [
        // ... other shared data
        'block_status' => ($this->getBlockStatus)(),
    ];
}
Enter fullscreen mode Exit fullscreen mode

Every page has access to block_status. The Vue frontend can:

  • Show warning banners: "Your subscription expires in 3 days"
  • Disable features incrementally
  • Display countdown timers

The middleware blocks. Inertia warns. Two different mechanisms for two different purposes.

Responsive design: desktop shows side-by-side layout with icon and message, mobile stacks vertically. Dark mode supported via CSS variables.


Payment Model

CompanyPayment

class CompanyPayment extends Model
{
    // Fields
    // company_id, user_id, plan_id, billing_period
    // amount, currency, discount_amount, discount_reason, promo_code_id
    // payment_status, payment_method, payment_response (JSON)
    // paid_at, period_start, period_end

    protected function casts(): array
    {
        return [
            'amount' => 'decimal:2',
            'discount_amount' => 'decimal:2',
            'payment_response' => 'array',
            'paid_at' => 'datetime',
            'period_start' => 'date',
            'period_end' => 'date',
        ];
    }

    public function markAsPaid(?array $response = null): void
    {
        $this->update([
            'payment_status' => 'success',
            'paid_at' => now(),
            'payment_response' => $response,
        ]);
    }

    public function markAsFailed(?array $response = null): void
    {
        $this->update([
            'payment_status' => 'failed',
            'payment_response' => $response,
        ]);
    }

    public function getTotalAmount(): float
    {
        return $this->amount - $this->discount_amount;
    }

    public function isSuccessful(): bool { return $this->payment_status === 'success'; }
    public function isPending(): bool { return $this->payment_status === 'pending'; }
    public function isFailed(): bool { return $this->payment_status === 'failed'; }
}
Enter fullscreen mode Exit fullscreen mode

Clean state transitions. markAsPaid() sets status, timestamp, and stores raw gateway response. No string comparisons scattered across controllers.

Events

Every successful payment fires CompanyPaymentProcessed:

public function getLogProperties(): array
{
    return [
        'company_id' => $this->company->id,
        'company_name' => $this->company->name,
        'payment_id' => $this->payment->id,
        'plan_id' => $this->payment->plan_id,
        'amount' => $this->payment->amount,
        'currency' => $this->payment->currency,
        'discount_amount' => $this->payment->discount_amount,
        'payment_status' => $this->payment->payment_status,
        'period_start' => $this->payment->period_start,
        'period_end' => $this->payment->period_end,
    ];
}
Enter fullscreen mode Exit fullscreen mode

Full audit trail. Every dollar tracked.

Admin View

In the admin panel, payment data is displayed in two layers:

Table level: Total payments sum and last payment date (calculated via SQL subquery)
Tooltip level: Full payment history with individual amounts, discounts, and dates

// AdminCompanyResource
'payments_history' => $this->payments->map(fn ($p) => [
    'id' => $p->id,
    'date' => $p->paid_at?->format('d-m-Y'),
    'amount' => $p->amount,
    'discount' => $p->discount_amount,
    'currency' => $p->currency,
    'total' => $p->getTotalAmount(),
]),
Enter fullscreen mode Exit fullscreen mode

Admin Filtering and Search

AdminCompaniesFilter

class AdminCompaniesFilter extends Filter
{
    protected function subscription_status(?string $value): Builder
    {
        return match ($value) {
            'active' => $this->builder
                ->where('subscription_ends_at', '>', now())
                ->where('subscription_ends_at', '>', now()->addDays(7)),
            'expiring' => $this->builder
                ->where('subscription_ends_at', '>', now())
                ->where('subscription_ends_at', '<=', now()->addDays(7)),
            'expired' => $this->builder
                ->where('subscription_ends_at', '<=', now())
                ->whereNotNull('subscription_ends_at'),
            'trial' => $this->builder
                ->where('trial_ends_at', '>', now())
                ->whereNotNull('trial_ends_at'),
            'never_activated' => $this->builder
                ->whereNull('plan_id')
                ->whereNull('trial_ends_at')
                ->whereNull('subscription_ends_at'),
        };
    }

    protected function plan_id(?string $value): Builder { /* exact match */ }
    protected function country(?string $value): Builder { /* exact match */ }
    protected function date_from(?string $value): Builder { /* created_at >= */ }
    protected function date_to(?string $value): Builder { /* created_at <= */ }

    protected function search_string(?string $value): Builder
    {
        return $this->builder->where(function ($q) use ($value) {
            $q->where('name', 'like', "%{$value}%")
              ->orWhereHas('createdBy', fn($q) => $q->where('email', 'like', "%{$value}%"));
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Same auto-discovery pattern from previous modules. Request has ?subscription_status=expiring&plan_id=basic? Base Filter class iterates params, calls matching methods.

Validation

class AdminCompaniesFilterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'subscription_status' => 'nullable|in:active,expiring,expired,trial,never_activated',
            'plan_id' => 'nullable|in:basic,optimal,advanced',
            'country' => 'nullable|string',
            'date_from' => 'nullable|date',
            'date_to' => 'nullable|date|after_or_equal:date_from',
            'search_string' => 'nullable|string|max:255',
            'sort_by' => 'nullable|in:created_at,subscription_ends_at,employees_count,payments_sum',
            'sort_order' => 'nullable|in:asc,desc',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Everything validated before reaching the filter. Invalid params can't cause SQL errors.

Quick Search

public function search(Request $request): JsonResponse
{
    $companies = Company::query()
        ->where('name', 'like', "%{$search}%")
        ->orWhereHas('createdBy', fn($q) => $q->where('email', 'like', "%{$search}%"))
        ->limit(15)
        ->get(['id', 'name']);

    return response()->json($companies);
}
Enter fullscreen mode Exit fullscreen mode

Separate JSON endpoint. 15 results max. Used in admin navigation for fast company lookup.


Testing

Subscription Status

test('company with future subscription has access', function () {
    $company = Company::factory()->create([
        'subscription_ends_at' => now()->addMonth(),
    ]);

    expect($company->hasAccess())->toBeTrue()
        ->and($company->hasActiveSubscription())->toBeTrue();
});

test('company on trial has access even without subscription', function () {
    $company = Company::factory()->create([
        'trial_ends_at' => now()->addDays(14),
        'subscription_ends_at' => null,
    ]);

    expect($company->hasAccess())->toBeTrue()
        ->and($company->isOnTrial())->toBeTrue();
});

test('company with expired subscription has no access', function () {
    $company = Company::factory()->create([
        'subscription_ends_at' => now()->subDay(),
        'trial_ends_at' => now()->subMonth(),
    ]);

    expect($company->hasAccess())->toBeFalse();
});
Enter fullscreen mode Exit fullscreen mode

Ban Cascade

test('blocking owner blocks employee access', function () {
    $owner->update(['user_blocked_at' => now(), 'user_blocked_status' => 1]);

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

test('employee can switch to another company when owner is banned', function () {
    // Employee belongs to companyA (owner banned) and companyB (active)
    actingAs($employee)
        ->post(route('company.switch', $companyB))
        ->assertRedirect();
});
Enter fullscreen mode Exit fullscreen mode

Payment Events

test('successful payment fires CompanyPaymentProcessed', function () {
    Event::fake();

    $payment->markAsPaid(['gateway_status' => 'ok']);

    Event::assertDispatched(CompanyPaymentProcessed::class);
    expect($payment->isSuccessful())->toBeTrue()
        ->and($payment->paid_at)->not->toBeNull();
});
Enter fullscreen mode Exit fullscreen mode

Expiry Notifications

test('expiring subscription triggers notification at 10 days', function () {
    $company = Company::factory()->create([
        'subscription_ends_at' => now()->addDays(10),
    ]);

    Artisan::call('subscriptions:check-expiring');

    expect(Notification::where('code', "subscription_expiring_10d_{$company->id}")->exists())
        ->toBeTrue();
});

test('banned owner does not receive expiry notification', function () {
    $company = Company::factory()->create([
        'subscription_ends_at' => now()->addDays(10),
    ]);
    $company->createdBy->update(['user_blocked_at' => now()]);

    Artisan::call('subscriptions:check-expiring');

    expect(Notification::where('code', "subscription_expiring_10d_{$company->id}")->exists())
        ->toBeFalse();
});
Enter fullscreen mode Exit fullscreen mode

Admin Filtering

test('admin can filter companies by subscription status', function () {
    actingAs($admin)
        ->get(route('admin.companies.index', ['subscription_status' => 'expired']))
        ->assertOk()
        ->assertInertia(fn ($page) => $page->has('companies.data'));
});

test('admin search finds company by owner email', function () {
    actingAs($admin)
        ->get(route('admin.companies.search', ['search' => $owner->email]))
        ->assertOk()
        ->assertJsonCount(1);
});
Enter fullscreen mode Exit fullscreen mode

Pest v4. Real HTTP requests. No middleware mocking. The tests prove the behavior users experience.


Design Decisions

  1. hasAccess() checks both trial AND subscription. A company on trial with an expired subscription still works. Trial takes priority over subscription status. No double penalty.

  2. GetBlockStatusAction as single source of truth. One place calculates all blocking logic. Middleware uses it. Frontend uses it (via Inertia shared props). No duplicated conditions.

  3. Three block reasons, not one. "Company blocked" is useless information. "Owner banned" vs "payment expired" vs "never paid" - each requires different user action.

  4. Owner/employee split on blocked pages. Owner can fix the problem (pay, appeal). Employee can't. Showing an employee a billing link makes no sense.

  5. Per-company blocking, not per-user. User owns Company A (banned owner) and Company B (active). They can switch to B. Blocking one company shouldn't kill their entire account.

  6. Unique notification codes. subscription_expiring_10d_42 ensures the same notification is never sent twice. Cron-safe.

  7. Subqueries for aggregates in admin. Total payments, last payment date - calculated in SQL. One query with subqueries is faster than loading all payments and summing in PHP.

  8. Config-driven plans and statuses. Plan names, subscription status thresholds (7 days for "expiring"), pagination limits - all in config. Changing a threshold doesn't require code changes.


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, Admin Users

Follow for the next module deep-dive.

Top comments (0)