Core architectural decisions and patterns for the danwel multi-tenant SaaS application.
------
A modern, secure, and extensible **multi-tenant** Laravel SaaS application for weekly time planning with integrations to external calendar and invoicing services. Built with security-first principles, clean architecture patterns, and designed for organizations with multiple users and future integration extensibility (Office 365, Harvest, etc.).
---organization_id**Decision: Shared Database with Row-Level Isolation**
┌─────────────────────────────────────────────────────────────────┐
│ Single MySQL Database │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ organizations │ users │ time_blocks │ clients │ │
│ │ ↓ ↓ ↓ ↓ │ │
│ │ [org_id=1] [org_id=1] [org_id=1] [org_id=1] │ │
│ │ [org_id=2] [org_id=2] [org_id=2] [org_id=2] │ │
│ │ [org_id=3] [org_id=3] [org_id=3] [org_id=3] │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘**Why Single Database (not database-per-tenant)?**
| Factor | Single DB | DB per Tenant |**Global Scopes on Models** (automatic filtering):
// App\Models\Traits\BelongsToOrganization.php
trait BelongsToOrganization
{
protected static function bootBelongsToOrganization(): void
{
// Automatically scope all queries to current organization
static::addGlobalScope('organization', function (Builder $builder) {
if ($organizationId = app(TenantContext::class)->getOrganizationId()) {
$builder->where('organization_id', $organizationId);
}
});
// Automatically set organization_id on create
static::creating(function (Model $model) {
if (!$model->organization_id) {
$model->organization_id = app(TenantContext::class)->getOrganizationId();
}
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class);
}
}**Tenant Context Service**:
// App\Services\TenantContext.php
class TenantContext
{
private ?int $organizationId = null;
private ?Organization $organization = null;
public function setOrganization(Organization $organization): void
{
$this->organizationId = $organization->id;
$this->organization = $organization;
}
public function getOrganizationId(): ?int
{
return $this->organizationId;
}
public function getOrganization(): ?Organization
{
return $this->organization;
}
public function check(): bool
{
return $this->organizationId !== null;
}
}**Middleware to Set Context**:
// App\Http\Middleware\SetTenantContext.php
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
// Get user's current/default organization
$organization = $user->currentOrganization
?? $user->organizations()->first();
if ($organization) {
app(TenantContext::class)->setOrganization($organization);
}
}
return $next($request);
}
}Users can belong to multiple organizations (freelancers, consultants):
// User can switch between organizations via:
// - Session-stored "current_organization_id"
// - Subdomain (org1.danwel.com) - optional future
// - Header (X-Organization-Id) for API calls---
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend (Blade + Alpine.js) │
│ or future SPA (Vue/React/Inertia) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Laravel Application │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Tenant Context Layer │ │
│ │ (Organization resolved from user session/header) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────┐ ┌─────────────┐ │ ┌─────────────┐ │
│ │ Controllers │ │ Requests │ │ │ Resources │ │
│ │ (HTTP) │ │ (Validation)│ │ │ (JSON) │ │
│ └──────┬──────┘ └─────────────┘ │ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Service Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ Organization│ │ Calendar │ │ Invoicing │ │ │
│ │ │ Service │ │ Service │ │ Service │ │ │
│ │ └─────────────┘ └──────┬──────┘ └──────┬──────────────┘ │ │
│ └──────────────────────────┼────────────────┼─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Integration Manager (Strategy Pattern) │ │
│ │ │ │
│ │ CalendarProviderInterface InvoicingProviderInterface │ │
│ │ ├── GoogleCalendarProvider ├── MoneybirdProvider │ │
│ │ ├── Office365Provider ├── HarvestProvider │ │
│ │ └── [Future Providers] └── [Future Providers] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Eloquent Models │ │
│ │ Organization | User | Integration | TimeBlock | Client │ │
│ │ ↓ (tenant scoped via global scopes) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ MySQL Database (Encrypted at rest) │
│ All tenant data isolated by organization_id │
└─────────────────────────────────────────────────────────────────────┘Organization (Tenant)
├── Users (many, with roles)
│ └── Personal preferences
├── Integrations (many - org-level connections)
│ ├── Calendar Integrations (Google, Office365)
│ │ └── Calendars (multiple per integration)
│ └── Invoicing Integrations (Moneybird, Harvest)
├── Clients (synced from invoicing + local)
├── Projects (belong to clients)
└── TimeBlocks (created by users, org-scoped)
├── Calendar sync status
└── Invoicing sync status---
┌─────────────────────────────────────────────────────────────────┐
│ Security Layers │
├─────────────────────────────────────────────────────────────────┤
│ Layer 1: Authentication │
│ └── User must be logged in (Sanctum) │
├─────────────────────────────────────────────────────────────────┤
│ Layer 2: Organization Membership │
│ └── User must belong to the organization │
├─────────────────────────────────────────────────────────────────┤
│ Layer 3: Role-Based Access (RBAC) │
│ └── User must have permission for the action │
├─────────────────────────────────────────────────────────────────┤
│ Layer 4: Global Scopes (Automatic) │
│ └── All queries filtered by organization_id │
├─────────────────────────────────────────────────────────────────┤
│ Layer 5: Policy Authorization │
│ └── Resource-level access control │
└─────────────────────────────────────────────────────────────────┘**Critical Principle**: OAuth tokens encrypted in database with organization isolation.
class Integration extends Model
{
use BelongsToOrganization;
protected $casts = [
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'token_metadata' => 'encrypted:array',
'expires_at' => 'datetime',
];
}// NEVER trust user input for organization_id
// BAD - vulnerable to IDOR:
$client = Client::find($request->client_id);
// GOOD - scoped to tenant automatically:
$client = Client::find($request->client_id);
// Global scope adds: WHERE organization_id = ?
// EXTRA SAFE - explicit scope in critical operations:
$client = Client::where('organization_id', $tenant->id)
->findOrFail($request->client_id);BelongsToOrganization trait---
┌─────────────────────────────────────────────────────────────────┐
│ OWNER │
│ • Full access to everything │
│ • Manage billing & subscription │
│ • Delete organization │
│ • Transfer ownership │
├─────────────────────────────────────────────────────────────────┤
│ ADMIN │
│ • Manage users (invite, remove, change roles except owner) │
│ • Manage integrations (connect, disconnect) │
│ • Manage clients & projects │
│ • View all time blocks │
│ • Cannot delete org or manage billing │
├─────────────────────────────────────────────────────────────────┤
│ MEMBER │
│ • Create/edit/delete own time blocks │
│ • View clients & projects │
│ • View own integrations │
│ • Cannot manage users or org settings │
├─────────────────────────────────────────────────────────────────┤
│ VIEWER │
│ • Read-only access to calendar │
│ • View time blocks (all or assigned) │
│ • Cannot create/edit anything │
└─────────────────────────────────────────────────────────────────┘Using **Spatie Laravel Permission** or custom implementation:
// Simple role-based checks
class TimeBlockPolicy
{
public function viewAny(User $user): bool
{
// All roles can view time blocks
return true;
}
public function view(User $user, TimeBlock $timeBlock): bool
{
// Owners/admins can view all, members/viewers own only
return $user->hasRole(['owner', 'admin'])
|| $timeBlock->user_id === $user->id;
}
public function create(User $user): bool
{
return $user->hasRole(['owner', 'admin', 'member']);
}
public function update(User $user, TimeBlock $timeBlock): bool
{
return $user->hasRole(['owner', 'admin'])
|| $timeBlock->user_id === $user->id;
}
public function delete(User $user, TimeBlock $timeBlock): bool
{
return $user->hasRole(['owner', 'admin'])
|| $timeBlock->user_id === $user->id;
}
}---
app/
├── Console/
│ └── Commands/
│ ├── RefreshExpiredTokens.php
│ ├── SyncCalendars.php
│ └── CleanupAuditLogs.php
├── Contracts/
│ └── Integrations/
│ ├── CalendarProviderInterface.php
│ ├── InvoicingProviderInterface.php
│ └── ProviderInterface.php
├── DTOs/
│ ├── EventData.php
│ ├── TimeEntryData.php
│ ├── TokenData.php
│ ├── CalendarData.php
│ ├── ContactData.php
│ ├── ProjectData.php
│ └── DateRange.php
├── Exceptions/
│ ├── IntegrationException.php
│ ├── TokenExpiredException.php
│ ├── TenantNotFoundException.php
│ └── UnsupportedProviderException.php
├── Http/
│ ├── Controllers/
│ │ ├── Auth/
│ │ │ ├── AuthenticatedSessionController.php
│ │ │ ├── RegisteredUserController.php
│ │ │ └── InvitationController.php
│ │ ├── CalendarController.php
│ │ ├── ClientController.php
│ │ ├── DashboardController.php
│ │ ├── IntegrationController.php
│ │ ├── OrganizationController.php
│ │ ├── ProjectController.php
│ │ ├── SettingsController.php
│ │ ├── TeamController.php
│ │ └── TimeBlockController.php
│ ├── Middleware/
│ │ ├── EnsureOrganizationMember.php
│ │ ├── SetTenantContext.php
│ │ ├── RefreshTokenIfNeeded.php
│ │ └── SecurityHeaders.php
│ ├── Requests/
│ │ ├── Organization/
│ │ │ ├── StoreOrganizationRequest.php
│ │ │ └── UpdateOrganizationRequest.php
│ │ ├── TimeBlock/
│ │ │ ├── StoreTimeBlockRequest.php
│ │ │ └── UpdateTimeBlockRequest.php
│ │ └── Team/
│ │ └── InviteUserRequest.php
│ └── Resources/
│ ├── CalendarResource.php
│ ├── ClientResource.php
│ ├── IntegrationResource.php
│ ├── OrganizationResource.php
│ ├── ProjectResource.php
│ ├── TimeBlockResource.php
│ └── UserResource.php
├── Models/
│ ├── AuditLog.php
│ ├── Calendar.php
│ ├── Client.php
│ ├── Integration.php
│ ├── Invitation.php
│ ├── Organization.php
│ ├── Project.php
│ ├── TimeBlock.php
│ └── User.php
├── Models/Traits/
│ └── BelongsToOrganization.php
├── Observers/
│ ├── OrganizationObserver.php
│ └── TimeBlockObserver.php
├── Policies/
│ ├── CalendarPolicy.php
│ ├── ClientPolicy.php
│ ├── IntegrationPolicy.php
│ ├── OrganizationPolicy.php
│ ├── ProjectPolicy.php
│ └── TimeBlockPolicy.php
├── Providers/
│ ├── AppServiceProvider.php
│ ├── AuthServiceProvider.php
│ ├── EventServiceProvider.php
│ └── IntegrationServiceProvider.php
└── Services/
├── AuditService.php
├── CalendarService.php
├── InvoicingService.php
├── OrganizationService.php
├── TenantContext.php
├── TimeBlockService.php
└── Integrations/
├── IntegrationManager.php
├── Calendar/
│ ├── GoogleCalendarProvider.php
│ └── Office365CalendarProvider.php
└── Invoicing/
├── MoneybirdProvider.php
└── HarvestProvider.php
config/
├── integrations.php # All provider configs
└── tenancy.php # Multi-tenancy settings
database/
├── migrations/
│ ├── 0001_01_01_000000_create_users_table.php
│ ├── 2024_01_01_000001_create_organizations_table.php
│ ├── 2024_01_01_000002_create_organization_user_table.php
│ ├── 2024_01_01_000003_create_invitations_table.php
│ ├── 2024_01_01_000004_create_integrations_table.php
│ ├── 2024_01_01_000005_create_calendars_table.php
│ ├── 2024_01_01_000006_create_clients_table.php
│ ├── 2024_01_01_000007_create_projects_table.php
│ ├── 2024_01_01_000008_create_time_blocks_table.php
│ └── 2024_01_01_000009_create_audit_logs_table.php
└── seeders/
└── DemoOrganizationSeeder.php
resources/
└── views/
├── layouts/
│ └── app.blade.php
├── auth/
│ ├── login.blade.php
│ ├── register.blade.php
│ └── invitation.blade.php
├── calendar/
│ └── index.blade.php
├── components/
│ ├── calendar-grid.blade.php
│ ├── org-switcher.blade.php
│ └── time-block.blade.php
├── dashboard.blade.php
├── organizations/
│ ├── create.blade.php
│ └── settings.blade.php
├── settings/
│ ├── index.blade.php
│ ├── integrations.blade.php
│ └── team.blade.php
└── team/
├── index.blade.php
└── invite.blade.php
tests/
├── Feature/
│ ├── MultiTenancy/
│ │ ├── OrganizationIsolationTest.php
│ │ ├── OrganizationSwitchingTest.php
│ │ └── CrossTenantAccessTest.php
│ ├── CalendarTest.php
│ ├── IntegrationTest.php
│ └── TimeBlockTest.php
└── Unit/
└── Services/
└── TenantContextTest.php---
organization_id |org.danwel.com)