As our Laravel applications grow, we inevitably face a common architectural dilemma: where do we put complex business logic that dictates whether an entity meets certain criteria?
You start simple. A user is allowed to purchase a premium item if they are active. Easy—you write a quick Eloquent scope. But three months later, the business team changes the rules. Now, a user can only buy that item if they are active, have a verified email, have spent at least $500 in the last 90 days, and are not flagged for suspicious activity.
Suddenly, your code becomes littered with long chains of Eloquent scopes in your queries, and worse, identical blocks of if-else statements in your PHP services to check the exact same logic for already-loaded models.
This is where the codebase starts to smell. We are violating the Single Responsibility Principle, duplicating rules, and creating a maintenance nightmare. To fix this, developers often reach for complex design patterns that end up overengineering the solution, violating the KISS (Keep It Simple, Stupid) principle.
But there is a elegant, highly practical pattern that solves this perfectly without breaking a sweat: The Specification Pattern.
Previous article in refactoring category: https://codecraftdiary.com/2026/06/15/laravel-event-driven-architecture/
The Real-World Problem: The “Premium Customer” Mess
Let’s look at a concrete example. Imagine an e-commerce platform where we need to determine if a customer qualifies for a VIP Discount.
The business rules for a VIP customer are:
- The account must be active.
- The user must have a verified email.
- They must have made at least 5 orders in total.
- They must not be on our fraud blacklist.
The Naive Laravel Approach
Typically, you see developers solving this in two ways. First, they write a massive query in a controller or service:
// 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();
PHPThis works fine for fetching data from the database. But what happens when you already have a User instance in memory—say, inside a Job or an Event Listener—and you need to check if this specific user qualifies?
You end up duplicating the logic in pure 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();
}
PHPWhy this breaks the KISS principle:
- Duplication: If the fraud rule changes, you have to find and rewrite both the SQL/Eloquent logic and the in-memory PHP logic.
- Model Bloat: Shoving this into the
Usermodel makes it a “God Object” over time. - Hidden Rules: Your business logic is trapped inside technical database implementation details.
Enter the Specification Pattern
The Specification Pattern solves this by turning a business rule into a first-class citizen—a single, isolated, reusable class.
A strict specification pattern can sometimes get overly complex with abstract syntax trees and custom expression builders. To keep it KISS-compliant, we are going to build a pragmatic version tailored for modern PHP 8.5+ and Laravel.
Let’s define a simple interface for our specifications:
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;
}
PHPImplementing the Specification
Let’s build our IsVipCustomer specification. Instead of writing abstract logic, we put our exact four business rules inside this class.
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');
});
}
}
PHPNotice how clean this is. The technical details of how we determine a VIP customer are encapsulated in exactly one file.
Making It Truly Useful: Combining Specifications
Where this pattern completely demolishes standard refactoring approaches is when business requirements change or combine. What if we want to find users who are VIP Customers AND are Located in the EU (for tax or shipping promotions)?
Instead of writing a third massive scope, we can create a simple composition layer. Let’s create an 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);
}
}
}
PHPPutting It Into Practice (Where It Pays Off)
Let’s look at how this simplifies our daily Laravel tasks. We’ll look at a Controller (database querying) and a Service/Job (in-memory validation).
Scenario A: Querying the Database
Inside your Admin Dashboard controller, you need to list all VIP customers from the EU to send them a special newsletter.
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'));
}
}
PHPScenario B: In-Memory Domain Validation
Now imagine a completely different part of the app. A user clicks “Claim VIP Reward”. The user is already logged in, so we already have the $user instance loaded in memory. We don’t want to hit the database with complex heavy joins again if we can avoid it.
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!']);
}
}
PHPWhy This is NOT Overengineering
If you look at the code above, a skeptic might say: “Why did we create three classes when a couple of Eloquent scopes could do the trick?”
Here is why this approach honors the KISS principle when dealing with real complexity:
- The “Single Source of Truth” rule: If the marketing team changes the definition of a VIP customer to require 10 orders instead of 5, you change one number in one class. Your controllers, jobs, and models remain completely untouched.
- Database vs. Memory consistency: Standard Eloquent scopes cannot be run on a regular PHP collection or an already-loaded model without triggering fresh SQL queries. The Specification Pattern bridges this gap natively.
- Impeccable Testability: Testing complex business logic mixed with controllers is painful. Testing a Specification class is a pure unit test. You pass a mock user, assert
trueorfalse, and you’re done in milliseconds.
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));
}
PHPSummary: When to Use It
Don’t use this pattern for simple things like User::where('role', 'admin'). That would be overengineering.
Reach for the Specification Pattern when:
- A business rule is dynamic and changes often based on business requirements.
- You need to use the exact same rule for both database filtering and in-memory validation.
- You need to combine multiple business rules dynamically depending on the context (e.g., matching different combinations of criteria for user segmentation).
By encapsulating volatile business logic into isolated specifications, you keep your models thin, your controllers clean, and your application incredibly adaptive to future changes. It’s Clean Architecture at its most practical level.

