DEV Community

Cover image for Writing Maintainable Feature test(Real Laravel example)
CodeCraft Diary
CodeCraft Diary

Posted on • Originally published at codecraftdiary.com

Writing Maintainable Feature test(Real Laravel example)

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.

🧩 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);
    }
}

Enter fullscreen mode Exit fullscreen mode

This 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,
]);
}
}

Enter fullscreen mode Exit fullscreen mode

💡 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.

Top comments (0)