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
- Company Dashboard
- Subscription Status Engine
- Blocking System
- Subscription Expiry Notifications
- Frontend Blocked Pages
- Payment Model
- Admin Filtering & Search
- Testing
- 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'),
]);
}
Key decisions:
- Subqueries for aggregates - total payments and last payment calculated in SQL, not PHP. No N+1.
-
Filter injection -
AdminCompaniesFilterapplied via scope. Controller doesn't know filtering internals. - Config-driven everything - pagination, plans, countries all from config files.
- 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,
];
}
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';
}
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;
}
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;
}
}
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);
}
}
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,
};
});
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);
}
}
}
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>
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)(),
];
}
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'; }
}
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,
];
}
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(),
]),
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}%"));
});
}
}
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',
];
}
}
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);
}
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();
});
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();
});
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();
});
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();
});
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);
});
Pest v4. Real HTTP requests. No middleware mocking. The tests prove the behavior users experience.
Design Decisions
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.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.
Three block reasons, not one. "Company blocked" is useless information. "Owner banned" vs "payment expired" vs "never paid" - each requires different user action.
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.
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.
Unique notification codes.
subscription_expiring_10d_42ensures the same notification is never sent twice. Cron-safe.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.
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)