По мере роста наших приложений на Laravel мы неизбежно сталкиваемся с распространенной архитектурной дилеммой:  куда поместить сложную бизнес-логику, которая определяет, соответствует ли сущность определенным критериям?

Вы начинаете с простого. Пользователю разрешено купить премиум-товар, если он активен. Легко — вы пишете быстрый scope в Eloquent. Но через три месяца бизнес-команда меняет правила. Теперь пользователь может купить этот товар только в том случае, если он активен, подтвердил свой email, потратил не менее 500 $ за последние 90 дней и не отмечен за подозрительную активность.

Внезапно ваш код наполняется длинными цепочками Eloquent scopes в запросах и, что еще хуже, одинаковыми блоками с if-else в PHP-сервисах для проверки абсолютно той же логики на уже загруженных моделях.

Именно здесь в кодовой базе появляется душок. Мы нарушаем принцип единственной ответственности (Single Responsibility Principle), дублируем правила и создаем кошмар для поддержки. Чтобы исправить это, разработчики часто обращаются к сложным паттернам проектирования, которые лишь усложняют решение и нарушают принцип KISS (Keep It Simple, Stupid).

Но есть элегантный и очень практичный паттерн, который идеально решает эту проблему без лишних усилий:  паттерн «Спецификация» (Specification Pattern).

 Предыдущая статья в категории рефакторинга: https://codecraftdiary.com/2026/06/15/laravel-event-driven-architecture/

Реальная проблема: каша из логики «VIP-клиента»

Давайте рассмотрим конкретный пример. Представьте платформу электронной коммерции, где нам нужно определить, имеет ли клиент право на VIP-скидку.

Бизнес-правила для VIP-клиента следующие:

  1. Аккаунт должен быть активен.

  2. У пользователя должен быть подтвержден email.

  3. У него должно быть минимум 5 заказов в общей сложности.

  4. Он не должен находиться в нашем черном списке мошенников.

Наивный подход в Laravel

Обычно разработчики решают это двумя способами. Во-первых, пишут громоздкий запрос в контроллере или сервисе:

 // In a Controller or Service
$vipUsers = User::query()
    ->where('is_active', true)
    ->whereNotNull('email_verified_at')
    ->whereHas('orders', function ($query) {
        $query->where('status', 'completed');
    }, '>=', 5)
    ->whereDoesntHave('flags', function ($query) {
        $query->where('type', 'fraud');
    })
    ->get();
 

Это отлично работает для получения данных из базы данных. Но что происходит, когда у вас уже есть инстанс User в памяти — например, внутри Job или Event Listener — и вам нужно проверить, подходит ли этот конкретный пользователь?

Вы заканчиваете тем, что дублируете логику на чистом PHP:

 public function qualifiesForVipDiscount(User $user): bool
{
    return $user->is_active 
        && $user->email_verified_at !== null
        && $user->orders()->where('status', 'completed')->count() >= 5
        && !$user->flags()->where('type', 'fraud')->exists();
}
 

Почему это нарушает принцип KISS:

  • Дублирование: если правило фрода изменится, вам придется искать и переписывать как SQL/Eloquent-логику, так и PHP-логику для работы в памяти.
  • Разрастание модели: заталкивание этой логики в модель User со временем превращает ее в «Божественный объект» (God Object).
  • Скрытые правила: ваша бизнес-логика оказывается заперта внутри технических деталей реализации базы данных.

Встречайте паттерн «Спецификация»

Паттерн «Спецификация» решает эту проблему, превращая бизнес-правило в полноправного гражданина — единый, изолированный и переиспользуемый класс.

Строгий паттерн спецификации иногда может усложняться абстрактными синтаксическими деревьями и кастомными конструкторами выражений. Чтобы сохранить соответствие  KISS, мы построим прагматичную версию, адаптированную для современного PHP 8.5+ и Laravel.

Давайте определим простой интерфейс для наших спецификаций:

 namespace App\Specifications;

use Illuminate\Database\Eloquent\Builder;

interface Specification
{
    /**
     * Check if a given object satisfies the specification in-memory.
     */
    public function isSatisfiedBy(mixed $candidate): bool;

    /**
     * Apply the specification directly to an Eloquent query builder.
     */
    public function apply(Builder $query): void;
}
 

Реализация спецификации

Давайте создадим нашу спецификацию IsVipCustomer. Вместо написания абстрактной логики мы помещаем ровно наши четыре бизнес-правила внутрь этого класса.

 namespace App\Specifications;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;

class IsVipCustomer implements Specification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof User) {
            return false;
        }

        return $candidate->is_active
            && $candidate->email_verified_at !== null
            && $candidate->orders->where('status', 'completed')->count() >= 5
            && !$candidate->flags->contains('type', 'fraud');
    }

    public function apply(Builder $query): void
    {
        $query->where('is_active', true)
            ->whereNotNull('email_verified_at')
            ->whereHas('orders', function ($q) {
                $q->where('status', 'completed');
            }, '>=', 5)
            ->whereDoesntHave('flags', function ($q) {
                $q->where('type', 'fraud');
            });
    }
}
 

Заметьте, насколько это чисто. Технические детали того, как мы определяем VIP-клиента, инкапсулированы ровно в одном файле.

Делаем паттерн по-настоящему полезным: объединение спецификаций

В чем этот паттерн полностью превосходит стандартные подходы к рефакторингу, так это при изменении или комбинации бизнес-требований. Что если мы хотим найти пользователей, которые являются  VIP-клиентами И при этом  находятся в ЕС (для налоговых или промо-акций по доставке)?

Вместо того чтобы писать третий громоздкий scope, мы можем создать простой слой композиции. Давайте создадим AndSpecification:

 namespace App\Specifications;

use Illuminate\Database\Eloquent\Builder;

class AndSpecification implements Specification
{
    private array $specifications;

    public function __construct(Specification ...$specifications)
    {
        $this->specifications = $specifications;
    }

    public function isSatisfiedBy(mixed $candidate): bool
    {
        foreach ($this->specifications as $spec) {
            if (!$spec->isSatisfiedBy($candidate)) {
                return false;
            }
        }
        return true;
    }

    public function apply(Builder $query): void
    {
        foreach ($this->specifications as $spec) {
            $spec->apply($query);
        }
    }
}
 

Применение на практике (где это окупается)

Давайте посмотрим, как это упрощает наши повседневные задачи в Laravel. Мы рассмотрим контроллер (запросы к базе данных) и сервис/Job (валидация в памяти).

 Сценарий А: Запрос к базе данных

Внутри контроллера панели администратора вам нужно получить список всех VIP-клиентов из ЕС, чтобы отправить им специальную рассылку.

 namespace App\Http\Controllers;

use App\Models\User;
use App\Specifications\AndSpecification;
use App\Specifications\IsVipCustomer;
use App\Specifications\IsLocatedInEU;

class VipNewsletterController extends Controller
{
    public function __invoke()
    {
        // Define the composite rule
        $vipInEuSpec = new AndSpecification(
            new IsVipCustomer(),
            new IsLocatedInEU()
        );

        // Apply it directly to the query
        $query = User::query();
        $vipInEuSpec->apply($query);

        $users = $query->paginate(30);

        return view('admin.vip-newsletter', compact('users'));
    }
}
 

 Сценарий Б: Валидация доменной логики в памяти

Теперь представьте совершенно другую часть приложения. Пользователь нажимает «Получить VIP-награду». Пользователь уже авторизован, поэтому экземпляр $user у нас уже загружен в памяти. Мы хотим избежать повторных тяжелых SQL-запросов с комплексными join к базе данных, если это возможно.

 namespace App\Http\Controllers;

use App\Specifications\IsVipCustomer;
use Illuminate\Http\Request;

class RewardController extends Controller
{
    public function claim(Request $request, IsVipCustomer $vipSpec)
    {
        $user = $request->user();

        // Check the exact same business rule in-memory
        if (!$vipSpec->isSatisfiedBy($user)) {
            return response()->json([
                'error' => 'You do not qualify for this reward.'
            ], 403);
        }

        // Process reward...
        return response()->json(['success' => 'Reward claimed!']);
    }
}
 

Почему это НЕ оверинжиниринг

Глядя на код выше, скептик может сказать: «Зачем создавать три класса, если пара Eloquent scope справилась бы с этой задачей?»

Вот почему такой подход соответствует принципу KISS при работе с реальной сложностью:

  1.  Правило «Единственного источника правды» (Single Source of Truth): Если отдел маркетинга изменит определение VIP-клиента и потребует 10 заказов вместо 5, вы измените одно число в одном классе. Ваши контроллеры, джобы и модели останутся абсолютно нетронутыми.

  2.  Согласованность БД и памяти: Стандартные Eloquent scopes нельзя выполнить на обычной PHP-коллекции или уже загруженной модели без отправки новых SQL-запросов. Паттерн «Спецификация» (Specification Pattern) решает эту проблему «из коробки».

  3.  Безупречная тестируемость: Тестировать сложную бизнес-логику, смешанную с контроллерами — настоящая мука. Тестирование класса спецификации — это чистый юнит-тест. Вы передаете мок пользователя, проверяете true или false, и всё готово за миллисекунды.

 public function test_user_with_insufficient_orders_is_not_vip()
{
    $user = new User(['is_active' => true, 'email_verified_at' => now()]);
    $user->setRelation('orders', collect()); // 0 orders
    $user->setRelation('flags', collect());

    $spec = new IsVipCustomer();

    $this->assertFalse($spec->isSatisfiedBy($user));
}
 

Итог: Когда применять этот паттерн

Не используйте этот паттерн для простых вещей вроде User::where('role', 'admin'). Это как раз и будет оверинжинирингом.

Используйте паттерн «Спецификация», когда:

  • Бизнес-правило динамическое и часто меняется в зависимости от требований бизнеса.

  • Вам нужно использовать одно и то же правило как для  фильтрации в базе данных, так и для валидации в памяти.

  • Вам нужно динамически комбинировать несколько бизнес-правил в зависимости от контекста (например, сопоставлять различные комбинации критериев для сегментации пользователей).

Изолируя изменчивую бизнес-логику в отдельные спецификации, вы сохраняете модели тонкими, контроллеры — чистыми, а само приложение делает невероятно гибким к будущим изменениям. Это чистая архитектура (Clean Architecture) на самом практичном уровне.