Security
Security bugs in a Laravel app cluster around the same handful of patterns: an unvalidated $request->all() flowing straight into a Model::create(), a $guarded = [] on a model that accepts user input, a raw SQL string concatenated with a request value, a {!! $bio !!} in a Blade view. None of them are subtle once you know to look for them.
This chapter is the list to look for. Read it once, internalise the names ("Form Request", "Gate", "$fillable", "API Resource"), and use the checklist at the end before opening any PR that touches a write path or an API response.
The shape — a safe write endpoint
A write endpoint in this codebase has four security touch points: a Form Request validates the input, the controller calls Gate::authorize(...), the Contract receives a typed DTO, and the response goes through an API Resource. Every one of them is doing security work.
public function update(UpdateOrderRequest $request, Order $order): JsonResource
{
Gate::authorize('update', $order);
$updated = app(OrderContract::class)->update(
$order,
UpdateOrderData::fromArray($request->validated()),
);
return new OrderResource($updated);
}
The Form Request enforces the input schema, the Gate enforces authorization, the DTO carries only the validated fields, and the Resource controls what leaves the server. If you remove any one of them, you've opened a hole.
Input validation in Form Requests
Never trust $request->all() on a write path. Every write endpoint takes a typed FormRequest with explicit rules; the controller hydrates the DTO from $request->validated(). Read paths may use $request->all() because Filter{Entity}Data only declares safe, nullable properties — extra keys are ignored.
❌ Bad — raw input straight into the DTO
public function store(Request $request)
{
return app(OrderContract::class)->createOrder(CreateOrderData::fromArray($request->all()));
}
✅ Good — Form Request enforces the schema first
class StoreOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'provider_id' => 'required|integer|exists:providers,id',
'amount' => 'required|numeric|min:0',
];
}
}
public function store(StoreOrderRequest $request)
{
return app(OrderContract::class)->createOrder(CreateOrderData::fromArray($request->validated()));
}
The bad version trusts the client to send only the fields you expect. The Form Request makes the schema explicit — anything outside the rules is dropped from validated() before it reaches the DTO.
Mass-assignment protection
The DDD entity template emits protected $guarded = []. That's tolerable for existing models because writes flow through a DTO + Action pipeline that whitelists columns upstream. For new models that accept user input directly, override the template — declare $fillable. Existing models stay as-is; don't bulk-change them.
✅ Good
class Banner extends Model
{
protected $fillable = ['title', 'image_path', 'link', 'is_active'];
}
$guarded = [] model that accepts $request->all() directly is a mass-assignment vulnerability. Either route the write through a DTO + Action (which whitelists), or switch the model to $fillable. Don't do neither.Authorization
Domain-specific abilities live under src/Domain/{Domain}/Models/Abilities/. Cross-cutting policies (for example winch-branch scoping) live in app/Policies/. Gate every state-changing endpoint via Gate::authorize(...), $user->can(...), or @can in Blade. Don't rely on route middleware alone for fine-grained checks.
✅ Good
public function update(UpdateOrderRequest $request, Order $order)
{
Gate::authorize('update', $order);
return app(OrderContract::class)->update($order, UpdateOrderData::fromArray($request->validated()));
}
Route middleware can tell you the user is authenticated; it can't tell you this specific user is allowed to update this specific order. That decision lives in the Ability or Policy and you invoke it explicitly in the controller.
SQL injection — use Eloquent or the query builder
Eloquent and the query builder use parameter binding. Never concatenate user input into raw SQL. For text search, use the whereLikeText macro — see Eloquent and queries.
❌ Bad
DB::select("SELECT * FROM users WHERE email = '{$email}'");
✅ Good
User::where('email', $email)->first();
DB::select('SELECT * FROM users WHERE email = ?', [$email]);
The bad version takes $email as an attacker-controlled string and runs whatever SQL fragment lands in there. The good version sends the value through a bound parameter — the driver guarantees it's treated as data, not SQL.
XSS — Blade auto-escapes
{{ $value }} escapes. {!! $value !!} does not. Reserve {!! !!} for content you have explicitly sanitised or generated server-side — never for user-supplied strings.
✅ Good
{{ $user->name }}
❌ Bad
{!! $user->bio !!}
The bad version renders whatever HTML the user typed into their bio — including <script> tags. If the field is user-controlled, escape it.
Sensitive data exposure
Never return password hashes, API tokens, OTP codes, or secret keys in API responses. Use an API Resource to whitelist exposed fields — JsonResource::toArray() is the only safe place to decide what leaves the server.
✅ Good
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// password, remember_token, api_key NOT included
];
}
return response()->json($user) will serialise every attribute on the model unless $hidden is set, and $hidden is easy to forget. Always wrap a model in an API Resource before returning it.CSRF
Laravel's VerifyCsrfToken middleware protects state-changing web routes by default. Don't disable it. API routes use Sanctum tokens — see API responses.
What to read next
- Anti-patterns — item #5 (fat controllers can hide auth gaps).
- Controllers — the Form Request pattern in context.
- API responses — Sanctum, API Resources, and the response envelope.
- Eloquent and queries — the
whereLikeTextmacro and other safe-by-default query helpers.