Skip to content

Práce s databází

Projekt používá MariaDB 11 v Docker prostředí a Laravel migrations pro správu databázového schématu.

V .env:

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=kviz
DB_USERNAME=kviz
DB_PASSWORD=kviz

V phpunit.xml:

<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_HOST" value="db"/>
<env name="DB_DATABASE" value="kviz_testing"/>

Terminal window
# Spustit všechny pending migrace
docker compose exec fpm php artisan migrate
# S seedováním
docker compose exec fpm php artisan migrate --seed
# Rollback poslední dávky
docker compose exec fpm php artisan migrate:rollback
# Rollback všech migrací
docker compose exec fpm php artisan migrate:reset
# Reset a opětovné spuštění všech migrací
docker compose exec fpm php artisan migrate:fresh
# Fresh s seedováním
docker compose exec fpm php artisan migrate:fresh --seed
Terminal window
# Vytvořit novou tabulku
docker compose exec fpm php artisan make:migration create_products_table
# Přidat sloupec do existující tabulky
docker compose exec fpm php artisan make:migration add_status_to_products_table
# S automatickým generováním struktury
docker compose exec fpm php artisan make:migration create_products_table --create=products
docker compose exec fpm php artisan make:migration add_status_to_products --table=products
<?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('chk_products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->foreignId('category_id')
->constrained('chk_categories')
->onDelete('cascade');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('chk_products');
}
};
// ✅ Správně
Schema::create('chk_products', function (Blueprint $table) { ... });
// ❌ Špatně
Schema::create('products', function (Blueprint $table) { ... });
$table->foreignId('region_id')
->constrained('chk_regions')
->onDelete('set null')
->onUpdate('cascade');

3. Používejte nullable() pro volitelná pole

Section titled “3. Používejte nullable() pro volitelná pole”
$table->string('phone')->nullable();
$table->text('description')->nullable();

4. Indexujte časté vyhledávací sloupce

Section titled “4. Indexujte časté vyhledávací sloupce”
$table->string('email')->unique();
$table->index('status');
$table->index(['user_id', 'created_at']);

Seedery slouží k naplnění databáze testovacími daty.

Sada je rozdělená na dvě vrstvy — referenční data a demo provoz:

database/seeders/
├── DatabaseSeeder.php # Jen produkční guard + volání dvou vrstev
├── ReferenceSeeder.php # Referenční data: Country, Region, City, formule,
│ # uživatelé/role, LeagueType, QuizzConfig, obsah stránek
├── DemoSeeder.php # Demo provoz (seedlý faker): Topic, Pub, blueprinty,
│ # speciály, výsledky, rezervace, platby, ligy, novinky
├── DemoAccountSeeder.php # MANUAL TOOL — prezentační účet (prod ID), mimo sadu
└── AppVideoSeeder.php # MANUAL TOOL — video fixtures, mimo sadu

Demo vrstva odpovídá rozsahem produkci (~90 %: všechny regiony s produkčními slugy, ~24 podniků, 1–2 blueprinty na podnik, 4+4 týdny eventů), ale drží mělkou historii. Rozsah řídí config('kviz.seed_scale') — full na stagingu, ci v testech (KVIZ_SEED_SCALE=ci v phpunit.xml).

NewLeagueSeeder kopíruje tvar produkce, ne jen počty. Produkce nemá jednu extraligu, ale sezóny: každá má celkovou ligu a pod ní denní varianty (pondělí až čtvrtek, víkend, čtrnáctidenní). Vedle nich běží regionální série, srovnávačky a stovky místních lig po jednom podniku.

Klíčová vlastnost je překryv. V produkci sedí typický blueprint v šesti až sedmi ligách naráz (rozsah 1–13), protože ho zároveň sbírá denní varianta, celková liga sezóny, regionální série i místní liga podniku. Bez toho nemají žebříčky ani filtry na čem ukázat, že se soustavy prolínají.

Scale Ligy Z toho Lig na blueprint Čas
full 35 2 sezóny × 7 lig, 3 regionální, 1 srovnávačka, 18 místních 3–8 ~25 s
ci 9 1 sezóna × 5 lig, 1 regionální, 3 místní 2–3 ~3 s

Přepočet žebříčků (LeagueResultsService) je nejdražší část celého seedu, proto je bohatší soustava jen ve full scale. Na CI by za ni sada platila desítkami sekund a nic by tam netestovala.

Všichni produkční admini mají v seednutém prostředí účet se svým e-mailem a heslem password (UserRoleCompanySeeder::PROD_ADMINS).

Každý podnik z PubSeeder::PUBS má zeměpisnou šířku a délku a většina má odkaz na Facebook. Bez souřadnic se v detailu podniku nevykreslí mapa a předpověď počasí nemá pro co počítat, takže by tyhle části webu na seednutém prostředí nešly vyzkoušet.

Souřadnice jsou přibližné, na úrovni centra města, ne přesná poloha adresy. Facebook nemá každý šestý podnik, aby šla otestovat i varianta detailu bez odkazu.

PubTableSeeder běží po ReservationSeeder a dělí podniky do tří stálých skupin, aby šly vyzkoušet všechny podoby rezervační stránky:

  • se stoly (zhruba polovina): 3, 6, 8 nebo 9 stolů s různou kapacitou, dva z nich pojmenované. Podniky se třemi stoly jsou malé schválně, aby se naseedovanými rezervacemi zaplnily. Rezervační stránka ukazuje rozmístění stolů a počet volných.
  • jen online rezervace (zhruba třetina): bez stolů, stránka ukazuje prostý seznam přihlášených týmů.
  • bez online rezervací: stránka rezervace se neotevře, stejně jako v produkci u podniků, které rezervace neberou.

Rezervace, které vytvořil ReservationSeeder, dostanou každá svůj stůl. Stálý tým sedí na všech termínech svého kvízu u stejného stolu. Když je rezervací víc než stolů, zbylé zůstanou bez stolu a termín je plně obsazený, takže jde vyzkoušet i čekací listina.

Do které skupiny podnik patří, se odvozuje z jeho názvu (PubTableSeeder::hasTables(), hasOnlineReservations()), takže je rozdělení při každém seedu stejné.

Podniky, které vznikly dřív, než katalog tahle pole obsahoval, si je doplní při dalším běhu seederu. Hodnoty vyplněné ručně zůstávají.

EshopStorefrontSeeder (referenční vrstva) zakládá oba obchody a jejich metody dopravy a platby. Na ID obchodu visí konstanty App\Enums\Shop, které kód používá napevno, a bez dopravní a platební metody nejde objednávku dokončit.

EshopSeeder (demo vrstva) plní sortiment a objednávky:

  • osm produktů s popisem, parametry, sklademi a obrázkem, z toho dva s velikostmi a jeden s běžící slevou, aby šla vyzkoušet i přeškrtnutá cena,
  • štítky produktů jsou kategorie (Oblečení, Doplňky, Hry, Dárky), protože výpis obchodu z nich staví filtrovací pilulky a tiskne je tak, jak jsou uložené. Filtr kombinuje vybrané kategorie přes AND, proto část produktů patří do dvou, Objednávky má na starost EshopOrderSeeder. Jedenáct objednávek pokrývá kombinace, na kterých se chování liší:
Co Kde to jde vyzkoušet
Všechny stavy nová, čeká na platbu, zpracovává se, odeslaná, dokončená, stornovaná, vrácená
Nákup na účet player1@, support@ a [email protected], heslo password — projde i „Moje objednávky”
Nákup bez účtu tři objednávky hosta s vlastním kontaktem
Košík z víc položek dvě a tři různé produkty v jedné objednávce
Velikost tričko L, XXL, mikina S a M
Sleva objednávky s deskovou hrou, sleva se propíše do položky
Doprava osobní odběr i kurýr
Poznámka zákazníka pět objednávek ji má
Historie od dnešního dne po dva měsíce zpět

Objednávky visí na testovacích účtech z UserRoleCompanySeeder; chybějící účet seeder nespolkne, ale spadne s hláškou, který seeder spustit. Slovenský obchod objednávky nemá, nemá vlastní sortiment a korunová cena v eurech by byla nesmysl.

Ceny jsou v databázi jako základ bez DPH, ale odvozené od koncové částky, takže zákazník vidí kulaté číslo (404,96 + 21 % = 490 Kč). Součet objednávky se počítá od koncové ceny a daň se dopočítá jako zbytek, jinak by sečítání zaokrouhlených základů vyrobilo objednávku za 648,99 Kč místo 649 Kč.

Objednávky se zapisují přes Order::withoutEvents(). OrderObserver totiž na každou změnu stavu posílá zákazníkovi e-mail a seed by jinak rozeslal celou sadu zpráv o objednávkách.

Ligy, podniky i novinky mají v produkci obrázek skoro vždy. Seedery proto cesty vyplňují, ale soubory na disk nekopírují — to dělá samostatný příkaz:

Terminal window
docker compose exec fpm php artisan demo:sync-assets

Příkaz nakopíruje obrázky, které už v repu jsou (public/images/web2/others/), na úložný disk pod demo/ a blog/. Kvůli demo datům se do repa nepřidávají žádné nové binárky. Bez toho kroku dataset funguje, jen se obrázky nenačtou.

Terminal window
# Všechny seedery (DatabaseSeeder)
docker compose exec fpm php artisan db:seed
# Konkrétní seeder
docker compose exec fpm php artisan db:seed --class=CountrySeeder
# Fresh migrate + seed
docker compose exec fpm php artisan migrate:fresh --seed
Terminal window
docker compose exec fpm php artisan make:seeder ProductSeeder
<?php
namespace Database\Seeders;
use App\Models\Product;
use Illuminate\Database\Seeder;
class ProductSeeder extends Seeder
{
public function run(): void
{
$products = [
['name' => 'Product 1', 'price' => 100],
['name' => 'Product 2', 'price' => 200],
];
foreach ($products as $product) {
Product::updateOrCreate(
['name' => $product['name']], // Unique identifikátor
$product // Data k aktualizaci
);
}
$this->command?->info("✅ ProductSeeder dokončen. Vytvořeno/aktualizováno " . count($products) . " produktů.");
}
}
public function run()
{
if (app()->environment('production')) {
$this->command?->warn('⚠️ Seeding je zakázáno v produkčním prostředí.');
return;
}
// Základní data v pořadí závislostí
$this->call([
CountrySeeder::class, // 1. Země (bez závislostí)
RegionSeeder::class, // 2. Regiony (vyžadují Country)
CitySeeder::class, // 3. Města (vyžadují Region)
]);
// Testovací data
$this->call([
UserRoleCompanySeeder::class,
PubSeeder::class, // 4. Puby (vyžadují City)
]);
}

Seedery se v produkci nespouští, DatabaseSeeder je tam odmítne. Data, která musí vzniknout i na produkci (referenční číselníky, přenos nastavení z configu do databáze, jednorázové opravy dat), proto jezdí jako jednorázové operace balíku timokoerber/laravel-one-time-operations.

Chovají se jako migrace: mají pořadí, evidenci v tabulce chk_operations a spustí se právě jednou. Oproti seederu tak chyba neproběhne při každém nasazení, ale jednou a je po ní stopa.

Terminal window
# Nová operace
docker-compose exec fpm php artisan operations:make nazev_operace
# Spuštění čekajících operací (deploy to dělá sám)
docker-compose exec fpm php artisan operations:process --sync
# Přehled, co už proběhlo
docker-compose exec fpm php artisan operations:show

Soubory bydlí v database/operations/, deploy je pouští za migrate --force na produkci, dev, novém webu i experimentálu.

Druh dat Kam
Struktura tabulek migrace v database/migrations/
Referenční data pro produkci operace v database/operations/
Testovací a demo data seeder v database/seeders/
Data pro jeden test factory

Datovou migraci už nepiš. Dřív byl jediný způsob, jak dostat řádky na produkci, insertOrIgnore uvnitř migrace — proto v database/migrations/ pár takových zůstalo (reinsert_shops_data, seed_form_integrations_from_config). Nové věci patří do operací.

Když stejná data potřebuje staging (seeder) i produkce (operace), nesmí být zapsaná dvakrát. Definice žije v katalogové třídě v app/Support/ReferenceData/ a obě cesty ji jen zavolají:

app/Support/ReferenceData/FormIntegrationData.php
FormIntegrationData::apply(bool $overwrite = false);

Parametr $overwrite odděluje obě prostředí. Seeder na stagingu přepisuje, ať je po každém seedu stejný výchozí stav. Operace v produkci jen doplňuje, co chybí — co si klient nastavil v adminu, zůstává.

Factories generují testovací data pro modely.

Terminal window
docker compose exec fpm php artisan make:factory ProductFactory --model=Product
<?php
namespace Database\Factories;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProductFactory extends Factory
{
protected $model = Product::class;
public function definition(): array
{
return [
'name' => $this->faker->words(3, true),
'description' => $this->faker->sentence(),
'price' => $this->faker->randomFloat(2, 10, 1000),
'category_id' => 1, // Nebo Category::factory()
];
}
/**
* State pro drahé produkty
*/
public function expensive(): static
{
return $this->state(fn (array $attributes) => [
'price' => $this->faker->randomFloat(2, 1000, 10000),
]);
}
/**
* State pro vyprodané produkty
*/
public function soldOut(): static
{
return $this->state(fn (array $attributes) => [
'stock' => 0,
]);
}
}
<?php
namespace App\Models;
use Database\Factories\ProductFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasFactory;
protected static function newFactory()
{
return ProductFactory::new();
}
// ... rest of model
}
// Vytvořit jeden produkt (bez uložení)
$product = Product::factory()->make();
// Vytvořit a uložit jeden produkt
$product = Product::factory()->create();
// Vytvořit 10 produktů
$products = Product::factory()->count(10)->create();
// S vlastními atributy
$product = Product::factory()->create([
'name' => 'Custom Product',
'price' => 99.99,
]);
// Použití states
$expensiveProduct = Product::factory()->expensive()->create();
$soldOutProduct = Product::factory()->soldOut()->create();
// S relacemi
$product = Product::factory()
->for(Category::factory())
->create();

Terminal window
# Export do SQL souboru
docker compose exec db mysqldump -u kviz -pkviz kviz > database/schema/mysql-schema.sql
# Export pouze struktury (bez dat)
docker compose exec db mysqldump -u kviz -pkviz --no-data kviz > database/schema/mysql-schema.sql
Terminal window
# Import do existující databáze
docker compose exec -T db mysql -u kviz -pkviz kviz < database/schema/mysql-schema.sql
# Import do testovací databáze
docker compose exec -T db mysql -u kviz -pkviz kviz_testing < database/schema/mysql-schema.sql

Pokud máte příliš mnoho migrací, můžete je “squashnout” do jednoho SQL souboru.

Terminal window
docker compose exec db mysqldump -u kviz -pkviz --no-data kviz > database/schema/mysql-schema-YYYY-MM-DD.sql
Terminal window
mkdir database/migrations/archived
mv database/migrations/2024_*.php database/migrations/archived/
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$sql = file_get_contents(database_path('schema/mysql-schema-2024-12-01.sql'));
DB::unprepared($sql);
}
public function down(): void
{
// Rollback není možný pro squash migraci
throw new Exception('Cannot rollback squashed migrations');
}
};

Terminal window
# Kompletní reset
docker compose exec fpm php artisan migrate:fresh --seed
# Pouze migrace (bez seedování)
docker compose exec fpm php artisan migrate:fresh
Terminal window
docker compose exec fpm php artisan migrate:status
Terminal window
# MySQL CLI
docker compose exec db mysql -u kviz -pkviz kviz
# Nebo přes fpm kontejner
docker compose exec fpm php artisan db
# Tinker (Laravel REPL)
docker compose exec fpm php artisan tinker
Terminal window
# Automatický backup script
./backup-database.sh
# Manuální backup
docker compose exec db mysqldump -u kviz -pkviz kviz > backup-$(date +%Y%m%d-%H%M%S).sql
Terminal window
docker compose exec -T db mysql -u kviz -pkviz kviz < backup-20240101-120000.sql

  • Tabulky: Plural, snake_case s prefixem chk_

    • chk_users, chk_regions, chk_quiz_events
  • Sloupce: snake_case

    • user_id, created_at, display_name
  • Foreign keys: {model}_id

    • country_id, region_id, user_id
  • Pivot tabulky: Alphabetically ordered, singular, s prefixem

    • chk_pub_user, chk_quiz_team
  • Indexy: {table}_{column}_index

    • users_email_index, regions_country_id_index
$table->id(); // BIGINT UNSIGNED AUTO_INCREMENT
$table->string('name', 255); // VARCHAR(255)
$table->text('description'); // TEXT
$table->integer('count'); // INT
$table->decimal('price', 10, 2); // DECIMAL(10,2)
$table->boolean('active'); // TINYINT(1)
$table->timestamp('published_at'); // TIMESTAMP
$table->timestamps(); // created_at, updated_at
$table->softDeletes(); // deleted_at

Terminal window
# Spusťte migrace
docker compose exec fpm php artisan migrate
Terminal window
# Zkontrolujte credentials v .env
# Zkontrolujte, že databázový kontejner běží
docker compose ps db
Terminal window
# Zkontrolujte syntax v migraci
# Zkontrolujte existenci foreign key tabulek
# Rollback a zkuste znovu
docker compose exec fpm php artisan migrate:rollback
docker compose exec fpm php artisan migrate