Laravel protects many technical defaults. It rotates sessions, verifies CSRF, escapes Blade output and binds query values. The framework does not know the rules of a product: which user may see an invoice, whether an approval may be reversed or what should happen when a queued job runs after a role change.
The relevant security property is not “the route has authentication middleware”. It is “no user can reach data or actions outside their tenant and current permission”.
That property must mean the same thing in the data model, application code and tests. Security then becomes verifiable instead of depending on one developer remembering every edge case.
From feature to security invariant
A feature usually describes the desired success. A security invariant adds what must never happen, even with manipulated requests, stale jobs or concurrent workflows.
| Product function | Plausible abuse | Security invariant | Evidence |
|---|---|---|---|
| View invoice | replace the object ID | current tenant and permitted role only | cross-tenant feature test |
| Approve quotation | approve one's own draft | four-eyes rule and permitted state | policy and transition test |
| Build export | role revoked after dispatch | authorise again during job execution | queue integration test |
| Download document | reuse an old signed link | current policy still applies | test after access revocation |
| Process webhook | replay the event | provider ID causes at most one mutation | replay and concurrency test |
The application is reachable through more than controllers. Every connection to the core needs an explicit trust decision and a negative test.
Tenant isolation is a system property
A tenant_id check in a controller is not sufficient. The same boundary applies everywhere data is loaded, cached or delivered:
- HTTP and API routes
- policies and nested relationships
- queue jobs, commands and scheduled tasks
- exports, downloads and temporary links
- cache keys, search and broadcast channels
- administrative tools
Plain Route Model Binding resolves an object but does not authorise access. The policy therefore represents the complete business decision:
final class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id
&& $user->is_active
&& $user->can('invoice.read');
}
}
public function show(Invoice $invoice): InvoiceResource
{
Gate::authorize('view', $invoice);
return InvoiceResource::make($invoice);
}
The important test does not merely prove the allowed request. It deliberately submits a valid ID from another tenant:
it('does not expose an invoice from another tenant', function () {
$user = User::factory()->for($tenantA)->create();
$invoice = Invoice::factory()->for($tenantB)->create();
$this->actingAs($user)
->getJson(route('invoices.show', $invoice))
->assertForbidden();
});
Authorisation names actions, not merely roles
Admin and editor rarely describe a business permission precisely enough. Useful policies use product language: approve, cancel, refund, publish or export.
One decision may depend on several conditions:
- Does the object belong to the current tenant?
- Does the user hold this exact capability?
- May the user approve their own change?
- Is the object in a permitted state?
- Has a deadline or amount threshold been exceeded?
A hidden button answers none of these questions. The interface explains a decision; the server-side policy enforces it.
State transitions are the real attack surface
Many business-logic vulnerabilities appear between two individually valid states. An order may be editable but must not jump from draft to paid. An approval may be allowed only from pending and must not be performed by its author.
| From | Action | To | Additional condition |
|---|---|---|---|
draft |
submit | pending |
required data is complete |
pending |
approve | approved |
a different authorised person |
approved |
execute | completed |
approved version is unchanged |
completed |
correct | new operation | no silent reverse mutation |
State verification and mutation belong in one atomic operation:
DB::transaction(function () use ($invoice, $user): void {
$locked = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
Gate::forUser($user)->authorize('approve', $locked);
throw_unless($locked->status === InvoiceStatus::Pending, DomainException::class);
$locked->update([
'status' => InvoiceStatus::Approved,
'approved_by' => $user->id,
'approved_at' => now(),
]);
});
lockForUpdate() is not a general security switch. It locks this row only within an appropriate database transaction. Business rules and database constraints remain necessary.
Input reaches the system through many paths
Form Requests provide a useful HTTP boundary, but data also arrives through webhooks, CSV imports, queue payloads, CLI commands and internal tools. Every source remains untrusted until its structure and relationship to the current context have been verified.
final class UpdateProfileRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'timezone' => ['required', Rule::in(DateTimeZone::listIdentifiers())],
];
}
}
// Only validated fields for this use case reach Eloquent.
$user->update($request->safe()->only(['name', 'timezone']));
Status, prices, roles, tenant_id and approval fields do not come from this request. They are derived by the already authorised application action.
A queued job does not inherit a trusted user context
A job runs later without the original HTTP request. Users may be suspended, permissions revoked or objects moved between dispatch and execution. The job therefore carries identifiers, rebuilds context and authorises the current situation.
final class BuildInvoiceExport implements ShouldQueue
{
public function __construct(
public readonly int $tenantId,
public readonly int $requestedBy,
public readonly int $exportId,
) {}
public function handle(): void
{
$tenant = Tenant::findOrFail($this->tenantId);
$user = User::whereBelongsTo($tenant)->findOrFail($this->requestedBy);
$export = Export::whereBelongsTo($tenant)->findOrFail($this->exportId);
Gate::forUser($user)->authorize('build', $export);
// Load data and build the export only now.
}
}
At dispatch, verify whether a job may be started. At execution, verify whether it may still act.
Files and external destinations need separate boundaries
Uploads and outgoing HTTP requests connect the application to parsers, filesystems and remote networks. Ordinary field validation does not cover those risks completely.
| Uploads | Outgoing requests |
|---|---|
| size and detected MIME type | server-selected hosts |
| generated storage name | DNS and IP validation |
| private storage | no automatic redirects |
| authorisation on every download | connection and total timeout |
| isolation for risky parsers | response-size and egress limits |
$document = $request->validate([
'document' => ['required', 'file', 'mimetypes:application/pdf', 'max:10240'],
])['document'];
$path = $document->store("tenants/{$tenant->id}/documents", 'local');
$response = Http::baseUrl(config('services.billing.url'))
->connectTimeout(2)
->timeout(5)
->withoutRedirecting()
->get('/v1/status');
Laravel's mimes and mimetypes rules derive a detected type from the content. This is not malware scanning or isolated parsing of complex documents. Likewise, withoutRedirecting() only stops redirects. User-selectable targets still need host, DNS, IP and network controls.
Repetition and concurrency are normal operations
Webhooks, queued jobs and user actions can arrive again or at the same time. Exactly-once delivery is not a safe default assumption. The application must process repetition safely.
- A provider event has a unique key backed by a unique constraint.
- Business mutation and state verification happen atomically.
- A competing insert is treated as an already received event.
- External side effects receive their own idempotency key.
- A concurrency test proves behaviour under parallel execution.
A transaction alone provides neither replay protection nor idempotency. It defines only the atomic boundary in which this particular invariant is checked and changed.
Audit logs document decisions, not secrets
A useful record answers who performed which business action on what object, when, from which state to which state and with what result.
{
"event": "invoice.approved",
"actor_id": 481,
"tenant_id": 27,
"subject_id": 8842,
"from": "pending",
"to": "approved",
"request_id": "req_01J..."
}
Passwords, session cookies, complete tokens and unnecessary document content do not belong in the log. Traceability comes from deliberate metadata, not from copying the request.
The negative matrix is part of the definition of done
Every critical action should include at least these counter-proofs:
- unauthenticated user is rejected
- wrong role is rejected
- correct permission in the wrong tenant is rejected
- correct user in a prohibited object state is rejected
- additional privileged fields are ignored or rejected
- old link fails after access is revoked
- repeated and concurrent requests preserve the invariant
- queue, API, export and download enforce the same rule
These tests are not a separate security suite beside the product. They document the feature's boundaries and run with every refactor.
How a robust review starts
- Identify valuable data, critical actions and roles.
- Define the realistic abuse case and invariant for each action.
- Trace every entry point: HTTP, API, queue, broadcast, file and integration.
- Review policy, state model, database boundaries and operations together.
- Reproduce the attack and preserve it as a negative regression test.
The result is not the longest possible findings list. It is a prioritised map of reachable risks, shared root causes and the tests that prevent recurrence.
Conclusion
Laravel provides strong security primitives. The decisive work starts where framework features end: tenants, business actions, states and asynchronous data paths. Expressing these boundaries as invariants and testing them negatively produces safer software, clearer architecture and more dependable change.