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) {
// ...
});
| Gut | Schlecht |
|---|---|
mod_documents_entries | documents |
mod_telegram_messages | telegram_messages |
mod_billing_invoices | invoices |
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) <= 64prüft. SQLite gibt die Indizes überSELECT 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:
| Tabelle | Model | Wichtige Felder |
|---|---|---|
users | User | id, name, email, role |
customers | Customer | id, name, email, portal_enabled |
customer_objects | CustomerObject | id, customer_id, name, street, lat, lon |
service_jobs | Job | id, customer_id, user_id, vehicle_id, type, started_at, ended_at |
work_shifts | WorkShift | id, user_id, started_at, ended_at |
vehicles | Vehicle | id, name, license_plate |
gps_points | GpsPoint | id, user_id, job_id, lat, lon, timestamp |
weather_snapshots | WeatherSnapshot | id, job_id, moment, temperature, etc. |
job_photos | JobPhoto | id, job_id, file_path |
job_audits | JobAudit | id, job_id, action, old_values, new_values |
settings | Setting | key, value, type |
roles | Role | id, slug, name |
permissions | Permission | id, slug, name, group |
role_permission | — | role_id, permission_id |
role_user | — | role_id, user_id |
modules | Module | id, slug, version, enabled |
module_api_tokens | ModuleApiToken | id, module_slug, token_hash |
mod_logs | ModLog | id, module_slug, level, message |
notification_logs | NotificationLog | id, notifiable_type, notifiable_id, channel |
alert_dismissals | AlertDismissal | id, job_id, alert_type |
monthly_statistics | MonthlyStatistic | id, year, month |
driver_dsgvo_confirmations | DsgvoConfirmation | id, driver_id, confirmed_at |
owntracks_credential_events | OwntracksCredentialEvent | id, 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'intrue, sonstfalse'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 indiziertesVARCHAR(255)kann unterutf8mb4die Index-Byte-Grenze von MySQL sprengen; begrenzen Sie solche Spalten (z. B.$table->string('token', 64)). - Nutzen Sie den Spaltentyp
jsonfür strukturierte Daten — MariaDB bildet ihn aufLONGTEXTmit 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 (
unsignedBigInteger↔id()). ALTER TABLEwird unterstützt, aber Spaltenänderungen über->change()brauchen weiterhin ein installiertesdoctrine/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.