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/Collectionsstr()->
Multiple TransformationsThe link to the GitHub Repository is provided at the end of the tutorial.
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.
So, to rephrase, we have two main challenges here:
So, what we need to do:
Here's the screenshot of the table I got at the end:
Now, let's build it step by step.
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:
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:
->whereNotIn()
and ->random()
without touching the database. It's much faster.reference_number
with a chained operation using Laravel str()
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:
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:
$colorData
is a Collection, so we don't query the DB anymore->where()
on the Collection. That Collection is multi-dimensional, so we need to query the second level value of size.name
first()
record at all, and then we return its reference_number
if it exists. This syntax avoids additional if-statement to check if the record exists.And this is the visual result:
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.
That's it for this tutorial. You can find the complete repository here on GitHub.