One of the things I enjoy the most about Laravel is how it encourages writing clean, testable business logic.
Laravel is a modern PHP framework -> https://laravel.com/ that provides elegant tools for routing, validation, and testing — making it a great fit for building maintainable applications.
But as your application grows, you often face situations where simple “happy-path” tests are no longer enough.
When domain rules get more complicated — dependencies between entities, conditions based on state, and layered validation — your feature tests need to evolve with them.
In this post, I’ll walk through a real-world inspired example from a warehouse module where I had to ensure zones (locations in a warehouse) could only be deactivated or updated under specific conditions.
Previously article in this category you can find below:
🧩 The Business Context
Each zone belongs to a category.
- A category can be active or inactive.
- A zone can only belong to an active category.
- A zone cannot be deactivated if it still contains inventory.
Pretty standard business logic — but with enough layers to easily introduce bugs if not properly validated and tested.
🧱 The Controller
Here’s a simplified version of the controller that handles these operations.
I’ve replaced the real model names with more generic ones, but the idea is the same.
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use App\Models\WarehouseZone;
use App\Models\ZoneCategory;
use App\Models\InventoryUnit;
use App\Http\Resources\GeneralResource;
class WarehouseZoneController extends Controller
{
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'zone_code' => 'required|string',
'zone_category_id' => [
'required',
'numeric',
'exists:zone_categories,id',
function ($attribute, $value, $fail) {
$category = ZoneCategory::find($value);
if ($category && $category->inactive) {
$fail('Cannot activate zone with inactive category.');
}
}
],
'name' => 'nullable|string',
'description' => 'nullable|string'
]);
$validator->validate();
$data = $request->all();
$data['unique_code'] = Str::random(10);
$data['category_reference'] = $data['zone_category_id'];
$zone = WarehouseZone::create($data);
return new GeneralResource($zone);
}
public function update(Request $request, $id)
{
$zone = WarehouseZone::findOrFail($id);
$validator = Validator::make($request->all(), [
'zone_code' => 'sometimes|required|string',
'zone_category_id' => [
'sometimes',
'numeric',
'exists:zone_categories,id',
function ($attribute, $value, $fail) use ($zone) {
$newCategory = ZoneCategory::find($value);
if ($newCategory && $newCategory->inactive) {
$fail('Cannot change zone to an inactive category.');
}
}
],
'inactive' => [
'sometimes',
function ($attribute, $value, $fail) use ($zone) {
if ($value && $zone->inventoryUnits()->where('quantity_sum_motion', '>', 0)->exists()) {
$fail('Cannot deactivate zone with inventory.');
}
}
],
'name' => 'nullable|string',
'description' => 'nullable|string'
]);
$validator->validate();
$zone->update($request->all());
return new GeneralResource($zone);
}
public function destroy($id)
{
$zone = WarehouseZone::findOrFail($id);
if ($zone->inventoryUnits()->where('quantity_sum_motion', '>', 0)->exists()) {
return response()->json([
'message' => 'Cannot deactivate zone with inventory.',
'error' => true
], 403);
}
$zone->update(['inactive' => true]);
return response()->json(null, 204);
}
}
PHPThis structure keeps things explicit and easy to reason about — no hidden traits or abstract validation magic.
Every rule is visible and testable.
🧪 The Feature Test
Let’s see how we can write a deeper feature test that not only checks status codes but actually verifies the domain rules in action.
<?php
use Tests\TestCase;
use App\Models\WarehouseZone;
use App\Models\ZoneCategory;
use App\Models\InventoryUnit;
class WarehouseZoneTest extends TestCase
{
protected ZoneCategory $activeCategory;
protected ZoneCategory $inactiveCategory;
protected WarehouseZone $zone;
protected function setUp(): void
{
parent::setUp();
$this->activeCategory = ZoneCategory::factory()->create(['inactive' => false]);
$this->inactiveCategory = ZoneCategory::factory()->create(['inactive' => true]);
$this->zone = WarehouseZone::factory()->create([
'zone_category_id' => $this->activeCategory->id,
'inactive' => false,
]);
}
public function test_zone_cannot_be_deactivated_if_it_contains_inventory()
{
InventoryUnit::factory()->create([
'zone_id' => $this->zone->id,
'quantity_sum_motion' => 25,
]);
$response = $this->putJson("/api/warehouse/zone/{$this->zone->id}", [
'inactive' => true,
]);
$response->assertStatus(422);
$this->assertDatabaseHas('warehouse_zones', [
'id' => $this->zone->id,
'inactive' => false,
]);
}
public function test_zone_cannot_be_moved_to_inactive_category_if_it_contains_inventory()
{
InventoryUnit::factory()->create([
'zone_id' => $this->zone->id,
'quantity_sum_motion' => 25,
]);
$response = $this->putJson("/api/warehouse/zone/{$this->zone->id}", [
'zone_category_id' => $this->inactiveCategory->id,
]);
$response->assertStatus(422);
}
public function test_zone_can_be_deactivated_after_inventory_removed()
{
InventoryUnit::where('zone_id', $this->zone->id)->delete();
$response = $this->putJson("/api/warehouse/zone/{$this->zone->id}", [
'inactive' => true,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('warehouse_zones', [
'id' => $this->zone->id,
'inactive' => true,
]);
}
}
PHP💡 Why Go This Deep?
Many teams stop testing after a single happy-path CRUD test.
But these “business rule” tests are where bugs actually hide.
They ensure your validation logic behaves consistently across multiple layers:
- Validation works as expected (custom rules included)
- Side effects are correct (e.g. cannot deactivate with inventory)
- State transitions follow business rules
In practice, these tests have saved me from several regressions — especially when someone later refactors validation or changes model relationships.
🚀 Key Takeaways
- Keep validation explicit and testable — don’t bury it in abstract layers.
- Write end-to-end tests that mirror actual workflows.
- Focus your tests on the rules that matter to the business, not just syntax.
- Deep tests often uncover broken assumptions early — long before users do.
If you want to make your Laravel tests feel less like “coverage” and more like living business documentation, this approach works well.

