Notice: this tutorial has both video version (see above) and text version, and also has links to the repository at the end.
I received an email from a person asking for help showing the table of data with dynamic columns. In this tutorial, I will show you exactly how I did it.
This will also be a good practice on these topics:
-
groupBy()
on Eloquent/Collections - Seeding Data with Minimizing DB Queries
- Generating a Field Value with
str()->
Multiple Transformations - Eager Loading to avoid N+1 Query Problem
The link to the GitHub Repository is provided at the end of the tutorial.
The Original Question
Notice: the question is shortened for clarity.
I have been struggling to generate a HTML table with php/laravel.
See below:
The sizes (headers) will be created by the user, so they are dynamic and therefore might be in different order, which makes it a bit harder for me to match the header with the row.
Another point is, even though there is no color/size associated with the product, I still want to display the table as an empty field for those ones, like in the example above.
The challenge -> The Plan
So, to rephrase, we have two main challenges here:
- Dynamic columns that should come from DB
- Showing the cell/column with empty values
So, what we need to do:
- Prepare the project and seed the data
- Get the Product data grouped by color name
- Get all Sizes to populate the column names
- Present it all in Blade, including empty values
Here's the screenshot of the table I got at the end:
Now, let's build it step by step.
Preparing DB Schema
I will summarize the DB structure to just the Migration files. If you want all the details of Models, there's a repository link at the end of this tutorial.
Migration for "products":
Schema::create('products', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('code'); $table->timestamps();});
Migration for "colors":
Schema::create('colors', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps();});
Migration for "sizes":
Schema::create('sizes', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps();});
Migration for "sizes":
Schema::create('product_color_sizes', function (Blueprint $table) { $table->foreignId('product_id')->constrained()->onDelete('cascade'); $table->foreignId('color_id')->constrained()->onDelete('cascade'); $table->foreignId('size_id')->constrained()->onDelete('cascade'); $table->string('reference_number'); $table->timestamps();});
Here's the visual DB schema generated with DBeaver:
Seeding Semi-Fake Data
Typically, developers seed data with one Seeder per model and use Factories. But in this case, I decided to create one seeder file and generate data that would be realistic and similar to what the question author posted.
php artisan make:seeder FakeDataSeeder
Then you may or may not add it to the main DatabaseSeeder
:
database/seeders/DatabaseSeeder.php:
class DatabaseSeeder extends Seeder{ public function run(): void { $this->call(FakeDataSeeder::class); }}
Then what's inside the Seeder? It's a pretty long piece of code. Let's divide it into two steps.
First, we seed sizes and colors from a list:
database/seeders/FakeDataSeeder.php:
use App\Models\Color;use App\Models\Size; class FakeDataSeeder extends Seeder{ public function run() { $sizes = [ 'XS', 'S', 'M', 'L', 'XL', 'XXL', ]; foreach ($sizes as $size) { Size::create(['name' => $size]); } $colors = [ 'Red', 'Blue', 'Green', 'Yellow', 'Black', 'White', ]; foreach ($colors as $color) { Color::create(['name' => $color]); } // ... }}
Then, we prepare the data: we take all those records into Collections and choose which product names/types we seed. This way, we don't need to query the DB to select color/size/name each time.
Finally, we make 10 iterations to create a product with various names/colors/sizes.
I will comment on the code itself for more clarity.
database/seeders/FakeDataSeeder.php:
use App\Models\Product;use App\Models\Color;use App\Models\Size; class FakeDataSeeder extends Seeder{ public function run() { // $sizes = [...]; // $colors = [...]; $productNames = collect([ 'T-Shirt', 'Polo Shirt', 'Hoodie', 'Sweatshirt', 'Jacket', 'Jeans', 'Trousers', ]); $productColors = Color::pluck('name', 'id'); $productSizes = Size::all(); // We will create 10 products for ($i = 1; $i <= 10; $i++) { $product = Product::create([ // Pick one random name from the Collection 'name' => $productNames->random(), 'code' => rand(1, 1000) ]); foreach ($productColors as $colorId => $colorName) { // We generate up to 6 random sizes // But they shouldn't be repeated, so we have $usedSizes $usedSizes = []; for ($j = 0; $j < rand(0, 6); $j++) { $size = $productSizes->whereNotIn('id', $usedSizes)->random(); if ($size && $colorId) { $usedSizes[] = $size->id; // We use hasMany relationship to create record $product->productColorSizes()->create([ 'size_id' => $size->id, 'color_id' => $colorId, 'reference_number' => str($product->code)->append( '-', str($colorName) ->limit(2, '') ->upper(), '-', str($size->name) ->upper(), ) ]); } } } } }}
I like two things about this method:
- We filter the data from pre-filled Collections with
->whereNotIn()
and->random()
without touching the database. It's much faster. - We generate the
reference_number
with a chained operation using Laravelstr()
helper. This beauty needs individual attention.
So you will understand better, this is the result.
I enjoy using str()->
methods if I have multiple string transformations.
Ok, so now, to seed the data, you can run the primary seed or a specific class:
php artisan db:seed
Or:
php artisan db:seed --class=FakeDataSeeder
And here's what we have in our database:
Getting and Grouping Data into Table
I know, I know. The tutorial is about grouping, and we get to it only now. But that's the point: some of you may find it helpful to also see the "boring" parts of preparing demos :)
Anyway, we will have this Route:
routes/web.php:
use App\Http\Controllers\ProductController; Route::get('products/{product}', ProductController::class);
The Controller is invokable with one __invoke()
method, which will use Route Model Binding for the product:
app/Http/Controllers/ProductController.php:
use App\Models\Product;use App\Models\Size; class ProductController extends Controller{ public function __invoke(Product $product) { // This is to avoid N+1 query: will explain later in detail $product->load(['productColorSizes.color', 'productColorSizes.size']); // This is to populate the columns for the table $sizes = Size::pluck('name'); // We're using groupBy from the relationship's relationship $productSizingTable = $product->productColorSizes->groupBy('color.name'); return view('products.show', compact('product', 'productSizingTable', 'sizes')); }}
And then, in Blade, we have this for the table:
resources/views/products/show.blade.php:
// ...<table class="table-auto w-full"> <thead> <tr> <th class="px-4 py-2">#</th> @foreach($sizes as $size) <th class="px-4 py-2">{{ $size }}</th> @endforeach </tr> </thead> <tbody> @foreach($productSizingTable as $color => $colorData) <tr> <td class="border px-4 py-2">{{ $color }}</td> @foreach($sizes as $size) <td class="border px-4 py-2">{{ $colorData->where('size.name', $size)->first()?->reference_number }}</td> @endforeach </tr> @endforeach </tbody></table>
This line is the most important for showing the specific cell:
$colorData->where('size.name', $size)->first()?->reference_number
Things to notice here:
- The
$colorData
is a Collection, so we don't query the DB anymore - We just use
->where()
on the Collection. That Collection is multi-dimensional, so we need to query the second level value ofsize.name
- We use PHP nullsafe operator to check if there's the
first()
record at all, and then we return itsreference_number
if it exists. This syntax avoids additional if-statement to check if the record exists.
And this is the visual result:
Avoiding N+1 Query with Eager Loading
I already showed you the Controller line to ->load()
additional data. But I want to emphasize it and show you before/after if we didn't do this.
For that, I've installed Laravel Debugbar to see the SQL queries running under the hood.
composer require barryvdh/laravel-debugbar
Notice: You don't necessarily need this specific package. I have this tutorial with alternatives: Laravel Eloquent: 4 Tools to Debug Slow SQL Queries
So, this is what we see at the bottom of the page:
5 DB queries, no big deal.
And now, let's comment out one line in Controller:
public function __invoke(Product $product){ // $product->load(['productColorSizes.color', 'productColorSizes.size']); $sizes = Size::pluck('name'); $productSizingTable = $product->productColorSizes->groupBy('color.name');
Refresh the page and see this in the Debugbar:
33 queries, 26 of them duplicated! Wow.
Why is this happening?
Look at this statement in our Controller:
$productSizingTable = $product->productColorSizes->groupBy('color.name');
We're trying to group by productColorSizes.color
relationship, with two levels. The first level doesn't need to be eager loaded in our case, but the second level causes additional color queries if we don't use ->load()
.
Also, another line of grouping, this time in the Blade:
$colorData->where('size.name', $size)->first()?->reference_number
If we don't load the productColorSizes.size
in the Controller, Laravel will query the database each time for each size.
So, use Eager Loading if you search or group by two-level relationships.
And, of course, prevent/log N+1 queries, I have a tutorial about it: Laravel: Automatically Log N+1 Queries to Bugsnag or Similar Tool.
GitHub Repository
That's it for this tutorial. You can find the complete repository here on GitHub.
I use this in my model:
protected $with ='client'];
but I still get warning from debugbarabout N+1 query. It's asking me to add App\Models\User in the model whereas I already defined the relationship of client in the method:public function client(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); }
What am I doing wrong here?
Maybe you need to have
return $this->belongsTo(Client::class, 'user_id');
instead?Hard to say without debugging the full code and where N+1 comes from, exactly.
Thank you Povilas for your suggestion. Client is a user and has a user role "client". I will try to post relevant code soon.
Hey, interestingly, what would you do if it were necessary to create a link between products based on the characteristic?
property
id title
property_values
id property_id title
product_properties
product_id property_values_id
and how to expand it further to make linking between products?
Use pivot table and $model->sync or $model->attach to attach the characteristics.
https://ibb.co/yVGGRpr
I mean, how should I write an migration to get such a result.
Each property is a separate product, collected in one group according to its characteristics.
And I have a question, how organize migrations