Your authorization test is green. The endpoint returns 403 Forbidden. But did the database stay unchanged?
A denied response proves what the client received. It does not prove what the application did before returning it.
The false-green test
Consider this deliberately incorrect controller fragment:
$order->update(['status' => $request->string('status')->toString()]);
Gate::authorize('update', $order);
If authorization rejects the caller after the update commits, a test that checks only assertForbidden() can still pass. The application returned the expected status and changed a record the caller had no right to change.
The application fix is to authorize before writing. The regression test should preserve both requirements: deny the caller and leave the relevant persisted state unchanged.
Install the test helper
I maintain Laravel AccessProof, an MIT package that combines real in-process Laravel HTTP feature-test requests with explicit database watches.
composer require --dev mykholy/laravel-accessproof:^1.2
It supports Laravel 12/13 and PHP 8.3+. Use APP_ENV=testing and a disposable database. An environment guard cannot prove your configured database is safe.
Watch the state a denied caller must not change
In your Laravel feature test, create an owner, outsider and order using your existing factories. This example assumes your application has tenant_id and status fields and a PATCH order endpoint; adapt them to your domain.
use Mykholy\AccessProof\AccessProof;
AccessProof::request($this)
->as($outsider)
->watch($order, ['tenant_id', 'status'])
->patchJson(
'/api/orders/'.$order->getKey(),
['status' => 'paid'],
)
->send()
->assertForbidden()
->assertUnchanged();
Take the snapshot before send(). The package reads the persisted watched row after the request without relying on an Eloquent tenant scope that might hide it. Updating or deleting that row fails the unchanged assertion. It does not snapshot every table.
With the incorrect controller, the status assertion can pass while the unchanged assertion fails. After moving authorization before the update, both should pass. That is the expected demonstration, not a claim that an arbitrary host application has been tested.
Give denial tests a positive control
For sensitive reads, 404 creates another false-green possibility: perhaps the URI does not exist. Some apps deliberately return 404 for inaccessible records. Rejecting every 404 is not the solution.
Verify that the same URI works for the owner, then deny the outsider:
AccessProof::compareRead($this)
->owner($owner)
->outsider($outsider)
->getJson('/api/orders/'.$order->getKey())
->withoutBodyMarker('ACCESSPROOF_TEST_SECRET_42')
->verify();
Seed the protected resource with that synthetic marker. Never use a real credential or customer identifier. The paired check expects an allowed owner response and denied outsider response from the same GET endpoint. Authentication remains application-owned; the outsider stays authenticated afterward, so keep guest scenarios in separate methods.
Counts cannot detect every replacement
For a denied POST, watchCount('orders', ['tenant_id' => $targetTenantId]) detects a net count change. A delete followed by an insert may keep the same count. For that boundary, use a bounded row watch:
AccessProof::request($this)
->as($outsider)
->watchRows('orders', ['tenant_id' => $targetTenantId], ['status', 'total'])
->postJson('/api/orders/batch-action')
->send()
->assertDenied()
->assertUnchanged();
The predicate is required. The default bound is 500 matching rows and the default identity column is id. Narrow the rows and columns to your business invariant. This checks final state in that set, not every transient operation.
What this does not establish
AccessProof does not discover routes or enforce policies. You supply meaningful actor/resource/action combinations. It does not observe email, remote APIs, queued jobs, filesystem writes or unwatched databases. A temporary mutation that is later reversed may escape final-state comparison. Add your application's assertions for those effects.
The repository includes an anonymized maintainer-reported SaaS field report. Its host app is private; that is supporting context, not independent public replication or security certification.
Start with one denied write and assert the state it must preserve. Installation, API reference and limitations.
*Visuals: AI-generated editorial artwork; copyable code is included below each visual.
Disclosure: Prepared with AI assistance and checked against package documentation and source APIs. Application-specific examples require adaptation and execution in your test environment; these adapted snippets were not freshly executed during preparation.*


Top comments (0)