Never let user input become part of your SQL or decide which model columns get filled: use query bindings for the first and $fillable plus validated() for the second.
๐ฎSQL injection is like a form where someone writes โand also give me the keys to the safeโ in the name box, and the clerk reads the whole thing out as an order. Mass assignment is like a sign-up form where someone adds an extra line โrole: managerโ and the clerk copies every line onto the staff card without checking.
Your data is the prize
Whole-database risk
A single injectable query can expose or wipe every table, not just the one you were querying.
Silent privilege jumps
Mass assignment lets someone set columns like is_admin just by adding a field to the request.
Laravel makes the safe way easy
Eloquent and the query builder bind values automatically. Problems only appear when you go "raw" or skip validation.
How SQL injection works
SQL injection happens when user input is glued directly into a SQL string. The database can't tell where your query ends and the attacker's text begins.
' OR '1'='1 into the search boxThe fix is parameter binding (also called a prepared statement). Your SQL uses a placeholder like ?, and the value is sent separately. The database always treats it as data, never as code.
SELECT * FROM posts
WHERE title = '' OR '1'='1'
-- the input rewrote the query
The condition is now always true.
SELECT * FROM posts WHERE title = ?
-- binding: "' OR '1'='1"
The database searches for a post literally titled ' OR '1'='1 and finds nothing.
How mass assignment works
Mass assignment means filling many model attributes at once from an array, like User::create($request->all()). If you pass along everything the request contains, the attacker chooses the columns.
is_admin=1 to the profile form with dev tools$user->update($request->all())is_admin column set to 1Write queries and saves that can't be hijacked
-
Use Eloquent and the query builder normally
Methods like
where(),find()andcreate()bind every value for you. This is safe even with raw user input:$posts = Post::where('title', 'like', '%' . $request->input('q') . '%') ->where('category_id', $request->integer('category')) ->get();The string concatenation here only builds the value (
%term%). Laravel still sends it as a binding. -
Going raw? Use
?bindingsSometimes you need a SQL expression Eloquent doesn't offer. Methods like
whereRaw(),selectRaw(),orderByRaw()andDB::select()accept a bindings array. Put a?where each value goes.// โ value passed as a binding Post::whereRaw('LOWER(title) = ?', [strtolower($title)])->get(); // โ named bindings work too DB::select('SELECT * FROM posts WHERE user_id = :id', ['id' => $userId]);โ ๏ธDB::raw()is never escapedDB::raw()tells Laravel "insert this text into the SQL exactly as-is". Never put a PHP variable inside it with"{$var}"or.concatenation.// โ injectable: $sort comes from the URL Post::select(DB::raw("*, {$sort} as rank"))->get(); -
Allow-list column names and sort directions
Bindings protect values, but you can't bind a column name or
asc/desc. When users choose a sort column, check it against a fixed list.$sort = in_array($request->query('sort'), ['title', 'created_at'], true) ? $request->query('sort') : 'created_at'; $dir = $request->query('dir') === 'asc' ? 'asc' : 'desc'; $posts = Post::orderBy($sort, $dir)->paginate(); -
Declare
$fillable(or$guarded) on every model$fillableis an allow-list of attributes thatcreate()andupdate()may set. Anything else in the array is silently ignored.<?php namespace App\Models; class Post extends Model { protected $fillable = ['title', 'body', 'category_id']; // user_id, is_featured, published_at are NOT fillable }$guardedis the opposite: a block-list.protected $guarded = ['is_admin'];allows everything exceptis_admin. Prefer$fillable: when you add a new sensitive column later, it's protected by default. -
Save only
validated()dataA Form Request's
validated()method returns only the fields that have rules. Extra fields the attacker added never reach the model. This is your second lock, on top of$fillable.public function store(StorePostRequest $request) { $post = $request->user()->posts()->create($request->validated()); return redirect()->route('posts.show', $post); }Notice that
user_idcomes from the relationship, not from the form. Set ownership and other sensitive values explicitly on the server.
Avoid this, do that
Post::whereRaw("title = '$title'")->get();
The variable is pasted into the SQL, so a quote in $title rewrites the query.
Post::whereRaw('title = ?', [$title])->get();
// or simply:
Post::where('title', $title)->get();
The value travels as a binding and can never become SQL.
protected $guarded = [];
$user->update($request->all());
Every column is writable, and every request field is passed in. Two safety nets removed at once.
protected $fillable = ['name', 'bio'];
$user->update($request->validated());
Only known, validated fields reach the model, and only allowed columns are written.
By default, non-fillable fields are dropped silently. Turn that into an exception locally so you notice typos and sneaky fields early.
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventSilentlyDiscardingAttributes(! app()->isProduction());
}
- Build a post search with
Post::where('title', 'like', ...). Search for' OR '1'='1and confirm no extra posts appear. - Run
Post::whereRaw('LOWER(title) = ?', ['hello'])->toSql()inphp artisan tinkerand look at the?placeholder. - Add a sortable posts list that only accepts
titleorcreated_atas the sort column. - Add an
is_featuredcolumn, leave it out of$fillable, and try to set it viaPost::create([...]). Check the database. - Enable
preventSilentlyDiscardingAttributesand repeat the previous task to see the exception.
Key takeaways
- Eloquent and the query builder bind values automatically, so normal
where()calls are safe. - With
whereRaw()and friends, always use?placeholders plus a bindings array. - Never interpolate variables into
DB::raw()or any raw SQL string. - Bindings can't protect column names or sort directions: allow-list them.
- Use
$fillableas an allow-list of mass-assignable attributes. - Pass
$request->validated(), not$request->all(), and set ownership on the server.