Зависит от того, что именно Вы делаете.
Если это, грубо говоря, лабораторная работа по нормализации таблиц - тогда ТОЛЬКО через промежуточную таблицу, за иные варианты Вам снимут голову.
Many-to-many через pivot table чаще всего не нравится тем, что для добычи результата приходится делать двойной джойн, а потом каким-то образом разгребать дублирующиеся данные "основной" таблицы.
Если не требуется искать блюда по ингредиентам ("найти все блюда, в которых есть чеснок") - тогда можно и схитрить немного.
Обычный вариант обхода - добавить немного скриптовой части:
- выгребаем данные основной таблицы: select * from meals where ...;
- скриптом собираем полученные ID в массив и выполняем второй запрос: select * from ingredients inner join meals_ingredients where meals_ingredients.meal_id IN (...тут список полученных на первом шаге id)
- разгребаем полученные данные по конкретным meal_id.
Немного более извращенный - сериализовать список использованных ингредиентов в отдельное поле блюда (например, тип text). При этом в базах, отличных от mysql, можно будет еще и отлично искать по этому полю. Этакий noSQL в условиях, максимально приближенных к боевым.
Фишка в том, что классическая нормализация many-to-many заранее учитывает тот факт, что первичные ключи могут (внезапно!) меняться. На практике же это ооооочень редкий случай.
При этом скоростные тесты показывают, что выполнение двух запросов по времени сравнимо с выполнением одного мегаджойна, просто за счет того, что объем отдаваемых БД данных существенно меньше.
Так что, как говорится, не Коддом единым.