How Architecture Tests Caught a Data Isolation Bug Before It Reached Production

php dev.to

TL;DR: Pest PHP architecture tests let you write your team coding rules as automated tests. Controllers using the DB facade directly? Build fails. Model missing the tenant isolation trait? Build fails. These tests caught a critical data isolation bug three weeks after we added them.


We had a rule in our team wiki. Every model that stores tenant-specific data must use the BelongsToTenant trait. This trait adds a global scope that automatically filters all queries by the current tenant. Without it a query could accidentally return data from other tenants.

The rule was clear. Everyone knew about it. It was mentioned in onboarding. It was in our code review checklist.

A new developer joined the team. Three weeks later they added a new model without the trait. The code review missed it because the reviewer was focused on the business logic which was actually well written. The model went to production.

For two days one tenant could see fragments of another tenant data in a specific report. We caught it from a support ticket not from our tests.

That was the day I added architecture tests.

What Architecture Tests Look Like

Pest PHP includes an arch() function that lets you write assertions about your code structure. Not about what your code does but about how it is organized.

// tests/Architecture/ArchTest.php

arch('models must use BelongsToTenant trait')
    ->expect('App\Models')
    ->toUseTrait('App\Traits\BelongsToTenant');

arch('controllers should not use DB facade')
    ->expect('App\Http\Controllers')
    ->not->toUse('Illuminate\Support\Facades\DB');

arch('services should not use Request')
    ->expect('App\Services')
    ->not->toUse('Illuminate\Http\Request');

arch('value objects must be readonly')
    ->expect('App\ValueObjects')
    ->toBeReadonly();

arch('soft delete models must also have tenant scope')
    ->expect('App\Models')
    ->toUseTrait('Illuminate\Database\Eloquent\SoftDeletes')
    ->toUseTrait('App\Traits\BelongsToTenant');
Enter fullscreen mode Exit fullscreen mode

These tests run in our CI pipeline on every commit. If any rule is violated the build fails. The developer gets a clear error message telling them exactly which rule they broke and which file broke it.

The Tests That Have Saved Us

Here are the architecture tests that have caught the most violations in the last 6 months.

The tenant isolation test caught 4 new models that were missing the BelongsToTenant trait. Each one would have been a potential data leak. This single test justified the entire effort.

The controller purity test catches developers who write database queries directly in controllers instead of using service classes. This happens more often than you would think especially when someone is "just adding a quick endpoint."

// This would fail the architecture test
class ReportController extends Controller {
    public function index() {
        $data = DB::table('orders')
            ->where('tenant_id', auth()->user()->tenant_id)
            ->sum('total');

        return response()->json($data);
    }
}
Enter fullscreen mode Exit fullscreen mode

The right approach is moving that query into a service class. The controller just calls the service and returns the result. This keeps controllers thin and makes the business logic testable in isolation.

The dependency direction test ensures that inner layers never depend on outer layers. Services do not import Request objects. Models do not import controller classes. This keeps the dependency graph clean and makes the code easier to test.

Going Beyond Basic Rules

The basic trait and dependency checks are useful but the real power comes from combining architecture tests with custom logic.

arch('controller methods should use Form Requests')
    ->expect('App\Http\Controllers')
    ->not->toUse('Illuminate\Http\Request');

arch('no env calls outside config')
    ->expect('App')
    ->not->toUse(['env']);
Enter fullscreen mode Exit fullscreen mode

The env() test is one I wish I had added earlier. Calling env() directly in application code works in development but returns null when the config is cached in production. This is a classic Laravel gotcha that catches developers at least once. The architecture test prevents it permanently.

I also added a test that ensures no model uses the $guarded property set to an empty array. We had an incident where a mass assignment vulnerability slipped through because a developer set $guarded to an empty array thinking it was the same as setting $fillable. It is not. An empty $guarded means every field is mass assignable.

arch('models should not use empty guarded')
    ->expect('App\Models')
    ->not->toUseStrictlyEmptyGuarded();
Enter fullscreen mode Exit fullscreen mode

A Mistake I Made Along the Way

When I first set up architecture tests I went overboard. I added 20 rules in one pull request. The CI pipeline lit up like a Christmas tree. Over 150 violations across the codebase.

The team was annoyed. Nobody wanted to fix 150 violations that had nothing to do with their current feature work. I had to revert the PR and take a different approach.

The lesson was clear. You can not enforce architectural rules retroactively on a large codebase all at once. It does not work socially even if it works technically.

How to Introduce Architecture Tests to an Existing Project

Here is the approach that worked for us after I learned from that mistake.

Step 1. Start with the most critical rule only. For us that was the tenant isolation trait. One test. Fix the handful of violations. Merge it.

Step 2. Add one new rule per week. Give the team time to adjust and fix existing violations.

Step 3. For rules with many existing violations use the ignoring() method to exclude legacy code.

arch('controllers should not use DB')
    ->expect('App\Http\Controllers')
    ->not->toUse('Illuminate\Support\Facades\DB')
    ->ignoring('App\Http\Controllers\Legacy');
Enter fullscreen mode Exit fullscreen mode

This lets you enforce the rule for all new code while giving the team time to refactor old code.

Step 4. Track violations over time. We added a simple script that counts the number of ignoring() exceptions. That number should go down every sprint not up. We went from 47 exceptions down to 12 over four months. The trend line matters more than the absolute number.

Real Numbers from Production

After running architecture tests for 6 months the results spoke for themselves.

The tenant isolation test caught 4 models missing the BelongsToTenant trait. Each would have been a data leak between tenants. Response time to catch these dropped from "whenever someone notices in production" to "immediately in CI."

The controller purity test caught 23 violations in the first month alone. After 3 months that number dropped to 2 per month. Developers learned the pattern and stopped writing database queries in controllers.

The env() test caught 7 direct calls that would have broken in production when config caching was enabled. One of those was in the payment processing flow. That single catch probably saved us a weekend of debugging.

What I Would Do Differently

I would add architecture tests on day one of every new project. Adding them to an existing codebase is painful because of all the existing violations. Starting fresh means the rules are enforced from the first commit and there is never any catch-up work.

I also would have been more aggressive about testing dependency directions. We caught the DB-in-controller pattern early but we were slow to add tests for other dependency violations. By the time we added them there were 40+ violations to fix.

And I would create a shared package of common architecture tests that every project uses. We ended up writing very similar tests across three different projects. A reusable test suite would have saved time. I have not built that package yet but it is on my list.

The Bigger Picture

Architecture tests are not about being strict for the sake of being strict. They are about making your codebase predictable.

When every model has the tenant trait you know data isolation is enforced everywhere. When no controller touches the database directly you know the service layer is the single source of truth. When dependencies flow in one direction you know refactoring one layer will not break another.

These guarantees compound over time. Six months in the codebase is still clean because the tests prevent it from degrading. New developers write code that follows the patterns automatically because the CI pipeline will not let them do otherwise.

Your team rules should live in your test suite not in a wiki.

What architecture rules would you enforce if you had automated tests for them?

Source: dev.to

arrow_back Back to Tutorials