Most developers think of security as a stage — an audit to be done after a feature is coded. Security research teaches you that it's a way of thinking.
The shift in perspective
When you develop, you think about what the code should do. When you look for vulnerabilities, you think about what the code could do if someone uses it differently than you intended.
These two perspectives radically change the way you model problems.
The most common errors I observe
1. Trusting user input too early (IDOR)
An IDOR (Insecure Direct Object Reference) occurs when an application exposes a direct reference to an internal database object (like an ID) and trusts user input to access or modify it.
// ❌ DANGEROUS — the ID comes directly from the request without owner validation
public function destroy(Request $request): RedirectResponse
{
Document::findOrFail($request->id)->delete();
return back();
}
// SECURE — Laravel Policy validates ownership
public function destroy(Document $document): RedirectResponse
{
$this->authorize('delete', $document);
$document->delete();
return back();
}The first version works perfectly in development. In production, any authenticated user can delete another user's document by simply manipulating the ID in the request parameters.
2. Mass Assignment vulnerabilities
Mass Assignment occurs when an application saves user input parameters (e.g. is_admin = true) directly to the database without checking which fields are allowed to be modified.
// ❌ RISKY — unless the model has a very restrictive $fillable array
User::create($request->all());
// SECURE — explicitly defining only allowed input fields
User::create($request->only(['name', 'email', 'password']));3. Race Conditions on critical resources
This type of bug occurs when two concurrent requests execute at the exact same millisecond to manipulate a resource (like a wallet balance or product stock), bypassing state checks.
// ❌ DANGEROUS — both concurrent requests might pass the check before decrementing
if ($wallet->balance >= $amount) {
$wallet->decrement('balance', $amount);
}
// SECURE — pessimistic locking lockForUpdate within a SQL transaction
DB::transaction(function () use ($wallet, $amount) {
$wallet->lockForUpdate()->find($wallet->id);
if ($wallet->balance < $amount) {
throw new InsufficientFundsException();
}
$wallet->decrement('balance', $amount);
});Warning: This type of bug is invisible in standard unit tests and extremely hard to reproduce manually. It requires robust SQL transactions and pessimistic locking (lockForUpdate()).
What it changes in my daily work
Threat Modeling first
For every feature I build, I ask three questions: Who can call this endpoint? What happens if two actions occur concurrently? What can a malicious user send?
Centralized Policies
Instead of scattering if ($user->isAdmin()) statements throughout controllers, I centralize authorization rules in dedicated Laravel Policy classes.
State Transition Validation
Objects shouldn't jump between states arbitrarily (e.g. draft directly to published). State transitions should be controlled by a State Pattern or dedicated transitions.
Zero-Trust internals
I do not blindly trust data retrieved from the database either if it controls critical logic, such as user roles, account balances, or operational status.
Security as a design constraint, not a layer
The main takeaway from my experience: security is not something you add at the end. It must be built into the architectural decisions from day one.
A well-structured codebase, with clear separation of concerns (Services, Policies, Transactions) and centralized authorization policies, is naturally much harder to compromise.