Security Best Practices
Laravel protects against most common web vulnerabilities by default, without any extra setup — but "by default" is not the same as "no matter what you do." Several of these protections can be accidentally switched off, and it's worth understanding exactly what Laravel is doing for you so you recognize the moment you're about to undo it.
CSRF Protection: The @csrf Directive
Cross-Site Request Forgery protection stops a malicious third-party site from silently submitting a
form to your application using a logged-in user's own browser session. Laravel's
VerifyCsrfToken middleware checks every state-changing request (POST, PUT, PATCH,
DELETE) for a valid token — which means every Blade form needs the @csrf directive
inside it:
<form method="POST" action="/articles">
@csrf
<input type="text" name="title">
<button type="submit">Save</button>
</form>
@csrf renders a hidden input containing a token tied to the user's session. Without it,
submitting the form doesn't silently succeed — Laravel rejects the request outright with a
419 "Page Expired" response, which is usually the first clue that a form is
missing its @csrf field.
Mass Assignment Protection: $fillable vs. $guarded
As covered in Eloquent ORM Basics, calling
Article::create($request->all()) mass-assigns every key in the request straight onto
model attributes. $fillable is the whitelist that makes this safe, by naming exactly
which columns are allowed to be set this way:
<?php
class Article extends Model
{
protected $fillable = ['title', 'body'];
}
$guarded = [] is dangerous: $guarded is the inverse —
it names columns that are not mass-assignable, and everything else is allowed. Setting
protected $guarded = []; means nothing is protected — every column on the
table, including ones like is_admin or role that a form should never be
able to touch, becomes settable from raw request input. It's tempting because it "just works"
during development, but it quietly removes a real security boundary.
SQL Injection: Automatic Protection, and How to Break It
Every query built through Eloquent or the Query Builder is automatically parameter-bound — values
are never concatenated directly into the SQL string, so user input can't be crafted to change the
query's meaning. That protection covers where(), find(), and everything
else built the normal way, with no extra effort required.
That automatic protection stops, however, the moment you build raw SQL yourself by concatenating a string:
<?php
$users = DB::select("select * from users where email = '" . $request->email . "'");
A malicious value for email can break out of the quotes and change what the query does
entirely — this is exactly the class of bug SQL injection describes.
The fix is to keep using parameter binding, even in raw SQL — a ? placeholder with values passed separately:
<?php
$users = DB::select('select * from users where id = ?', [$id]);
With a placeholder, the database driver sends the value separately from the query structure, so there's no way for it to be interpreted as SQL — no matter what characters it contains.
XSS Protection: Blade's Automatic Escaping
As covered in Blade Templating Basics, Blade's standard
{{ }} output syntax runs every value through PHP's htmlspecialchars()
automatically, converting characters like < and > into harmless
entities before they reach the page:
<p>{{ $comment->body }}</p> <!-- safe: any HTML/JS in $comment->body is escaped, not executed -->
{!! !!}, skips escaping
entirely and prints the value exactly as stored — appropriate only for content you trust completely,
like HTML you generated yourself. Used on untrusted user input, it's a direct Cross-Site Scripting
hole: a comment body containing <script> tags would execute in every other
visitor's browser that views the page.
<p>{!! $comment->body !!}</p> <!-- dangerous if $comment->body came from user input -->
Password Hashing With the Hash Facade
Passwords should never be stored as plaintext, and Laravel makes the correct behavior the easy one
— the Hash facade wraps a strong, salted hashing algorithm (bcrypt by default) behind a
single call:
<?php
use Illuminate\Support\Facades\Hash;
$user->password = Hash::make($request->password);
$user->save();
Checking a login attempt works the same way, comparing the hash rather than the raw string:
Hash::check($request->password, $user->password). Never write your own hashing scheme
or store a password with a reversible encryption — Hash::make() exists specifically so
you never have to.
Rate Limiting Sensitive Routes
Login forms, password-reset requests, and similar routes are prime targets for brute-force attempts
— hundreds of guesses fired at once. The throttle middleware, covered in
Middleware, caps how many requests a given user or IP can make
in a time window:
<?php
Route::post('/login', [LoginController::class, 'login'])
->middleware('throttle:5,1');
throttle:5,1 allows at most 5 attempts per minute from the same source before returning
a 429 Too Many Requests response — enough friction to make automated password guessing
impractical, without getting in a real user's way.
Keeping Dependencies Updated
Laravel and every package it depends on are installed and versioned through Composer, and security fixes are published regularly. Checking for outdated packages, and updating deliberately rather than letting a project drift for years, is a real security practice, not just housekeeping:
# see which installed packages have newer versions available
composer outdated
# update dependencies according to composer.json version constraints
composer update