Obsah článku
Pokud tvoříte aplikace v Laravelu, určitě jste se setkali se vztahy mezi modely. U jednoduchých aplikací mohou být tyto vztahy jednoduché, kolikrát nepřekročíte jednu úroveň vztahů. Například máte seznam států a k nim příslušející města. Mezi státy a městy nejspíše budete mít vztah OneHasMany/BelongsTo. Ale co když tvoříte daleko obsáhlejší aplikaci, kde například ještě pod danými městy máte další (klidně stejný) vztah vůči prodejnám (tedy každá město může mít neomezeně prodejen). A vy se například potřebujete podívat, v jakých státech existují prodejny s názvem XYZ. Tedy Vaše Query nepůjde pouze o jeden level do hloubky, ale rovnou o dva. Pojďme se spolu podívat, jak pomocí Laravelu můžete tvořit pokročilejší queries.
Vstupní data
Nejdříve je důležité si definovat vstupní data, resp. ukázková data, ze kterých si vyzkoušíme složit query. Pro zjednodušení si představte podobnou databázovou strukturu jako tabulky níže. Tuto strukturu si v rámci našeho MySQL/MaraDB vytvoříme.
Předpokládám, že jste si nainstalovali nebo máte po ruce framework Laravel. Pokud nemáte, můžete si jej nainstalovat.
id | name |
1 | Česká republika |
2 | Slovensko |
3 | Rakousko |
… | … |
id | country_id | name |
1 | 1 | Praha |
2 | 1 | Brno |
3 | 1 | Pardubice |
4 | 2 | Bratislava |
… | … | … |
id | city_id | name |
1 | 1 | KFC |
2 | 1 | Subway |
3 | 4 | KFC |
… | … | … |
Vytvořme si tedy migrace.
Migrace zemí/států
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('countries', function (Blueprint $table) {
$table->id();
$table->string('name', 255);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('countries');
}
};
Migrace měst
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('cities', function (Blueprint $table) {
$table->id();
$table->foreignId('country_id')->constrained();
$table->string('name', 255);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('cities');
}
};
Migrace obchodů
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('shops', function (Blueprint $table) {
$table->id();
$table->foreignId('city_id')->constrained();
$table->string('name', 255);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('shops');
}
};
Seeder
Můžeme si také vytvořit seeder, a tak nemusíme vytvářet záznamy ručně. Jelikož jsem nechtěl využít faker, přeci jen jsem část vytvořil manuálně – pro lepší přehlednost v rámci této ukázky.
Upravme tedy DatabaseSeeder.php:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
$this->call([
CountryPackSeeder::class,
]);
}
}
A vytvořme CountryPackSeeder.php:
<?php
namespace Database\Seeders;
use App\Models\City;
use App\Models\Country;
use App\Models\Shop;
use Illuminate\Database\Seeder;
class CountryPackSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$countries = [
[
'name' => 'Česká republika',
'cities' => [
[
'name' => 'Praha',
'shops' => [
[
'name' => "McDonald's",
],
[
'name' => "KFC",
],
[
'name' => "Subway",
],
]
],
[
'name' => 'Brno',
'shops' => [
[
'name' => "McDonald's",
],
[
'name' => "KFC",
],
]
],
[
'name' => 'Pardubice',
'shops' => [
[
'name' => "KFC",
],
[
'name' => "Subway",
],
]
],
]
],
[
'name' => 'Slovensko',
'cities' => [
[
'name' => 'Bratislava',
'shops' => [
[
'name' => "McDonald's",
],
[
'name' => "KFC",
],
[
'name' => "Subway",
],
]
],
[
'name' => 'Košice',
'shops' => [
[
'name' => "McDonald's",
],
[
'name' => "KFC",
],
]
],
[
'name' => 'Prešov',
'shops' => [
[
'name' => "KFC",
],
[
'name' => "Subway",
],
]
],
]
],
[
'name' => 'Polsko',
'cities' => [
[
'name' => 'Varšava',
'shops' => [
[
'name' => "McDonald's",
],
[
'name' => "KFC",
],
[
'name' => "Subway",
],
]
],
[
'name' => 'Krakov',
'shops' => [
[
'name' => "McDonald's",
],
[
'name' => "KFC",
],
]
],
[
'name' => 'Lodž',
'shops' => [
[
'name' => "KFC",
],
[
'name' => "Subway",
],
]
],
]
],
[
'name' => 'Rakousko',
'cities' => [
[
'name' => 'Vídeň',
'shops' => [
[
'name' => "McDonald's",
],
[
'name' => "KFC",
],
[
'name' => "Subway",
],
]
],
[
'name' => 'Linec',
'shops' => [
[
'name' => "McDonald's",
],
[
'name' => "KFC",
],
]
],
[
'name' => 'Salzburg',
'shops' => [
[
'name' => "KFC",
],
[
'name' => "Subway",
],
]
],
]
],
];
foreach ($countries as $country) {
$newCountry = Country::updateOrCreate(['name' => $country['name']], [
'name' => $country['name']
]);
foreach($country['cities'] as $city) {
$newCity = City::updateOrCreate(
[
'country_id' => $newCountry->id,
'name' => $city['name']
],
[
'country_id' => $newCountry->id,
'name' => $city['name']
]
);
foreach($city['shops'] as $shop) {
Shop::updateOrCreate(
[
'city_id' => $newCity->id,
'name' => $shop['name']
],
[
'city_id' => $newCity->id,
'name' => $shop['name']
]
);
}
}
}
}
}
Nezapomeňte spustit migraci.
sail artisan db:seed --class=CountryPackSeeder
Tím máme data v databázi a můžeme vytvářet zajímavé dotazy.
Modely
Ještě, než začneme tvořit dotaz/querynu, podíváme se na jednotlivé modely.
Country Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Country extends Model
{
use HasFactory;
protected $fillable = [
'name',
];
public function cities()
{
return $this->hasMany(City::class);
}
}
City Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class City extends Model
{
use HasFactory;
protected $fillable = [
'country_id',
'name',
];
public function shops()
{
return $this->hasMany(Shop::class);
}
public function country()
{
return $this->belongsTo(Country::class);
}
}
Shop Model
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Shop extends Model
{
use HasFactory;
protected $fillable = [
'city_id',
'name',
];
public function city()
{
return $this->belongsTo(City::class);
}
}
Routy, controller, view
V rychlosti ještě můžeme přidat kontroler, např. AdvancedQueriesController.php.
<?php
namespace App\Http\Controllers;
use App\Models\Country;
use Illuminate\Http\Request;
class AdvancedQueriesController extends Controller
{
public function countriesWithShopsCount()
{
$countries = Country::all();
return view('advanced-queries.countries-with-shops-count', compact('countries'));
}
}
Kontroler si naroutujeme.
<?php
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
Route::get('/', function () {
return view('welcome');
});
Route::get(
'/advanced-queries',
[
\App\Http\Controllers\AdvancedQueriesController::class,
'countriesWithShopsCount'
]
)->name('advanced-queries');
A vytvoříme pohled/view.
<table border="1">
<thead>
<tr>
<td>Country</td>
<td>Shops</td>
</tr>
</thead>
<tbody>
@foreach ($countries as $country)
<tr>
<td>{{ $country->name }}</td>
<td>How to query shops count?</td>
</tr>
@endforeach
</tbody>
</table>
Výsledkem byste měli na url /advanced-queries vidět tabulku podobnou této:

Dotazy z praxe
Podíváme se na ukázky kódu, které celkem dost používám a vidím je i v ostatních projektech.
1) Počet obchodů v jednotlivých zemích
Tedy potřebujeme získat tabulku, kde v jednom sloupci budou uvedeny země/státy a ve druhém počet obchodů. Hlavně se prosím vyvarujme následujícímu (ani jsem netestoval, zda by vůbec fungovalo):
v kontrolleru například:
$countries = Country::with(['cities', 'cities.shops'])->get();
...
a pak někde v blade.php:
@foreach($countries as $country)
<td>{{ $country->name }}</td>
<td>
@php $myCount = 0; @endphp
@foreach($country->cities as $city)
@php $myCount += count($city->shops); @endphp
@endforeach
{{ $myCount }}
</td>
@endforeach
Jde o to, že:
- není to profesionální
- není to optimalizované
- je to pomalé
- zkrátka odpad… Nemůžete kvůli pár datům žádat databázi o všechna data, která jsou související se státy… 🙂
Jak tedy na to?
Musíme se ptát vztahu, který nepřímo souvisí se státy, tedy Country -> City -> Shop.
HasManyThrough
Jedna z možností, jak získat počet obchodů dle zemí je v použití vztahu hasManyThrough.
Náš Country Model si doplníme o následující metodu:
...
public function shops()
{
return $this->hasManyThrough(Shop::class, City::class);
}
...
V kontroleru můžeme použít následující:
...
public function countriesWithShopsCount()
{
$countries = Country::with('shops')->get();
return view('advanced-queries.countries-with-shops-count', compact('countries'));
}
...
A nakonec pohled/view můžeme upravit, aby obsahoval počet obchodů:
<table border="1">
<thead>
<tr>
<td>Country</td>
<td>Shops</td>
</tr>
</thead>
<tbody>
@foreach ($countries as $country)
<tr>
<td>{{ $country->name }}</td>
<td>{{ $country->shops->count() }}</td>
</tr>
@endforeach
</tbody>
</table>

Když se podíváme detailněji, tento postup vygeneroval tyto SQL dotazy:
select * from `countries`
select `shops`.*, `cities`.`country_id` as `laravel_through_key` from `shops` inner join `cities` on `cities`.`id` = `shops`.`city_id` where `cities`.`country_id` in (1, 2, 3, 4)

Optimalizace dotazu
Pojďme náš dotaz více optimalizovat. Jelikož nás u obchodů zajímá pouze počet, nemusíme získávat veškerá data z tabulky shops. Upravme tedy ještě nás kontroler:
...
public function countriesWithShopsCount()
{
$countries = Country::withCount('shops')->get();
return view('advanced-queries.countries-with-shops-count', compact('countries'));
}
...
<table border="1">
<thead>
<tr>
<td>Country</td>
<td>Shops</td>
</tr>
</thead>
<tbody>
@foreach ($countries as $country)
<tr>
<td>{{ $country->name }}</td>
<td>{{ $country->shops_count }}</td>
</tr>
@endforeach
</tbody>
</table>
Díky tomu jsme se dostaly pouze na jeden SQL dotaz:
select `countries`.*, (select count(*) from `shops` inner join `cities` on `cities`.`id` = `shops`.`city_id` where `countries`.`id` = `cities`.`country_id`) as `shops_count` from `countries`

2) Obchody a k nim příslušné země/státy
Opět se budeme ptát databáze – konkrétně chceme získat obchody a k nim související země (Obchod->Město->Země/stát).
Nejdříve si pojďme připravit kontroler, routu a pohled/view.
Do kontroleru přidáme novou metodu:
...
public function shopsWithCountries()
{
$shops = Shop::all();
return view('advanced-queries.shops-with-countries', compact('shops'));
}
...
Nyní si vytvoříme příslušný pohled/view:
<table border="1">
<thead>
<tr>
<td>Shop</td>
<td>Country</td>
</tr>
</thead>
<tbody>
@foreach ($shops as $shop)
<tr>
<td>{{ $shop->name }}</td>
<td>How to query country name?</td>
</tr>
@endforeach
</tbody>
</table>
A naroutujeme:
...
Route::get(
'/advanced-queries/shops-with-countries',
[
\App\Http\Controllers\AdvancedQueriesController::class,
'shopsWithCountries'
]
)->name('advanced-queries.shops-with-countries');
...
Díky tomu získáme přibližně podobnou tabulku:

Možná Vás napadne, a díky struktuře Modelů to nejspíše bude i fungovat, že na pohledu/view použijete toto:
<table border="1">
<thead>
<tr>
<td>Shop</td>
<td>Country</td>
</tr>
</thead>
<tbody>
@foreach ($shops as $shop)
<tr>
<td>{{ $shop->name }}</td>
<td>{{ $shop->city->country->name }}</td>
</tr>
@endforeach
</tbody>
</table>
Tato možnost Vám však vygeneruje množství SQL dotazů, což není vůbec ideální.

Logiku však můžeme optimalizovat – stačí upravit metodu v kontroleru:
...
public function shopsWithCountries()
{
$shops = Shop::with('city.country')->get();
return view('advanced-queries.shops-with-countries', compact('shops'));
}
...
Pomocí této úpravy jsme docílili toho, že se vygenerovaly pouze 3 SQL dotazy.

Konkrétně se jedná o tyto dotazy:
select * from `shops`
select * from `cities` where `cities`.`id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
select * from `countries` where `countries`.`id` in (1, 2, 3, 4)
Jak si můžete všimnout, stále dostáváme data, která bychom nepotřebovali. Ano, mohli bychom použít select, ale zkusme ještě více tuto logiku optimalizovat.
Bohužel Laravel obsahuje pouze vztah hasManyThrough. V tomto případě potřebujeme pravý opak, tedy vztah belongsToThrough. Můžeme si pomoci balíčkem/package staudenmeir / belongs-to-through od Jonase Staudenmeira.
Po nainstalování balíčku rozšíříme nás Shop Model.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Shop extends Model
{
use HasFactory;
use \Znck\Eloquent\Traits\BelongsToThrough;
protected $fillable = [
'city_id',
'name',
];
public function city()
{
return $this->belongsTo(City::class);
}
public function country()
{
return $this->belongsToThrough(Country::class, City::class);
}
}
Tedy doplnili jsme traitu a přidali metodu country obsahující belongsToThrough. Nyní ještě upravíme náš kontroler.
...
public function shopsWithCountries()
{
$shops = Shop::with('country')->get();
return view('advanced-queries.shops-with-countries', compact('shops'));
}
...
Už se ptáme pouze na vztah country, nikoliv city.country. City můžeme vynechat i v našem pohledu/view:
<table border="1">
<thead>
<tr>
<td>Shop</td>
<td>Country</td>
</tr>
</thead>
<tbody>
@foreach ($shops as $shop)
<tr>
<td>{{ $shop->name }}</td>
<td>{{ $shop->country->name }}</td>
</tr>
@endforeach
</tbody>
</table>
Jak si můžete všimnout, dostali jsme se na dva SQL dotazy.

select * from `shops`
select `countries`.*, `cities`.`id` as `laravel_through_key` from `countries` inner join `cities` on `cities`.`country_id` = `countries`.`id` where `cities`.`id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
Díky tomu jsme úplně vypustili data v rámci měst (city). Takto jsme během pár kroků optimalizovali aplikaci a Vám se tak opět o trochu rychleji bude načítat 🙂
Tip na závěr – další úroveň vztahů
Někdy se však můžete setkat, že potřebujete jít ještě více do hloubky v rámci vztahů. Z ukázky výše jsme se pohybovali ve třech úrovních (např. shop->city->country). Co když nám však přibude další úroveň?
Zkusme se podívat na případ, kdy by pod obchodem byli ještě zaměstnanci (employees).
Migrace
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('employees', function (Blueprint $table) {
$table->id();
$table->foreignId('shop_id')->constrained();
$table->string('name', 255);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('employees');
}
};
Factory
Pro zaměstnance si vytvoříme jednoduchou továrnu s testovacími daty.
<?php
namespace Database\Factories;
use App\Models\Shop;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Employee>
*/
class EmployeeFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'shop_id' => Shop::inRandomOrder()->first()->id,
'name' => fake()->lastName(),
];
}
}
Seeder
Upravíme si naše seedery. Nejdříve soubor si vytvoříme EmployeesSeeder.php.
<?php
namespace Database\Seeders;
use App\Models\Employee;
use Illuminate\Database\Seeder;
class EmployeesSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Employee::factory()->count(200)->create();
}
}
Dále přidáme náš nový seeder do DatabaseSeeder.php.
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
$this->call([
CountryPackSeeder::class,
EmployeesSeeder::class,
]);
}
}
Model
Také je zapotřebí vytvořit model a nastavit mu vztah (obchod <-> zaměstnanec).
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Employee extends Model
{
use HasFactory;
protected $fillable = [
'shop_id',
'name',
];
public function shop()
{
return $this->belongsTo(Shop::class);
}
}
Úpravu uděláme i v modelu Shop, kde uvedeme nový vztah na Employee.
...
public function employees()
{
return $this->hasMany(Employee::class);
}
...
Příklad z praxe – Zobrazení tabulky s počtem zaměstnanců v jednotlivých zemích
V tomto případě se budeme pohybovat do čtvrté úrovně, tedy Stát/země->Město->Obchod->Zaměstnanci. Opět se budeme snažit o co nejlepší možnou optimalizaci SQL dotazu. Než začneme, připravíme si nový pohled, který přidáme do kontroleru a opět naroutujeme.
Kontroler
Můžeme si upravit náš AdvancedQueriesController.php a přidáme do něj novou metodu, která nám bude vracet view.
...
public function countriesWithEmployeesCount()
{
$countries = Country::all();
return view('advanced-queries.countries-with-employees-count', compact('countries'));
}
...
Pohled/View
<table border="1">
<thead>
<tr>
<td>Country</td>
<td>Employees</td>
</tr>
</thead>
<tbody>
@foreach ($countries as $country)
<tr>
<td>{{ $country->name }}</td>
<td>How to query employees count?</td>
</tr>
@endforeach
</tbody>
</table>
Router
Ještě nám zbývá naroutovat metodu z kontroleru na nějakou url adresu.
...
Route::get(
'/advanced-queries/countries-with-employees-count',
[
\App\Http\Controllers\AdvancedQueriesController::class,
'countriesWithEmployeesCount'
]
)->name('advanced-queries.countries-with-employees-count');
...
Jak získat počet zaměstnanců?
Celkem jednoduchá otázka, složitější odpověď. Laravel Eloquent bohužel ani v tomto případě není dostačující na zvládnutí této otázky jednodušší cestou. Máme však řešení, které nám pomůže. Konkrétně se řešení nachází opět u Jonase Staudenmeira, resp. v jeho vytvořeném balíčku staudenmeir / eloquent-has-many-deep.
Po nainstalování balíčku upravme model City:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Country extends Model
{
use HasFactory;
use \Staudenmeir\EloquentHasManyDeep\HasRelationships;
protected $fillable = [
'name',
];
public function cities()
{
return $this->hasMany(City::class);
}
public function shops()
{
return $this->hasManyThrough(Shop::class, City::class);
}
public function employees()
{
return $this->hasManyDeep(Employee::class, [City::class, Shop::class]);
}
}
V kontroleru, abychom co nejvíce optimalizovali, využijeme withCount(), který jsme si ukázali dříve v tomto článku:
...
public function countriesWithEmployeesCount()
{
$countries = Country::withCount('employees')->get();
return view('advanced-queries.countries-with-employees-count', compact('countries'));
}
...
Pohled upravíme o zobrazování počtu:
<table border="1">
<thead>
<tr>
<td>Country</td>
<td>Employees</td>
</tr>
</thead>
<tbody>
@foreach ($countries as $country)
<tr>
<td>{{ $country->name }}</td>
<td>{{ $country->employees_count }}</td>
</tr>
@endforeach
</tbody>
</table>
Výsledek by měl vypadat podobně (jelikož jsme použili továrnu, která generuje zaměstnance u ochodů náhodně, počty se mohu lišit).

Tímto jsme docílili, že se vygeneroval pouze jeden SQL dotaz.
select `countries`.*, (select count(*) from `employees` inner join `shops` on `shops`.`id` = `employees`.`shop_id` inner join `cities` on `cities`.`id` = `shops`.`city_id` where `countries`.`id` = `cities`.`country_id`) as `employees_count` from `countries`

Závěrem
Myslete na to, jak data načítáte a dbejte na to, abyste nezatěžovali server. I z bezpečnostních důvodů není potřeba kolikrát sahat po všech datech, ale jen po vybraných. Nechtějte z databáze data, která ani nevyužijete. Pomůže Vám to v rámci optimalizace aplikace (rychlost).