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)