fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
633
docs/database/seeding.md
Normal file
633
docs/database/seeding.md
Normal file
@@ -0,0 +1,633 @@
|
||||
# Database Seeding
|
||||
|
||||
Das Seed-System ermöglicht die Initialisierung der Datenbank mit Standard-Daten, getrennt von Schema-Migrationen. Es bietet eine saubere Trennung zwischen Schema-Änderungen (Migrations) und Initial-Daten (Seeds).
|
||||
|
||||
## Übersicht
|
||||
|
||||
**Migrations** sind für Schema-Änderungen (CREATE TABLE, ALTER TABLE, etc.)
|
||||
**Seeds** sind für Initial-Daten (Standard-Content-Types, Default-Rollen, etc.)
|
||||
|
||||
Seeds sind idempotent - sie können mehrfach ausgeführt werden ohne Duplikate zu erstellen. Das System trackt ausgeführte Seeds in einer `seeds` Tabelle.
|
||||
|
||||
## Architektur
|
||||
|
||||
Das Seed-System besteht aus folgenden Komponenten:
|
||||
|
||||
### Core-Komponenten
|
||||
|
||||
- **`Seeder` Interface** - Basis-Interface für alle Seeder-Klassen
|
||||
- **`SeedLoader`** - Lädt Seeder-Klassen über das Discovery-System
|
||||
- **`SeedRunner`** - Führt Seeder aus und verwaltet Idempotenz
|
||||
- **`SeedRepository`** - Verwaltet die `seeds` Tabelle für Tracking
|
||||
- **`SeedCommand`** - Console Command für manuelle Ausführung
|
||||
|
||||
### Discovery-Integration
|
||||
|
||||
Das Seed-System nutzt das Framework Discovery-System zur automatischen Erkennung von Seeder-Klassen. Alle Klassen, die das `Seeder` Interface implementieren, werden automatisch gefunden und können ausgeführt werden.
|
||||
|
||||
### Seeds Tabelle
|
||||
|
||||
Die `seeds` Tabelle wird durch die Migration `CreateSeedsTable` erstellt und trackt alle ausgeführten Seeders:
|
||||
|
||||
- `id` (VARCHAR, PRIMARY KEY) - Eindeutige ID (SHA256 Hash des Seeder-Namens)
|
||||
- `name` (VARCHAR, UNIQUE) - Seeder-Name
|
||||
- `description` (TEXT) - Beschreibung des Seeders
|
||||
- `executed_at` (TIMESTAMP) - Zeitpunkt der Ausführung
|
||||
|
||||
Diese Tabelle verhindert, dass Seeders mehrfach ausgeführt werden.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Migration ausführen
|
||||
|
||||
Die `seeds` Tabelle wird automatisch erstellt, wenn die Migration `CreateSeedsTable` ausgeführt wird:
|
||||
|
||||
```bash
|
||||
php console.php db:migrate
|
||||
```
|
||||
|
||||
Die Migration ist Teil des Framework-Seed-Systems und wird automatisch erkannt.
|
||||
|
||||
### 2. Discovery-Cache aktualisieren
|
||||
|
||||
Nach dem Erstellen neuer Seeder-Klassen sollte der Discovery-Cache geleert werden:
|
||||
|
||||
```bash
|
||||
php console.php cache:clear
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Alle Seeders ausführen
|
||||
|
||||
Führt alle gefundenen Seeder aus, die noch nicht ausgeführt wurden:
|
||||
|
||||
```bash
|
||||
php console.php db:seed
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
```
|
||||
Running all seeders...
|
||||
|
||||
Found 1 seeder(s):
|
||||
- DefaultContentTypesSeeder: Seeds default CMS content types (page, post, landing_page)
|
||||
|
||||
[info] Running seeder 'DefaultContentTypesSeeder'...
|
||||
[info] Seeder 'DefaultContentTypesSeeder' completed successfully
|
||||
|
||||
✅ All seeders completed.
|
||||
```
|
||||
|
||||
### Spezifischen Seeder ausführen
|
||||
|
||||
Führt nur einen bestimmten Seeder aus:
|
||||
|
||||
```bash
|
||||
php console.php db:seed --class=DefaultContentTypesSeeder
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
```
|
||||
Running seeder: DefaultContentTypesSeeder
|
||||
[info] Running seeder 'DefaultContentTypesSeeder'...
|
||||
[info] Seeder 'DefaultContentTypesSeeder' completed successfully
|
||||
✅ Seeder 'DefaultContentTypesSeeder' completed.
|
||||
```
|
||||
|
||||
### Alle Seeds neu ausführen (--fresh)
|
||||
|
||||
Löscht die `seeds` Tabelle und führt alle Seeders neu aus:
|
||||
|
||||
```bash
|
||||
php console.php db:seed --fresh
|
||||
```
|
||||
|
||||
**Warnung:** Die `--fresh` Option sollte nur in Development-Umgebungen verwendet werden.
|
||||
|
||||
**Ausgabe:**
|
||||
```
|
||||
⚠️ Clearing seeds table (--fresh option)...
|
||||
✅ Seeds table cleared.
|
||||
|
||||
Running all seeders...
|
||||
...
|
||||
```
|
||||
|
||||
## Seeder erstellen
|
||||
|
||||
### 1. Seeder-Klasse erstellen
|
||||
|
||||
Erstelle eine Klasse, die das `Seeder` Interface implementiert:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\YourDomain\Seeds;
|
||||
|
||||
use App\Framework\Database\Seed\Seeder;
|
||||
use App\Domain\YourDomain\Services\YourService;
|
||||
|
||||
final readonly class YourSeeder implements Seeder
|
||||
{
|
||||
public function __construct(
|
||||
private YourService $yourService
|
||||
) {}
|
||||
|
||||
public function seed(): void
|
||||
{
|
||||
// Your seeding logic here
|
||||
// Should be idempotent - check if data exists before creating
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'YourSeeder';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Description of what this seeder does';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Idempotenz sicherstellen
|
||||
|
||||
Seeds sollten idempotent sein - sie sollten prüfen, ob Daten bereits existieren, bevor sie erstellt werden:
|
||||
|
||||
**Beispiel 1: Mit Exception-Handling**
|
||||
|
||||
```php
|
||||
public function seed(): void
|
||||
{
|
||||
try {
|
||||
$this->service->findBySlug('example');
|
||||
// Skip if already exists
|
||||
} catch (\RuntimeException $e) {
|
||||
// Create if not exists
|
||||
$this->service->create(
|
||||
name: 'Example',
|
||||
slug: 'example',
|
||||
description: 'Example description'
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Beispiel 2: Mit expliziter Existenz-Prüfung**
|
||||
|
||||
```php
|
||||
public function seed(): void
|
||||
{
|
||||
if (!$this->repository->exists(ExampleId::fromString('example'))) {
|
||||
$this->service->create(...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Beispiel 3: Mit Config-Datei**
|
||||
|
||||
```php
|
||||
public function seed(): void
|
||||
{
|
||||
$configPath = $this->pathProvider->getBasePath()
|
||||
->join('config', 'your-domain', 'default-data.php');
|
||||
|
||||
$defaultData = require $configPath->toString();
|
||||
|
||||
foreach ($defaultData as $slug => $data) {
|
||||
try {
|
||||
$this->service->findBySlug($slug);
|
||||
// Skip if already exists
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->service->create(
|
||||
name: $data['name'],
|
||||
slug: $slug,
|
||||
description: $data['description'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Verzeichnis-Struktur
|
||||
|
||||
Seeder sollten im `Seeds` Verzeichnis der jeweiligen Domain liegen:
|
||||
|
||||
```
|
||||
src/Domain/
|
||||
├── Cms/
|
||||
│ └── Seeds/
|
||||
│ └── DefaultContentTypesSeeder.php
|
||||
└── User/
|
||||
└── Seeds/
|
||||
└── DefaultRolesSeeder.php
|
||||
```
|
||||
|
||||
Das Discovery-System findet automatisch alle Seeder-Klassen, die das `Seeder` Interface implementieren.
|
||||
|
||||
### 4. Dependency Injection
|
||||
|
||||
Seeder können alle Framework-Services über Dependency Injection verwenden:
|
||||
|
||||
```php
|
||||
final readonly class YourSeeder implements Seeder
|
||||
{
|
||||
public function __construct(
|
||||
private YourService $yourService,
|
||||
private PathProvider $pathProvider,
|
||||
private Clock $clock,
|
||||
private Logger $logger
|
||||
) {}
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Der DI Container stellt automatisch alle benötigten Dependencies bereit.
|
||||
|
||||
## Standard-Content-Types
|
||||
|
||||
Das CMS-System stellt Standard-Content-Types über den `DefaultContentTypesSeeder` bereit:
|
||||
|
||||
- **`page`** - Standard-Seiten (z.B. "Über uns", "Impressum", "Kontakt")
|
||||
- **`post`** - Blog-Artikel/News
|
||||
- **`landing_page`** - Marketing-Landing-Pages
|
||||
|
||||
Diese werden automatisch erstellt, wenn der Seeder ausgeführt wird. Die Definitionen können in `config/cms/default-content-types.php` angepasst werden.
|
||||
|
||||
### Config-Datei anpassen
|
||||
|
||||
Die Standard-Content-Types werden in `config/cms/default-content-types.php` definiert:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'page' => [
|
||||
'name' => 'Page',
|
||||
'description' => 'Standard pages for general content',
|
||||
'isSystem' => true,
|
||||
],
|
||||
'post' => [
|
||||
'name' => 'Post',
|
||||
'description' => 'Blog posts and news articles',
|
||||
'isSystem' => true,
|
||||
],
|
||||
'landing_page' => [
|
||||
'name' => 'Landing Page',
|
||||
'description' => 'Marketing landing pages for campaigns',
|
||||
'isSystem' => true,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
Du kannst diese Datei anpassen, um weitere Content-Types hinzuzufügen oder bestehende zu ändern. Nach Änderungen führe `db:seed --fresh` aus, um die Seeds neu auszuführen.
|
||||
|
||||
## Seeds Tabelle
|
||||
|
||||
Das Seed-System verwendet eine `seeds` Tabelle, um zu tracken, welche Seeders bereits ausgeführt wurden:
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE seeds (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
executed_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_seeds_name ON seeds(name);
|
||||
```
|
||||
|
||||
### Spalten
|
||||
|
||||
- **`id`** (VARCHAR, PRIMARY KEY) - Eindeutige ID (SHA256 Hash des Seeder-Namens)
|
||||
- **`name`** (VARCHAR, UNIQUE) - Seeder-Name (muss eindeutig sein)
|
||||
- **`description`** (TEXT) - Beschreibung des Seeders
|
||||
- **`executed_at`** (TIMESTAMP) - Zeitpunkt der Ausführung
|
||||
|
||||
### Verwendung
|
||||
|
||||
Die Tabelle wird automatisch erstellt, wenn die Migration `CreateSeedsTable` ausgeführt wird. Der `SeedRepository` verwaltet diese Tabelle automatisch:
|
||||
|
||||
- `hasRun(string $name): bool` - Prüft, ob ein Seeder bereits ausgeführt wurde
|
||||
- `markAsRun(string $name, string $description): void` - Markiert einen Seeder als ausgeführt
|
||||
- `clearAll(): void` - Löscht alle Einträge (für `--fresh` Option)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Idempotenz
|
||||
|
||||
Seeds sollten immer idempotent sein - prüfe auf Existenz vor dem Erstellen:
|
||||
|
||||
```php
|
||||
public function seed(): void
|
||||
{
|
||||
// ✅ GUT: Prüft Existenz vor Erstellung
|
||||
try {
|
||||
$this->service->findBySlug('example');
|
||||
return; // Skip if exists
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->service->create(...);
|
||||
}
|
||||
|
||||
// ❌ SCHLECHT: Erstellt ohne Prüfung
|
||||
$this->service->create(...); // Kann Duplikate erzeugen
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Config-Dateien
|
||||
|
||||
Verwende Config-Dateien für anpassbare Seed-Daten:
|
||||
|
||||
```php
|
||||
// config/your-domain/default-data.php
|
||||
return [
|
||||
'example' => [
|
||||
'name' => 'Example',
|
||||
'description' => 'Example description',
|
||||
'isSystem' => true,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
Vorteile:
|
||||
- Einfache Anpassung ohne Code-Änderung
|
||||
- Versionierbar in Git
|
||||
- Kann in verschiedenen Umgebungen unterschiedlich sein
|
||||
|
||||
### 3. System-Daten
|
||||
|
||||
Markiere System-Daten mit `isSystem: true` um sie vor Löschung zu schützen:
|
||||
|
||||
```php
|
||||
$this->service->create(
|
||||
name: 'System Role',
|
||||
slug: 'admin',
|
||||
isSystem: true // Kann nicht gelöscht werden
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Fehlerbehandlung
|
||||
|
||||
Seeds sollten aussagekräftige Fehlermeldungen werfen:
|
||||
|
||||
```php
|
||||
public function seed(): void
|
||||
{
|
||||
try {
|
||||
$configPath = $this->pathProvider->getBasePath()
|
||||
->join('config', 'domain', 'data.php');
|
||||
|
||||
if (!file_exists($configPath->toString())) {
|
||||
throw new \RuntimeException(
|
||||
"Config file not found: {$configPath->toString()}"
|
||||
);
|
||||
}
|
||||
|
||||
// ... seeding logic
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException(
|
||||
"Failed to seed data: {$e->getMessage()}",
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Dokumentation
|
||||
|
||||
Dokumentiere, was jeder Seeder macht:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Seeds default user roles for the application
|
||||
*
|
||||
* Creates the following roles:
|
||||
* - admin: Full system access
|
||||
* - editor: Content editing access
|
||||
* - viewer: Read-only access
|
||||
*/
|
||||
final readonly class DefaultRolesSeeder implements Seeder
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Abhängigkeiten zwischen Seeders
|
||||
|
||||
Wenn Seeders voneinander abhängen, stelle sicher, dass sie in der richtigen Reihenfolge ausgeführt werden:
|
||||
|
||||
```php
|
||||
// Seeder 1: Muss zuerst ausgeführt werden
|
||||
final readonly class DefaultRolesSeeder implements Seeder
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'DefaultRolesSeeder'; // Wird alphabetisch zuerst ausgeführt
|
||||
}
|
||||
}
|
||||
|
||||
// Seeder 2: Kann danach ausgeführt werden
|
||||
final readonly class DefaultUsersSeeder implements Seeder
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'DefaultUsersSeeder'; // Wird nach DefaultRolesSeeder ausgeführt
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Hinweis:** Seeders werden alphabetisch nach Namen sortiert. Verwende Präfixe für die Reihenfolge, falls nötig (z.B. `01_DefaultRolesSeeder`, `02_DefaultUsersSeeder`).
|
||||
|
||||
## Migration vs. Seed
|
||||
|
||||
| Migration | Seed |
|
||||
|-----------|------|
|
||||
| Schema-Änderungen | Initial-Daten |
|
||||
| Forward-only (optional rollback) | Idempotent (kein rollback) |
|
||||
| Versioniert | Getrackt in `seeds` Tabelle |
|
||||
| Automatisch beim `db:migrate` | Manuell mit `db:seed` |
|
||||
|
||||
## Beispiele
|
||||
|
||||
### Beispiel: Default Roles Seeder
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\User\Seeds;
|
||||
|
||||
use App\Domain\User\Services\RoleService;
|
||||
use App\Framework\Database\Seed\Seeder;
|
||||
|
||||
final readonly class DefaultRolesSeeder implements Seeder
|
||||
{
|
||||
public function __construct(
|
||||
private RoleService $roleService
|
||||
) {}
|
||||
|
||||
public function seed(): void
|
||||
{
|
||||
$roles = [
|
||||
['name' => 'Admin', 'slug' => 'admin', 'permissions' => ['*']],
|
||||
['name' => 'Editor', 'slug' => 'editor', 'permissions' => ['content.edit']],
|
||||
['name' => 'Viewer', 'slug' => 'viewer', 'permissions' => ['content.view']],
|
||||
];
|
||||
|
||||
foreach ($roles as $roleData) {
|
||||
try {
|
||||
$this->roleService->findBySlug($roleData['slug']);
|
||||
// Skip if already exists
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->roleService->create(
|
||||
name: $roleData['name'],
|
||||
slug: $roleData['slug'],
|
||||
permissions: $roleData['permissions']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'DefaultRolesSeeder';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Seeds default user roles (admin, editor, viewer)';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Beispiel: Seeder mit Config-Datei
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Settings\Seeds;
|
||||
|
||||
use App\Domain\Settings\Services\SettingService;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\Database\Seed\Seeder;
|
||||
|
||||
final readonly class DefaultSettingsSeeder implements Seeder
|
||||
{
|
||||
public function __construct(
|
||||
private SettingService $settingService,
|
||||
private PathProvider $pathProvider
|
||||
) {}
|
||||
|
||||
public function seed(): void
|
||||
{
|
||||
$configPath = $this->pathProvider->getBasePath()
|
||||
->join('config', 'settings', 'defaults.php');
|
||||
|
||||
if (!file_exists($configPath->toString())) {
|
||||
throw new \RuntimeException(
|
||||
"Config file not found: {$configPath->toString()}"
|
||||
);
|
||||
}
|
||||
|
||||
$defaultSettings = require $configPath->toString();
|
||||
|
||||
foreach ($defaultSettings as $key => $value) {
|
||||
if (!$this->settingService->exists($key)) {
|
||||
$this->settingService->create($key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'DefaultSettingsSeeder';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Seeds default application settings';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Seeder wird nicht gefunden
|
||||
|
||||
**Problem:** Der Seeder wird nicht vom Discovery-System gefunden.
|
||||
|
||||
**Lösung:**
|
||||
1. Stelle sicher, dass die Klasse das `Seeder` Interface implementiert
|
||||
2. Stelle sicher, dass die Klasse im `Seeds` Verzeichnis liegt
|
||||
3. Leere den Discovery-Cache: `php console.php cache:clear`
|
||||
4. Prüfe, ob das `Seeder` Interface in den `targetInterfaces` der Discovery-Konfiguration ist
|
||||
|
||||
**Debug:**
|
||||
```bash
|
||||
# Prüfe, ob Seeder gefunden werden
|
||||
php console.php db:seed
|
||||
# Sollte "Found X seeder(s):" anzeigen
|
||||
```
|
||||
|
||||
### Seeder wird mehrfach ausgeführt
|
||||
|
||||
**Problem:** Der Seeder wird trotz Tracking mehrfach ausgeführt.
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob die `seeds` Tabelle existiert: `php console.php db:status | grep seeds`
|
||||
2. Prüfe, ob der Seeder-Name korrekt ist (muss eindeutig sein)
|
||||
3. Prüfe die `seeds` Tabelle direkt:
|
||||
```sql
|
||||
SELECT * FROM seeds WHERE name = 'YourSeeder';
|
||||
```
|
||||
|
||||
### SQL Syntax Error (ON DUPLICATE KEY UPDATE)
|
||||
|
||||
**Problem:** `SQLSTATE[42601]: Syntax error: syntax error at or near "DUPLICATE"`
|
||||
|
||||
**Ursache:** Die Datenbank ist PostgreSQL, aber der Code verwendet MySQL-Syntax.
|
||||
|
||||
**Lösung:** Verwende PostgreSQL-Syntax (`ON CONFLICT`) statt MySQL-Syntax (`ON DUPLICATE KEY UPDATE`):
|
||||
|
||||
```php
|
||||
// ❌ MySQL-Syntax (funktioniert nicht mit PostgreSQL)
|
||||
INSERT INTO table (...) VALUES (...)
|
||||
ON DUPLICATE KEY UPDATE ...
|
||||
|
||||
// ✅ PostgreSQL-Syntax
|
||||
INSERT INTO table (...) VALUES (...)
|
||||
ON CONFLICT (id) DO UPDATE SET ...
|
||||
```
|
||||
|
||||
### Seeder schlägt fehl
|
||||
|
||||
**Problem:** Der Seeder wirft eine Exception.
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe die Fehlermeldung in der Console-Ausgabe
|
||||
2. Stelle sicher, dass alle Dependencies verfügbar sind
|
||||
3. Prüfe, ob Config-Dateien existieren und korrekt formatiert sind
|
||||
4. Verwende `--fresh` um alle Seeds neu auszuführen (nur Development)
|
||||
|
||||
### Migration für seeds Tabelle fehlt
|
||||
|
||||
**Problem:** `SQLSTATE[42P01]: Undefined table: relation "seeds" does not exist`
|
||||
|
||||
**Lösung:**
|
||||
1. Führe die Migration aus: `php console.php db:migrate`
|
||||
2. Prüfe, ob die Migration `CreateSeedsTable` existiert
|
||||
3. Prüfe den Migrations-Status: `php console.php db:status`
|
||||
|
||||
Reference in New Issue
Block a user