По мере роста наших приложений на 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-клиента следующие:
Аккаунт должен быть активен.
У пользователя должен быть подтвержден email.
У него должно быть минимум 5 заказов в общей сложности.
Он не должен находиться в нашем черном списке мошенников.
Наивный подход в 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 при работе с реальной сложностью:
Правило «Единственного источника правды» (Single Source of Truth): Если отдел маркетинга изменит определение VIP-клиента и потребует 10 заказов вместо 5, вы измените одно число в одном классе. Ваши контроллеры, джобы и модели останутся абсолютно нетронутыми.
Согласованность БД и памяти: Стандартные Eloquent scopes нельзя выполнить на обычной PHP-коллекции или уже загруженной модели без отправки новых SQL-запросов. Паттерн «Спецификация» (Specification Pattern) решает эту проблему «из коробки».
Безупречная тестируемость: Тестировать сложную бизнес-логику, смешанную с контроллерами — настоящая мука. Тестирование класса спецификации — это чистый юнит-тест. Вы передаете мок пользователя, проверяете 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) на самом практичном уровне.
Комментарии (0)
Пока нет комментариев — будьте первым.