In one sentence

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.

Why it matters

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.

Attack #1

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.

๐Ÿ˜ˆAttackertypes ' OR '1'='1 into the search box
๐ŸงตYour codepastes it into the SQL string
๐Ÿ—„๏ธDatabaseruns a query whose meaning has changed
๐Ÿ’ฅEffectevery row returned, or data altered
The attacker's quote mark closes your string early, and the rest of their input becomes SQL code.

The 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.

โŒ Interpolated

          SELECT * FROM posts
          WHERE title = '' OR '1'='1'
          -- the input rewrote the query
        

The condition is now always true.

โœ… Bound

          SELECT * FROM posts WHERE title = ?
          -- binding: "' OR '1'='1"
        

The database searches for a post literally titled ' OR '1'='1 and finds nothing.

Attack #2

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.

๐Ÿ˜ˆAttackeradds is_admin=1 to the profile form with dev tools
๐Ÿ“ฆController$user->update($request->all())
๐Ÿ—„๏ธDatabaseis_admin column set to 1
๐Ÿ’ฅEffecta normal user is now an admin
Hidden or extra fields are trivial to add. Your form's HTML is not a security boundary.
Step by step

Write queries and saves that can't be hijacked

  1. Use Eloquent and the query builder normally

    Methods like where(), find() and create() 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.

  2. Going raw? Use ? bindings

    Sometimes you need a SQL expression Eloquent doesn't offer. Methods like whereRaw(), selectRaw(), orderByRaw() and DB::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 escaped

    DB::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();
              
  3. 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();
            
  4. Declare $fillable (or $guarded) on every model

    $fillable is an allow-list of attributes that create() and update() 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
              }
            

    $guarded is the opposite: a block-list. protected $guarded = ['is_admin']; allows everything except is_admin. Prefer $fillable: when you add a new sensitive column later, it's protected by default.

  5. Save only validated() data

    A 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_id comes from the relationship, not from the form. Set ownership and other sensitive values explicitly on the server.

Common mistakes

Avoid this, do that

โŒ Avoid

          Post::whereRaw("title = '$title'")->get();
        

The variable is pasted into the SQL, so a quote in $title rewrites the query.

โœ… Do

          Post::whereRaw('title = ?', [$title])->get();
          // or simply:
          Post::where('title', $title)->get();
        

The value travels as a binding and can never become SQL.

โŒ Avoid

          protected $guarded = [];

          $user->update($request->all());
        

Every column is writable, and every request field is passed in. Two safety nets removed at once.

โœ… Do

          protected $fillable = ['name', 'bio'];

          $user->update($request->validated());
        

Only known, validated fields reach the model, and only allowed columns are written.

๐Ÿ’ก Pro tip: make mistakes loud in development

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());
        }
      
๐Ÿ‹๏ธ Try it yourself
  1. Build a post search with Post::where('title', 'like', ...). Search for ' OR '1'='1 and confirm no extra posts appear.
  2. Run Post::whereRaw('LOWER(title) = ?', ['hello'])->toSql() in php artisan tinker and look at the ? placeholder.
  3. Add a sortable posts list that only accepts title or created_at as the sort column.
  4. Add an is_featured column, leave it out of $fillable, and try to set it via Post::create([...]). Check the database.
  5. Enable preventSilentlyDiscardingAttributes and repeat the previous task to see the exception.
Recap

Key takeaways

Check yourself

Mini quiz