Zum Hauptinhalt springen
Schneespur
Dokumentation durchblättern

Module entwickeln

Datenbank & Migrationen

Wie Module eigene Tabellen anlegen, warum jede Tabelle ein mod-Präfix trägt und wie Sie die 64-Zeichen-Grenze von MySQL umgehen, die in den Tests unsichtbar bleibt.

Ein Modul, das Daten speichert, bringt seine eigenen Tabellen mit — über Laravel-Migrationen im Modul-Verzeichnis. Diese Seite zeigt, wo die Migrationen liegen, wie Tabellen benannt werden und warum eine Migration grün durch die Tests laufen und trotzdem auf der echten Datenbank scheitern kann.

Welche Datenbank läuft wo

Schneespur läuft in Produktion und Staging auf MySQL / MariaDB. Alle Module teilen sich diese eine Datenbank.

SQLite kommt ausschließlich in der Testumgebung zum Einsatz (:memory:), damit die Test-Suite keinen externen Server braucht. Diese Trennung ist wichtig: SQLite ist deutlich nachsichtiger als MySQL. Eine Migration, die vendor/bin/phpunit besteht, kann auf der Live-Datenbank dennoch fehlschlagen. Der häufigste Fallstrick ist die 64-Zeichen-Grenze weiter unten — schreiben Sie Migrationen gegen die Regeln von MySQL, nicht gegen die von SQLite.

Wo die Migrationen liegen

Legen Sie Migrationen im Verzeichnis database/migrations/ Ihres Moduls ab:

modules/my-module/
  database/
    migrations/
      2026_06_01_000001_create_mod_my_module_documents_table.php
      2026_06_01_000002_create_mod_my_module_settings_table.php

Sie müssen die Migrationen nicht von Hand starten: schneespur:modules-sync führt sie nach Installation und Update automatisch aus, schneespur:modules-remove rollt sie vor dem Entfernen wieder zurück. Der Lebenszyklus dahinter ist unter Modul-Lebenszyklus beschrieben.

Tabellen benennen: das mod-Präfix

Versehen Sie jede Tabelle mit dem Präfix mod_{module_slug}_, damit es keine Kollisionen mit Core-Tabellen oder anderen Modulen gibt:

Schema::create('mod_documents_entries', function (Blueprint $table) {
    // ...
});
GutSchlecht
mod_documents_entriesdocuments
mod_telegram_messagestelegram_messages
mod_billing_invoicesinvoices

Das Präfix ist nicht nur Kosmetik: Es macht jede Tabelle eindeutig einem Modul zuordenbar und erlaubt es dem System, beim Entfernen eines Moduls genau dessen Tabellen aufzuräumen.

Die 64-Zeichen-Grenze (MySQL/MariaDB)

MySQL und MariaDB begrenzen jeden Bezeichner — Tabellen, Spalten und Indizes — auf 64 Zeichen. Wird die Grenze überschritten, kommt es zu diesem Fehler:

SQLSTATE[42000]: ... 1059 Identifier name '...' is too long

Die Gefahr sind automatisch erzeugte Index-Namen. Wenn Sie einen Index nicht selbst benennen, baut Laravel den Namen als {table}_{col1}_{col2}_..._{type} zusammen. Zusammen mit dem verpflichtenden Tabellen-Präfix mod_{slug}_ überschreitet ein zweispaltiger Index auf einer langen Tabelle schnell die 64 Zeichen — und SQLite akzeptiert das stillschweigend. Ihre Tests bleiben grün, während die Produktion bricht.

Ein echtes Beispiel — der Fehler, für den dieser Leitfaden geschrieben wurde:

mod_telegram_enrollment_tokens_notifiable_type_notifiable_id_index
└──────────────── 66 Zeichen → von MySQL abgelehnt (errno 1059) ───┘

Regel: Übergeben Sie immer einen expliziten, kurzen Index- oder Constraint-Namen als letztes Argument, sobald der automatische Name lang werden könnte — bei zusammengesetzten Indizes, unique, morphs und Fremdschlüsseln auf präfigierten Tabellen:

// ❌ Auto-Name → 66 Zeichen → fällt auf MySQL durch, besteht aber auf SQLite
$table->index(['notifiable_type', 'notifiable_id']);
$table->unique(['event_type', 'recipient_group']);

// ✅ Expliziter Kurzname → überall sicher
$table->index(['notifiable_type', 'notifiable_id'], 'tg_enroll_notifiable_idx');
$table->unique(['event_type', 'recipient_group'], 'tg_rules_event_group_unq');

Konvention für Kurznamen: {abbrev}_{purpose}_{idx|unq|fk} — lassen Sie das ausführliche mod_{slug}_-Präfix weg und nehmen Sie ein erkennbares Modul-Kürzel (tg_ für Telegram, doc_ für Documents, …). Einspaltige Indizes mit Auto-Namen wie {table}_{col}_index sind meist unkritisch — es sind die zusammengesetzten, die zubeißen.

Tipp: Ergänzen Sie einen Wächter-Test, der jeden angelegten Index auflistet und strlen($name) <= 64 prüft. SQLite gibt die Indizes über SELECT name FROM sqlite_master WHERE type='index' preis, sodass die Prüfung in der normalen Test-Suite läuft und die Grenze erkennt, bevor es ein echtes MySQL-Deploy tut.

Eine vollständige Migration

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('mod_documents_entries', function (Blueprint $table) {
            $table->id();
            $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
            $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
            $table->string('title');
            $table->string('file_path');
            $table->string('mime_type');
            $table->unsignedBigInteger('file_size');
            $table->string('category')->default('general');
            $table->string('visibility')->default('admin'); // admin, accountant, customer
            $table->date('expires_at')->nullable();
            $table->timestamps();

            // Zusammengesetzter Index: expliziter Kurzname, um unter MySQLs 64-Zeichen-Grenze zu bleiben
            $table->index(['customer_id', 'visibility'], 'doc_customer_visibility_idx');
            $table->index('category'); // einzelne Spalte → Auto-Name ist sicher
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('mod_documents_entries');
    }
};

Core-Tabellen als Referenz

Diese Tabellen liegen in der Kern-Anwendung. Ihr Modul kann sie über Fremdschlüssel referenzieren:

TabelleModelWichtige Felder
usersUserid, name, email, role
customersCustomerid, name, email, portal_enabled
customer_objectsCustomerObjectid, customer_id, name, street, lat, lon
service_jobsJobid, customer_id, user_id, vehicle_id, type, started_at, ended_at
work_shiftsWorkShiftid, user_id, started_at, ended_at
vehiclesVehicleid, name, license_plate
gps_pointsGpsPointid, user_id, job_id, lat, lon, timestamp
weather_snapshotsWeatherSnapshotid, job_id, moment, temperature, etc.
job_photosJobPhotoid, job_id, file_path
job_auditsJobAuditid, job_id, action, old_values, new_values
settingsSettingkey, value, type
rolesRoleid, slug, name
permissionsPermissionid, slug, name, group
role_permissionrole_id, permission_id
role_userrole_id, user_id
modulesModuleid, slug, version, enabled
module_api_tokensModuleApiTokenid, module_slug, token_hash
mod_logsModLogid, module_slug, level, message
notification_logsNotificationLogid, notifiable_type, notifiable_id, channel
alert_dismissalsAlertDismissalid, job_id, alert_type
monthly_statisticsMonthlyStatisticid, year, month
driver_dsgvo_confirmationsDsgvoConfirmationid, driver_id, confirmed_at
owntracks_credential_eventsOwntracksCredentialEventid, driver_id, event

Das Kern-Datenmodell ist unter Core-Datenmodell ausführlicher beschrieben.

Das Setting-Model

Das Setting-Model bietet einen Schlüssel-Wert-Speicher für die Modul-Konfiguration. So müssen Sie für einfache Einstellungen keine eigene Tabelle anlegen:

use App\Models\Setting;

// Lesen (mit optionalem Default)
$value = Setting::get('my-module.api_key');
$value = Setting::get('my-module.enabled', false);

// Schreiben (mit explizitem Typ)
Setting::set('my-module.api_key', 'abc123');
Setting::set('my-module.enabled', true, 'bool');
Setting::set('my-module.config', ['a' => 1], 'json');
Setting::set('my-module.retries', 5, 'int');

Unterstützte Typen: string, int, bool, json

Die Typumwandlung passiert automatisch beim Lesen:

  • 'bool' → wandelt '1'/'true' in true, sonst false
  • 'int' → wandelt in einen Integer
  • 'json' → dekodiert JSON in ein Array

Einstellungen initialisieren

Definieren Sie Standardwerte mit ModuleManager::registerSettings() im ServiceProvider Ihres Moduls. Bestehende Werte werden dabei nie überschrieben — ein Update bringt also neue Standards, ohne die Konfiguration des Betreibers zu zerstören:

app(ModuleManager::class)->registerSettings('my-module', [
    'api_key' => '',           // string
    'enabled' => true,         // bool
    'max_retries' => 3,        // int
    'config' => ['mode' => 'auto'], // json
]);

Einstellungen aufräumen

Wird ein Modul entfernt, löscht ModuleManager::cleanupSettings() alle Einstellungen mit dem Schlüssel-Präfix des Moduls. Das Slug-Präfix ist der Grund, warum dieses Aufräumen sauber gelingt:

$manager->cleanupSettings('my-module');
// Löscht: my-module.api_key, my-module.enabled, my-module.max_retries usw.

Die Registrierung der Einstellungen im ServiceProvider ist unter ServiceProvider im Zusammenhang beschrieben.

Eloquent-Models in Modulen

Module können eigene Eloquent-Models definieren. Über $table zeigen sie auf die präfigierte Tabelle, über Beziehungen greifen sie auf Core-Models zu:

namespace Schneespur\Module\Documents\Models;

use Illuminate\Database\Eloquent\Model;
use App\Models\Customer;

class Document extends Model
{
    protected $table = 'mod_documents_entries';

    protected $fillable = [
        'customer_id', 'uploaded_by', 'title', 'file_path',
        'mime_type', 'file_size', 'category', 'visibility', 'expires_at',
    ];

    protected $casts = [
        'file_size' => 'integer',
        'expires_at' => 'date',
    ];

    public function customer()
    {
        return $this->belongsTo(Customer::class);
    }

    public function uploader()
    {
        return $this->belongsTo(\App\Models\User::class, 'uploaded_by');
    }
}

Worauf Sie bei MySQL / MariaDB achten

  • 64-Zeichen-Grenze für alle Bezeichner — benennen Sie zusammengesetzte Indizes und Unique-Constraints explizit (siehe oben). Das ist die häufigste Ursache für „läuft im Test, scheitert beim Deploy”.
  • Geben Sie jeder indizierten string()-Spalte eine Länge. Ein indiziertes VARCHAR(255) kann unter utf8mb4 die Index-Byte-Grenze von MySQL sprengen; begrenzen Sie solche Spalten (z. B. $table->string('token', 64)).
  • Nutzen Sie den Spaltentyp json für strukturierte Daten — MariaDB bildet ihn auf LONGTEXT mit JSON-Prüfung ab, MySQL hat einen nativen Typ. Bauen Sie nicht selbst aus Text plus manueller Kodierung.
  • Fremdschlüssel verlangen, dass beide Tabellen die InnoDB-Engine nutzen (Standard) und passende Spaltentypen haben (unsignedBigIntegerid()).
  • ALTER TABLE wird unterstützt, aber Spaltenänderungen über ->change() brauchen weiterhin ein installiertes doctrine/dbal.

Migrationen gegen die Testdatenbank (SQLite) schreiben

Die Test-Suite läuft aus Geschwindigkeitsgründen auf sqlite::memory:. SQLite ist aber nachsichtiger als MySQL. Diese Fälle bestehen auf SQLite und scheitern in der Produktion — achten Sie darauf:

  • Zu lange Bezeichner — SQLite akzeptiert jede Länge; MySQL lehnt über 64 Zeichen ab (siehe oben).
  • SELECT ... FOR UPDATE — SQLite ignoriert Zeilensperren, sodass sich Nebenläufigkeits- Fehler verstecken. Prüfen Sie Sperrpfade gegen echtes MySQL.
  • Typstrenge — SQLite ist locker typisiert; MySQL erzwingt Spaltentypen und -längen.
  • UNIQUE + NULL — wie sich NULL in einem Unique-Index verhält, unterscheidet sich zwischen den Engines; verlassen Sie sich nicht darauf.

Faustregel: Schreiben Sie Migrationen nach den Regeln von MySQL und behandeln Sie ein grünes SQLite-Ergebnis als notwendig, aber nicht hinreichend.