Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

ayazsarwar's avatar

get brand's product count associated with category

Hello guys, I have 5 tables below, I want to get all categories with eagerloading brands and their product counts associated with that category.

my tables structure:

Brands Table:

id,
name

Categories Table:

id,
name,
parent_id

Products Table:

id,
name,
brand_id,
category_id <-- I have also tried to explicitly set it to get category brand product count. but :/

Product Channels Table:

id,
name,
brand_id,
product_id,
channel_id

I have tried:

$categories = Category::selectRaw('*')->forWebsite()
            ->withCount('products')
            ->with([
                'brands' => function ($query1) {
                    $query1->select('brands.id', 'brands.name')
                        ->distinct('brands.id')
                        ->withCount(['products'=>function($query2){
                            $query2->where('products.category_id','categories.id');
                        }]);
                }
            ])->get();

I'm able to fetch categories and their brands but I'm unable to get product counts of brands associated with the category. :/

But I'm unable to get brand products count belonging to that category.

I have also tried raw query by joining these tables but this is not efficient and very very slow, it takes more then 2 mins to fetch categories along with brands & their product counts from around 36k products.

And in this query i was returning a custom array which i would format as required in my api resource

though this query returns the desired data;

$categories = Category::selectRaw('
                categories.*,
                GROUP_CONCAT(
                        distinct p.brand_id,\'|\',(
                        SELECT COUNT(*)
                        FROM products AS p2
                        JOIN category_product AS cp2 ON cp2.product_id = p2.id
                        WHERE (p2.brand_id = p.brand_id AND cp2.category_id = categories.id)
                        having count(*)>0
                    )
                ) as brands,
                count(p.id) as total_items
            ')
            ->leftjoin('category_product as cp', 'cp.category_id', '=', 'categories.id')
            ->leftjoin('products as p', 'cp.product_id', '=', 'p.id')
            ->when(current_auth() == 'website', function ($query) {
                return $query->where(['categories.active' => true, 'categories.website' => true]);
            })
            ->groupBy('categories.id')
            ->orderBy('categories.name')
            ->get();

the desired data after formatting in api Resource is:

{
        "id": 71,
        "parent_id": 8,
        "name": "Air Freshners",
        "slug": "household-category-air-freshners",
        "thumb": null,
        "brands": [
		{
                	"id": 368,
                	"total_items": 2
           	},
		{
                	"id": 257,
                	"total_items": 1
           	}
        ],
        "total_items": 3
    }

my category model has relations:

    public function products()
    {
        return $this->belongsToMany(Product::class);
    }

// Im using Products table as pivot for brand_category relation here
    public function brands()
    {
        return $this->belongsToMany(Brand::class,'products');
    }

No how do i do this by eagerloading these relations.

I need your help guys! help! :/

0 likes
6 replies
rodrigo.pedra's avatar

Try this:

$categories = Category::query()
    ->forWebsite()
    ->with([
        'brands' => function ($relation) {
            $relation
                ->select(['brands.id', 'brands.name'])
                ->selectSub(function ($query) {
                    $query
                        ->selectRaw('COUNT(DISTINCT aggregate.id)')
                        // alias sub-query products table to aggregate
                        ->from('products', 'aggregate')
                        // reference outer query products table (the pivot table)
                        ->whereColumn('aggregate.brand_id', 'products.brand_id')
                        ->whereColumn('aggregate.category_id', 'products.category_id');
                }, 'products_count')
                // cast the sub-query results as integer
                ->withCasts(['products_count' => 'integer']);
        },
    ])->get();

The built-in withCount() also adds a sub-query for counting, but don't let us customize the "binding" columns' table qualification.

1 like
ayazsarwar's avatar

Thankyou @rodrigo.pedra <3, I was able to reduce response time from 2 mins to 5-10 seconds but its still longer then it should be.

I have modified it further to get exact results that I want like:

$categories = Category::query()
            ->select('categories.*')
            ->with([
                'brands' => function ($brand) {
                    $brand
                        ->select(['brands.id', 'brands.name'])
                        ->selectSub(function ($query) {
                            $query
                                ->selectRaw('COUNT(DISTINCT aggregate.id)')
                                // alias sub-query products table to aggregate
                                ->from('products', 'aggregate')
                                // reference outer query products table (the pivot table)
                                ->whereColumn('aggregate.brand_id', 'products.brand_id')
                                ->whereColumn('aggregate.category_id', 'products.category_id')
                                ->whereRaw('exists (select * from `product_channels` where `aggregate`.`id` = `product_channels`.`product_id` and (`product_channels`.`active` = 1 and `product_channels`.`website` = 1) and `discontinued_at` is null and `product_channels`.`deleted_at` is null)')
                                ->where(['aggregate.active' => true, 'aggregate.website' => true]);
                        }, 'products_count')->where(['brands.active' => true, 'brands.website' => true])->having('products_count','>=',1);
                    // cast the sub-query results as integer
                    // ->withCasts(['products_count' => 'integer']);
                },
            ])->withCount(['products' => function ($query) {
                return $query->where(['active' => true, 'website' => true])
                ->whereHas('channel', function ($query) {
                    return $query->where(['active' => true, 'website' => true])->whereNull('discontinued_at');
                },'>=',1);
            }])
            ->where(['active'=>true,'website'=>true])
            ->get();

This query does brings the exact/correct results, but the problem is the execution time.

The query you provided took ~8 seconds and after i further filtered with product channels, its taking 15-30 seconds.

I have tried to get explain statement for subquery and its some indexing problem.

But I think i'm doing something wrong with model relations.

Previously when I was unable to get results, I had added category_id in products table, and was using products table as pivot for category -> brands relation (which was a workaround to test the desired results) where it should be category -> category_product -> products -> brand

i.e category->belongsToMany->products->belongsTo->brand

how do i get this relation working perfectly so i dont get this long execution time?

I dont understand if its hasManyThrough scenerio or not.

Please suggest. Thanks

ayazsarwar's avatar

@michaloravec Thankyou, Even if I remove the the whereHas statement, the eagerloaded relation subquery is taking long to execute, any advices for that?

MichalOravec's avatar

You still use ->whereRaw('exists ...there, it's the same as whereHas.

1 like
rodrigo.pedra's avatar

I would try adding each constraint and check the performance impact.

Also, you seen to be repeating a lot the active and website conditions. Are those indexed?

If you are using MySQL 8, or other DBMS that supports generated columns, I'd add a stored generated column and add an index to that.

I think MySQL 5.7 supports generated columns, but I don't remember if you can create an index on those columns on MySQL 5.7.

You could of course create a composite index from the columns that are used together to flag a record as "visible"/"invisible". But as you seem to be using several fields for flagging that, maybe a index on a generated column that only resolves to 1 when visible or 0 when invisible would perform better.

Creating the correct index might help a lot on this performance issue.

Mind that I am not saying the index on the generated column is the correct index to be created. But I've used this approach in some projects for a similar scenario (composing multiple columns for a "on"/"off" flag) with good results.

I also would consider @michaloravec suggestion on considering using regular JOIN for some parts of your query.

The ->withCount(...) and ->selectSub() work by adding dependent sub-queries to the SELECT clause.

Although those sub-queries are handy, they will perform worst than a JOIN when the query optimizer can't convert them to a JOIN, for simpler sub-queries it can.

Just to be clear the query optimizer is a DBMS component, not a Laravel feature.

For example in the ->selectSub(...) inside the brands eager-loading, you have an EXISTS sub-query as a WHERE clause inside the sub-query generated by the ->selectSub(...). That is one sub-query the query optimizer might not be able to optimize. So it ends executing it for each retrieved record leading to a decreased performance similar to the N+1 problem, but at the database level.

As a last resource, if you come up with a optimized SQL query that you find very hard to convert to Eloquent, you can hydrate eloquent models from a SQL result:

$sql = '... very optimized SQL query ...';

// bindings are optional, but are convenient 
// to avoid SQL injection issues
$categories = Category::query()->fromQuery($sql, ['binding1', 'binding2', ...]);

// you will need to lazy eager load any relation 
// as ->fromQuery(...) will ignore any ->with(...) calls.
$categories->load(['brand' => function ($query) {...}]);

Not the ideal thing to do, but it is something you can reach for if you really need it.

Hope any of this helps.

Please or to participate in this conversation.