# 04 — Data Model & Portable Migrations Use Laravel Schema builder; avoid database-specific ENUM/DDL. Composite unique indexes include `tenant_id` for tenant-owned data. ## Core (Tenants, Users, Events, Settings, Analytics, Purchases, Ledger) ```php use Illuminate\\Database\\Migrations\\Migration; use Illuminate\\Database\\Schema\\Blueprint; use Illuminate\\Support\\Facades\\Schema; // Tenants Schema::create('tenants', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('slug')->unique(); $table->string('domain')->nullable()->unique(); $table->string('contact_name'); $table->string('contact_email'); $table->string('contact_phone')->nullable(); $table->integer('event_credits_balance')->default(1); $table->timestamp('free_event_granted_at')->nullable(); $table->integer('max_photos_per_event')->default(500); $table->integer('max_storage_mb')->default(1024); $table->json('features')->nullable(); $table->timestamp('last_activity_at')->nullable(); $table->timestamps(); }); // Users: tenancy + role (portable — avoid DB ENUM) Schema::table('users', function (Blueprint $table) { $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete(); $table->string('role', 32)->default('tenant_user')->index(); }); // Events: include tenant_id and composite unique Schema::table('events', function (Blueprint $table) { $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); $table->unique(['tenant_id', 'slug']); }); // System settings Schema::create('system_settings', function (Blueprint $table) { $table->id(); $table->string('key')->unique(); $table->text('value')->nullable(); $table->text('description')->nullable(); $table->boolean('is_public')->default(false); $table->timestamps(); }); // Platform analytics Schema::create('platform_analytics', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete(); $table->string('metric_name', 100); $table->bigInteger('metric_value'); $table->date('metric_date'); $table->json('metadata')->nullable(); $table->timestamps(); $table->index(['metric_date', 'metric_name']); $table->index(['tenant_id', 'metric_date']); }); // Event purchases (event credits) Schema::create('event_purchases', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); $table->unsignedInteger('events_purchased')->default(1); $table->decimal('amount', 10, 2); $table->string('currency', 3)->default('EUR'); $table->string('provider', 32); // app enum: app_store|play_store|stripe|paypal $table->string('external_receipt_id')->nullable(); $table->string('status', 16)->default('pending'); // app enum $table->timestamp('purchased_at')->nullable(); $table->timestamps(); $table->index(['tenant_id', 'purchased_at']); }); // Credits ledger Schema::create('event_credits_ledger', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); $table->integer('delta'); $table->string('reason', 32); // app enum $table->foreignId('related_purchase_id')->nullable()->constrained('event_purchases')->nullOnDelete(); $table->text('note')->nullable(); $table->timestamps(); $table->index(['tenant_id', 'created_at']); }); ``` ## Domain (Event Types, Events, Emotions, Tasks, Photos, Likes) ```php use Illuminate\\Database\\Migrations\\Migration; use Illuminate\\Database\\Schema\\Blueprint; use Illuminate\\Support\\Facades\\Schema; Schema::create('event_types', function (Blueprint $table) { $table->id(); $table->json('name'); // Translatable: { "de": "Hochzeit", "en": "Wedding" } $table->string('slug', 100)->unique(); $table->string('icon', 64)->nullable(); $table->json('settings')->nullable(); $table->timestamps(); }); Schema::create('events', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); $table->json('name'); // Translatable: { "de": "Event Name", "en": "Event Name" } $table->date('date'); $table->string('slug'); $table->json('description')->nullable(); // Translatable $table->json('settings')->nullable(); $table->foreignId('event_type_id')->constrained('event_types'); $table->boolean('is_active')->default(true); $table->string('default_locale', 5)->default('de'); // For event-specific i18n fallback $table->timestamps(); $table->unique(['tenant_id', 'slug']); }); Schema::create('emotions', function (Blueprint $table) { $table->id(); $table->json('name'); // Translatable: { "de": "Freude", "en": "Joy" } $table->string('icon', 50); $table->string('color', 7); $table->json('description')->nullable(); // Translatable $table->integer('sort_order')->default(0); $table->boolean('is_active')->default(true); }); // Pivot: emotion x event_type Schema::create('emotion_event_type', function (Blueprint $table) { $table->foreignId('emotion_id')->constrained('emotions')->cascadeOnDelete(); $table->foreignId('event_type_id')->constrained('event_types')->cascadeOnDelete(); $table->primary(['emotion_id', 'event_type_id']); }); Schema::create('tasks', function (Blueprint $table) { $table->id(); $table->foreignId('emotion_id')->constrained('emotions'); $table->foreignId('event_type_id')->nullable()->constrained('event_types')->nullOnDelete(); $table->json('title'); // Translatable $table->json('description'); // Translatable $table->string('difficulty', 16)->default('easy'); // app enum $table->json('example_text')->nullable(); // Translatable $table->integer('sort_order')->default(0); $table->boolean('is_active')->default(true); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); $table->string('scope', 16)->default('global'); // global|tenant|event $table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete(); $table->timestamps(); $table->unique(['tenant_id', 'emotion_id', 'title->de']); // Example for de fallback; adjust for multi-locale }); // Photos Schema::create('photos', function (Blueprint $table) { $table->id(); $table->foreignId('event_id')->constrained('events')->cascadeOnDelete(); $table->foreignId('emotion_id')->constrained('emotions'); $table->foreignId('task_id')->nullable()->constrained('tasks')->nullOnDelete(); $table->string('guest_name'); $table->string('file_path'); $table->string('thumbnail_path'); $table->unsignedInteger('likes_count')->default(0); $table->boolean('is_featured')->default(false); $table->json('metadata')->nullable(); $table->timestamps(); $table->index(['event_id', 'created_at']); }); // Photo likes Schema::create('photo_likes', function (Blueprint $table) { $table->id(); $table->foreignId('photo_id')->constrained('photos')->cascadeOnDelete(); $table->string('guest_name'); $table->string('ip_address', 45)->nullable(); $table->timestamp('created_at')->useCurrent(); $table->unique(['photo_id', 'guest_name', 'ip_address']); }); ``` ## Legal Pages ```php use Illuminate\\Database\\Schema\\Blueprint; use Illuminate\\Support\\Facades\\Schema; Schema::create('legal_pages', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); $table->string('slug', 32); // imprint|privacy|terms|custom $table->json('title'); // Translatable $table->json('body_markdown'); // Translatable Markdown content per locale $table->string('locale_fallback', 5)->default('de'); $table->unsignedInteger('version')->default(1); $table->timestamp('effective_from')->nullable(); $table->boolean('is_published')->default(false); $table->timestamps(); $table->unique(['slug', 'tenant_id', 'version']); }); ``` ## Notes - Prefer app-level enums (string columns + validation) over DB `ENUM`. - Use `cascadeOnDelete()` only where child data must be removed with parent; otherwise `nullOnDelete()`. - Every tenant-owned table should include `tenant_id` and appropriate composite indexes. - **i18n Integration**: JSON fields (e.g., `name`, `description`) store locale-specific values as `{ "de": "Text", "en": "Text" }`. Use Laravel's `json` cast or spatie/laravel-translatable for access. Fallback to `default_locale` or global fallback ('de'). Update via Filament resources with locale selectors. Ensure indexes on JSON paths if querying (e.g., `->de` for German titles).