Если предположить, что категория у продукта может быть одна, то так.
Если категорий может быть несколько, то надо будет поправить связь.
Данные о продуктах запакованы в JSON, можете какие угодно поставить.
SELECT
`category`.`title`,
CONCAT(
'{',
'"hasMore":', IF(COUNT(`product`.`id`)>3, 'true', 'false'), ', ',
'"products":[',
COALESCE(GROUP_CONCAT(
JSON_OBJECT('id', `product`.`id`, 'title', TRIM(`product`.`title`))
ORDER BY `product`.`title` ASC
LIMIT 3
)),
']',
'}'
) AS product
FROM
`category`
LEFT JOIN `product` ON (`category`.`id`=`product`.`category_id`)
GROUP BY `category_id`
PS Чтоб строка в GROUP_CONCAT не обрезалась рекомендуется увеличть максимальную длину GROUP_CONCAT до максимально возможной цфиры. Если это невозможно сделать глобвально, то хотя бы на сессию (выполнять сразу после подключения к БД):
SET SESSION group_concat_max_len = 1000000;
Ну и потом в коде как-то так (предположим, что результаты выполнения запроса вы сложили в переменную $categories и это массив/итератор):
<?php foreach ($categories as $category) { ?>
<h3><?= $category->title ?></h3>
<?php $products = json_decode($category->products) ?>
<?php if (!empty($category->hasMore)) { ?>
<?php foreach ($products as $product) { ?>
<span>Id: <?= $product->id ?>, Title: <?= $product->title ?></span>
<?php } ?>
<?php } ?>
<?php if (!empty($category->hasMore)) { ?>
<a>Смотреть больше</a>
<?php } ?>
<?php } ?>