danwel - Architecture

Core architectural decisions and patterns for the danwel multi-tenant SaaS application.

---

Table of Contents



1. [Executive Summary](#executive-summary)
2. [Multi-Tenancy Strategy](#multi-tenancy-strategy)
3. [Core Architecture](#core-architecture)
4. [Security Design](#security-design)
5. [Roles & Permissions](#roles--permissions)
6. [Directory Structure](#directory-structure)

---

Executive Summary

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.).

---

Multi-Tenancy Strategy



Key Requirements



| Requirement | Description |
|-------------|-------------|
| **Multiple Organizations** | Each organization (tenant) has isolated data |
| **Multiple Users per Org** | Users belong to organizations with roles |
| **Multiple Calendars** | Users can connect multiple calendar accounts |
| **Multiple Invoicing** | Organizations can have multiple invoicing accounts |
| **Data Isolation** | Strict separation between organizations |
| **Future: Cross-org features** | Possible shared resources (templates, etc.) |

Tenancy Approach: Single Database with 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 |
|--------|-----------|---------------|
| **Complexity** | Simple | Complex migrations across DBs |
| **SpinupWP** | Perfect fit | Requires custom DB provisioning |
| **Cost** | Single MySQL instance | Multiple instances or complex routing |
| **Performance** | Excellent with indexes | Overhead per connection |
| **Cross-tenant queries** | Easy (admin analytics) | Very complex |
| **Migrations** | Single migration run | Must run per tenant |
| **Backups** | Single backup | Multiple backup configs |

**For your scale**: Single database is the right choice. Move to DB-per-tenant only if you need:
  • • Strict regulatory compliance (HIPAA, specific GDPR requirements)

  • • Tenants with vastly different data volumes (enterprise vs small)

  • • Independent scaling per tenant


  • Tenant Scoping Implementation

    **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);
    }
    }


    Organization Switching

    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

    ---

    Core Architecture



    Design Principles



  • • **SOLID Principles** - Clean, maintainable code

  • • **Strategy Pattern** - Swappable calendar/invoicing providers

  • • **Adapter Pattern** - Normalize different API responses

  • • **Repository Pattern** - Abstract data access

  • • **Service Layer** - Business logic separation

  • • **DTOs** - Type-safe data exchange

  • • **Global Scopes** - Automatic tenant isolation


  • High-Level Architecture



    ┌─────────────────────────────────────────────────────────────────────┐
    │ 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 │
    └─────────────────────────────────────────────────────────────────────┘


    Entity Relationship Overview



    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 Design



    Multi-Tenant Security Layers



    ┌─────────────────────────────────────────────────────────────────┐
    │ 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 │
    └─────────────────────────────────────────────────────────────────┘


    Token Storage Security

    **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',
    ];
    }


    Security Measures



    | Measure | Implementation |
    |---------|---------------|
    | **Encryption** | AES-256 for tokens (Laravel Crypt) |
    | **Password Hashing** | Argon2id |
    | **Session Security** | Encrypted, HTTP-only, SameSite |
    | **CSRF Protection** | All state-changing routes |
    | **Rate Limiting** | Per-user and per-IP |
    | **SQL Injection** | Eloquent ORM (parameterized) |
    | **XSS Prevention** | Blade auto-escaping |
    | **Tenant Isolation** | Global scopes + policies |
    | **Audit Logging** | All sensitive operations |

    Cross-Tenant Attack Prevention



    // 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);


    Security Checklist (Multi-Tenant)



  • • [ ] All tenant models use BelongsToOrganization trait

  • • [ ] Global scopes active on all tenant-scoped models

  • • [ ] Policies verify organization membership

  • • [ ] No raw queries without organization_id filter

  • • [ ] Cross-tenant access tests passing

  • • [ ] Invitation tokens are single-use and expire

  • • [ ] Role changes audited

  • • [ ] Organization deletion soft-deletes all data

  • • [ ] API requires organization context

  • • [ ] Tokens encrypted at rest
  • ---

    Roles & Permissions



    Role Hierarchy



    ┌─────────────────────────────────────────────────────────────────┐
    │ 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 │
    └─────────────────────────────────────────────────────────────────┘


    Permission Implementation

    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;
    }
    }

    ---

    Directory Structure



    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

    ---

    Summary: Multi-Tenant Architecture



    | Aspect | Decision |
    |--------|----------|
    | **Tenancy Model** | Single DB with organization_id |
    | **Isolation** | Global scopes + policies |
    | **User-Org Relationship** | Many-to-many with roles |
    | **Calendars** | Multiple per integration |
    | **Integrations** | Organization-level (shared by team) |
    | **Security** | 5-layer (auth → membership → role → scope → policy) |

    **Future-Proof for:**
  • • Subdomain routing (org.danwel.com)

  • • Per-tenant feature flags

  • • Enterprise SSO

  • • Database-per-tenant migration (if needed)

  • • Mobile/Tablet apps (Phase 2)